@oxygen-agent/cli 1.846.6 → 1.851.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.
- package/README.md +1 -1
- package/dist/help.js +3 -9
- package/dist/index.js +80 -52
- package/node_modules/@oxygen/shared/dist/billing.d.ts +14 -14
- package/node_modules/@oxygen/shared/dist/billing.js +17 -18
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +12 -5
- package/node_modules/@oxygen/shared/dist/plan-limits.js +13 -11
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +15 -0
- package/node_modules/@oxygen/shared/dist/sending-seat-capacity.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/sending-seat-capacity.js +87 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +63 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.js +101 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +31 -11
- package/node_modules/@oxygen/shared/dist/sequences.js +42 -63
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/help.js
CHANGED
|
@@ -17,10 +17,7 @@ const HELP_GROUPS = [
|
|
|
17
17
|
"api-keys",
|
|
18
18
|
"whoami",
|
|
19
19
|
"status",
|
|
20
|
-
|
|
21
|
-
// rather than in "Other commands", because "what do I do next" is a
|
|
22
|
-
// getting-started question and this is the only surface that answers it.
|
|
23
|
-
"activation",
|
|
20
|
+
"home",
|
|
24
21
|
"orgs",
|
|
25
22
|
"commands",
|
|
26
23
|
"skills",
|
|
@@ -142,12 +139,9 @@ export function applyOxygenHelp(program, binaryName) {
|
|
|
142
139
|
` 2. ${binaryName} skills install --json load the skills that teach the GTM loops (automatic after login)`,
|
|
143
140
|
` 3. ${binaryName} context resolve --json load workspace context before operating primitives`,
|
|
144
141
|
` 4. ${binaryName} capabilities search "<goal>" --json route the goal, then hydrate one exact command`,
|
|
145
|
-
|
|
146
|
-
// answer is a prescribed play — not a capability search over 70 command
|
|
147
|
-
// groups. `activation` names the step this workspace is on right now.
|
|
148
|
-
` 5. ${binaryName} recipes list --stage day-1 --json the prescribed day-1 play; ${binaryName} activation shows where you are in it`,
|
|
142
|
+
` 5. ${binaryName} recipes list --json choose a play from the Recipe catalog for your goal`,
|
|
149
143
|
` Provider inventory: ${binaryName} tools search <brand> --json, then ${binaryName} tools search --provider <provider-id> --all --json (Blitz uses blitzapi).`,
|
|
150
|
-
`
|
|
144
|
+
` Installable systems and recurring monitors: ${binaryName} blueprints list --json.`,
|
|
151
145
|
" Docs: https://oxygen-agent.com/docs",
|
|
152
146
|
"",
|
|
153
147
|
"Conventions:",
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
9
9
|
import { Command, CommanderError, Option } from "commander";
|
|
10
10
|
import { applyOxygenHelp } from "./help.js";
|
|
11
11
|
import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
|
|
12
|
-
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
12
|
+
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
13
13
|
import { TAG_COLORS } from "@oxygen/shared/select-options";
|
|
14
14
|
import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
|
|
15
15
|
import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
|
|
@@ -250,8 +250,9 @@ const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
|
|
|
250
250
|
const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
|
|
251
251
|
// The only numbers on `tables import --help` used to be --batch-size (a
|
|
252
252
|
// per-request chunk) and the background threshold, so users read 500 as a
|
|
253
|
-
// per-file cap. Echo the
|
|
254
|
-
|
|
253
|
+
// per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
|
|
254
|
+
// of restating either as a chunk-size constraint.
|
|
255
|
+
const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: ${TABLE_IMPORT_ROW_LIMIT.toLocaleString("en-US")} rows on every plan. File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
|
|
255
256
|
const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
256
257
|
const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
|
|
257
258
|
// Single-row paid runs are auto-backgrounded server-side; the CLI waits this
|
|
@@ -2613,7 +2614,7 @@ export function createProgram() {
|
|
|
2613
2614
|
});
|
|
2614
2615
|
program
|
|
2615
2616
|
.command("home")
|
|
2616
|
-
.description("Your workspace standup: what happened since you last looked, what needs you, a year of workspace activity
|
|
2617
|
+
.description("Your workspace standup: what happened since you last looked, what needs you, and a year of workspace activity. Read-only, 0 credits. The same neutral composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
|
|
2617
2618
|
.option("--json", "Print a JSON envelope.")
|
|
2618
2619
|
.action(async (options) => {
|
|
2619
2620
|
await handleAsyncAction("home standup", options, readHomeStandup);
|
|
@@ -2623,7 +2624,9 @@ export function createProgram() {
|
|
|
2623
2624
|
// is the exact name discovery routes to (it is a gatewayCommand of the
|
|
2624
2625
|
// onboarding-and-copilot capability route). Both call the one read.
|
|
2625
2626
|
const activationCommand = program
|
|
2626
|
-
.command("activation")
|
|
2627
|
+
.command("activation", { hidden: true })
|
|
2628
|
+
// Backward-compatible exact invocation only. The fixed play is no longer a
|
|
2629
|
+
// first-touch CLI surface; users choose motions in Recipes or Blueprints.
|
|
2627
2630
|
.description(ACTIVATION_DESCRIPTION)
|
|
2628
2631
|
.option("--json", "Print a JSON envelope.")
|
|
2629
2632
|
.action(async (options) => {
|
|
@@ -8391,6 +8394,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8391
8394
|
.option("--json", "Print a JSON envelope.")
|
|
8392
8395
|
.action(async (options) => {
|
|
8393
8396
|
await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
|
|
8397
|
+
}))
|
|
8398
|
+
.addCommand(new Command("invoices")
|
|
8399
|
+
.description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace billed through another organization sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
|
|
8400
|
+
.option("--json", "Print a JSON envelope.")
|
|
8401
|
+
.action(async (options) => {
|
|
8402
|
+
await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
|
|
8394
8403
|
}))
|
|
8395
8404
|
.addCommand(new Command("allowance")
|
|
8396
8405
|
.description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
|
|
@@ -10305,10 +10314,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10305
10314
|
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
10306
10315
|
}))
|
|
10307
10316
|
.addCommand(new Command("connect")
|
|
10308
|
-
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
|
|
10317
|
+
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
|
|
10309
10318
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
10310
10319
|
.option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
|
|
10311
10320
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
10321
|
+
.option("--cookie-auth", "Offer LinkedIn cookie authentication in Unipile's hosted wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
|
|
10322
|
+
.option("--custom-proxy", "Let the account owner enter a custom proxy in Unipile's hosted wizard. OXYGEN never receives the proxy credentials.")
|
|
10312
10323
|
.option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
|
|
10313
10324
|
.option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
|
|
10314
10325
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -10324,6 +10335,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10324
10335
|
...(reconnect ? { reconnect_connection_id: reconnect } : {}),
|
|
10325
10336
|
...(country ? { country } : {}),
|
|
10326
10337
|
...(options.salesNav ? { sales_nav: true } : {}),
|
|
10338
|
+
...(options.cookieAuth ? { cookie_auth: true } : {}),
|
|
10339
|
+
...(options.customProxy ? { custom_proxy: true } : {}),
|
|
10327
10340
|
...(count ? { count } : {}),
|
|
10328
10341
|
...(inboxScope ? { inbox_sync_scope: inboxScope } : {}),
|
|
10329
10342
|
},
|
|
@@ -12342,7 +12355,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12342
12355
|
}
|
|
12343
12356
|
}))
|
|
12344
12357
|
.addCommand(new Command("update")
|
|
12345
|
-
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence
|
|
12358
|
+
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
|
|
12346
12359
|
.argument("<sequence>", "Sequence id or slug.")
|
|
12347
12360
|
.option("--name <name>", "New human-readable sequence name.")
|
|
12348
12361
|
.option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
|
|
@@ -14064,6 +14077,62 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14064
14077
|
},
|
|
14065
14078
|
});
|
|
14066
14079
|
});
|
|
14080
|
+
}))
|
|
14081
|
+
.addCommand(new Command("profile")
|
|
14082
|
+
.description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, age ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
|
|
14083
|
+
.addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
|
|
14084
|
+
// NOT --profile: the program declares a GLOBAL --profile (the stored
|
|
14085
|
+
// CLI credential profile), and Commander resolves the value to that
|
|
14086
|
+
// one, so the subcommand's copy silently reads empty. --type is also
|
|
14087
|
+
// the word an operator uses for this ("a specific warm-up type").
|
|
14088
|
+
.option("--type <name>", "Warm-up type to pin: standard, dedicated_tenant, or entry.")
|
|
14089
|
+
.option("--domains <list>", "Comma-separated domains to bind. Every inbox on them inherits the profile.")
|
|
14090
|
+
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to override individually — the exception path, not the bulk path.")
|
|
14091
|
+
.option("--all", "Bind every domain that currently holds a mailbox.")
|
|
14092
|
+
.option("--cold-send-per-day <n>", "Override the profile's DOMAIN budget for cold sends per day (not a per-inbox number).")
|
|
14093
|
+
.option("--warmup-per-day <n>", "Override the profile's DOMAIN budget for warm-up emails per day (not a per-inbox number).")
|
|
14094
|
+
.option("--clear", "Remove the profile, restoring inference and the age ramp alone.")
|
|
14095
|
+
.option("--approved", "Apply the previewed change.")
|
|
14096
|
+
.option("--json", "Print a JSON envelope.")
|
|
14097
|
+
.action(async (options) => {
|
|
14098
|
+
await handleAsyncAction("mailboxes warmup profile", options, () => {
|
|
14099
|
+
const domains = readCsvOption(options.domains);
|
|
14100
|
+
const mailboxes = readCsvOption(options.mailboxes);
|
|
14101
|
+
const all = options.all === true;
|
|
14102
|
+
const clear = options.clear === true;
|
|
14103
|
+
const profile = readOption(options.type);
|
|
14104
|
+
// No selector and no profile is the discovery call: show what
|
|
14105
|
+
// the profiles ARE rather than erroring on a missing flag.
|
|
14106
|
+
if (!all && domains.length === 0 && mailboxes.length === 0 && !profile && !clear) {
|
|
14107
|
+
return requestOxygen("/api/cli/mailboxes/warmup-profile");
|
|
14108
|
+
}
|
|
14109
|
+
if (!profile && !clear) {
|
|
14110
|
+
throw new OxygenError("invalid_request", "Name a --type to pin, or pass --clear to remove one.", { exitCode: 2 });
|
|
14111
|
+
}
|
|
14112
|
+
const coldSend = readOption(options.coldSendPerDay);
|
|
14113
|
+
const warmupPerDay = readOption(options.warmupPerDay);
|
|
14114
|
+
if ((coldSend === null) !== (warmupPerDay === null)) {
|
|
14115
|
+
throw new OxygenError("invalid_request", "A budget override needs BOTH --cold-send-per-day and --warmup-per-day; they are separate budgets and defaulting one silently would misstate the domain's ceiling.", { exitCode: 2 });
|
|
14116
|
+
}
|
|
14117
|
+
return requestOxygen("/api/cli/mailboxes/warmup-profile", {
|
|
14118
|
+
method: "POST",
|
|
14119
|
+
body: {
|
|
14120
|
+
profile: clear ? null : profile,
|
|
14121
|
+
...(coldSend !== null && warmupPerDay !== null
|
|
14122
|
+
? {
|
|
14123
|
+
budget: {
|
|
14124
|
+
cold_send_per_day: Number(coldSend),
|
|
14125
|
+
warmup_per_day: Number(warmupPerDay),
|
|
14126
|
+
},
|
|
14127
|
+
}
|
|
14128
|
+
: {}),
|
|
14129
|
+
...(domains.length > 0 ? { domains } : {}),
|
|
14130
|
+
...(mailboxes.length > 0 ? { mailboxes } : {}),
|
|
14131
|
+
...(all ? { all: true } : {}),
|
|
14132
|
+
...(options.approved === true ? { approved: true } : {}),
|
|
14133
|
+
},
|
|
14134
|
+
});
|
|
14135
|
+
});
|
|
14067
14136
|
}))
|
|
14068
14137
|
.addCommand(new Command("pause")
|
|
14069
14138
|
.description("Pause warmup at the rail that actually enrolled each mailbox — OXYGEN Warm-up for current enrollments, TrulyInbox for inboxes still warming there. A mailbox left on a rail Oxygen no longer drives reports unsupported instead of being retargeted, so the reply never claims a pause that did not happen. Targets the whole pool unless --mailboxes is given.")
|
|
@@ -19287,10 +19356,10 @@ function readRecord(value, key) {
|
|
|
19287
19356
|
function tableWebUrl(tableIdOrSlug) {
|
|
19288
19357
|
return `https://oxygen-agent.com/tables/${encodeURIComponent(tableIdOrSlug)}`;
|
|
19289
19358
|
}
|
|
19290
|
-
function
|
|
19359
|
+
function formatImportFileSizeLimit(tier) {
|
|
19291
19360
|
const limits = PLAN_LIMITS[tier].import;
|
|
19292
19361
|
const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
|
|
19293
|
-
return `${
|
|
19362
|
+
return `${megabytes} MB`;
|
|
19294
19363
|
}
|
|
19295
19364
|
// A backgrounded import returns as soon as the file is staged, with counts.rows
|
|
19296
19365
|
// still 0 while the worker loads. With no follow-up command in the envelope that
|
|
@@ -19748,50 +19817,9 @@ async function handleUpdateAction(options) {
|
|
|
19748
19817
|
emitCliFailure("update", error);
|
|
19749
19818
|
}
|
|
19750
19819
|
}
|
|
19751
|
-
/**
|
|
19752
|
-
* `oxygen home`: the standup PLUS the prescribed play's next step.
|
|
19753
|
-
*
|
|
19754
|
-
* Two reads, because the web Home already composes exactly these two — its RSC calls
|
|
19755
|
-
* the standup composition and `readActivationState` side by side (apps/web/src/lib/
|
|
19756
|
-
* activation/state.ts) — and a terminal-first operator must not be the one surface
|
|
19757
|
-
* shown a standup with no next move. Both legs are existing read-only `/api/cli/*`
|
|
19758
|
-
* contracts, so neither surface knows anything the other cannot ask for.
|
|
19759
|
-
*
|
|
19760
|
-
* The activation leg is allSettled and never fatal: a standup that read fine must not
|
|
19761
|
-
* be thrown away because the play read failed. When it does fail the leg is NAMED in
|
|
19762
|
-
* `degraded` rather than dropped silently — an absent next step has to read as "we
|
|
19763
|
-
* could not tell", never as "there is nothing to do".
|
|
19764
|
-
*/
|
|
19820
|
+
/** `oxygen home`: the same outcome-neutral standup rendered by web Home. */
|
|
19765
19821
|
async function readHomeStandup() {
|
|
19766
|
-
|
|
19767
|
-
requestOxygen("/api/cli/home/standup"),
|
|
19768
|
-
requestOxygen("/api/cli/activation/state"),
|
|
19769
|
-
]);
|
|
19770
|
-
if (standup.status === "rejected")
|
|
19771
|
-
throw standup.reason;
|
|
19772
|
-
const data = standup.value;
|
|
19773
|
-
if (activation.status === "fulfilled") {
|
|
19774
|
-
return {
|
|
19775
|
-
...data,
|
|
19776
|
-
// The play's progress and its single next step; `oxygen activation` prints the
|
|
19777
|
-
// per-step detail this deliberately leaves out.
|
|
19778
|
-
activation: {
|
|
19779
|
-
play: activation.value.play ?? null,
|
|
19780
|
-
next_step: activation.value.next_step ?? null,
|
|
19781
|
-
safety: activation.value.safety ?? null,
|
|
19782
|
-
degraded: activation.value.degraded ?? [],
|
|
19783
|
-
full_state: "oxygen activation --json",
|
|
19784
|
-
},
|
|
19785
|
-
};
|
|
19786
|
-
}
|
|
19787
|
-
return {
|
|
19788
|
-
...data,
|
|
19789
|
-
activation: null,
|
|
19790
|
-
degraded: [
|
|
19791
|
-
...(Array.isArray(data.degraded) ? data.degraded : []),
|
|
19792
|
-
"activation",
|
|
19793
|
-
],
|
|
19794
|
-
};
|
|
19822
|
+
return requestOxygen("/api/cli/home/standup");
|
|
19795
19823
|
}
|
|
19796
19824
|
function buildApiKeyCreateBody(options) {
|
|
19797
19825
|
const body = {};
|
|
@@ -112,9 +112,10 @@ export declare function resolveCreditTopupPack(id: string | null | undefined): C
|
|
|
112
112
|
export declare const TRIAL_PERIOD_DAYS = 7;
|
|
113
113
|
export declare const TRIAL_CREDIT_GRANT = 20000;
|
|
114
114
|
/**
|
|
115
|
-
* Free-tier entry (founder-ratified 2026-08-16
|
|
115
|
+
* Free-tier entry (founder-ratified 2026-08-16; grant collapsed to a single
|
|
116
|
+
* 10,000-credit signup grant 2026-08-26).
|
|
116
117
|
*
|
|
117
|
-
*
|
|
118
|
+
* ONE grant, at signup, and no monthly trickle. The free tier's *surface* is
|
|
118
119
|
* unmetered — CRM, Tables, Workflows, Knowledge, Agents, Copilot, Sequence
|
|
119
120
|
* authoring cost nothing marginal, so they are not rationed — and credits meter
|
|
120
121
|
* only real per-call spend. That is why `BASE_PRICING_PLANS.free` still carries
|
|
@@ -122,21 +123,20 @@ export declare const TRIAL_CREDIT_GRANT = 20000;
|
|
|
122
123
|
* allowance exists), not a missing value, and null still means "never claw back
|
|
123
124
|
* a leftover balance".
|
|
124
125
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
126
|
+
* WHY THE SPLIT WENT AWAY. It used to be 5,000 at signup plus 5,000 on first
|
|
127
|
+
* CLI/MCP activation, to pull people toward the agent-native surface. Two things
|
|
128
|
+
* killed it. The marketing page advertises "10,000 credits", so a user who
|
|
129
|
+
* signed up and never touched the CLI saw half of what was promised — the page
|
|
130
|
+
* was writing a cheque the product did not honour. And the activation half
|
|
131
|
+
* needed its own idempotency key, its own trigger definition, and its own
|
|
132
|
+
* explanation, for a nudge that was never measured. One grant is honest and is
|
|
133
|
+
* one code path.
|
|
131
134
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* other path re-mints either one — not downgrade, churn, re-subscribe, or
|
|
135
|
-
* billing-owner re-link. Top-ups stay purchasable on free, which is what keeps
|
|
135
|
+
* Neither churn nor downgrade nor re-subscribe nor billing-owner re-link ever
|
|
136
|
+
* mints it a second time. Top-ups stay purchasable on free, which is what keeps
|
|
136
137
|
* the metered surface open-ended rather than capped at the grant.
|
|
137
138
|
*/
|
|
138
|
-
export declare const FREE_SIGNUP_GRANT_CREDITS =
|
|
139
|
-
export declare const FREE_ACTIVATION_GRANT_CREDITS = 5000;
|
|
139
|
+
export declare const FREE_SIGNUP_GRANT_CREDITS = 10000;
|
|
140
140
|
export declare const FREE_TIER_GRANTS_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_GRANTS_ENABLED";
|
|
141
141
|
/**
|
|
142
142
|
* Fail-closed rollout switch for the free-tier grants, matching the staged
|
|
@@ -166,9 +166,10 @@ export function resolveCreditTopupPack(id) {
|
|
|
166
166
|
export const TRIAL_PERIOD_DAYS = 7;
|
|
167
167
|
export const TRIAL_CREDIT_GRANT = 20_000;
|
|
168
168
|
/**
|
|
169
|
-
* Free-tier entry (founder-ratified 2026-08-16
|
|
169
|
+
* Free-tier entry (founder-ratified 2026-08-16; grant collapsed to a single
|
|
170
|
+
* 10,000-credit signup grant 2026-08-26).
|
|
170
171
|
*
|
|
171
|
-
*
|
|
172
|
+
* ONE grant, at signup, and no monthly trickle. The free tier's *surface* is
|
|
172
173
|
* unmetered — CRM, Tables, Workflows, Knowledge, Agents, Copilot, Sequence
|
|
173
174
|
* authoring cost nothing marginal, so they are not rationed — and credits meter
|
|
174
175
|
* only real per-call spend. That is why `BASE_PRICING_PLANS.free` still carries
|
|
@@ -176,21 +177,20 @@ export const TRIAL_CREDIT_GRANT = 20_000;
|
|
|
176
177
|
* allowance exists), not a missing value, and null still means "never claw back
|
|
177
178
|
* a leftover balance".
|
|
178
179
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
180
|
+
* WHY THE SPLIT WENT AWAY. It used to be 5,000 at signup plus 5,000 on first
|
|
181
|
+
* CLI/MCP activation, to pull people toward the agent-native surface. Two things
|
|
182
|
+
* killed it. The marketing page advertises "10,000 credits", so a user who
|
|
183
|
+
* signed up and never touched the CLI saw half of what was promised — the page
|
|
184
|
+
* was writing a cheque the product did not honour. And the activation half
|
|
185
|
+
* needed its own idempotency key, its own trigger definition, and its own
|
|
186
|
+
* explanation, for a nudge that was never measured. One grant is honest and is
|
|
187
|
+
* one code path.
|
|
185
188
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* other path re-mints either one — not downgrade, churn, re-subscribe, or
|
|
189
|
-
* billing-owner re-link. Top-ups stay purchasable on free, which is what keeps
|
|
189
|
+
* Neither churn nor downgrade nor re-subscribe nor billing-owner re-link ever
|
|
190
|
+
* mints it a second time. Top-ups stay purchasable on free, which is what keeps
|
|
190
191
|
* the metered surface open-ended rather than capped at the grant.
|
|
191
192
|
*/
|
|
192
|
-
export const FREE_SIGNUP_GRANT_CREDITS =
|
|
193
|
-
export const FREE_ACTIVATION_GRANT_CREDITS = 5_000;
|
|
193
|
+
export const FREE_SIGNUP_GRANT_CREDITS = 10_000;
|
|
194
194
|
export const FREE_TIER_GRANTS_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_GRANTS_ENABLED";
|
|
195
195
|
/**
|
|
196
196
|
* Fail-closed rollout switch for the free-tier grants, matching the staged
|
|
@@ -248,10 +248,9 @@ export const BASE_PRICING_PLANS = {
|
|
|
248
248
|
//
|
|
249
249
|
// `free` now serves TWO populations and deliberately does not distinguish
|
|
250
250
|
// them here: a newly signed-up workspace and a voluntarily churned one get the
|
|
251
|
-
// same capability set. Only the
|
|
252
|
-
// FREE_SIGNUP_GRANT_CREDITS
|
|
253
|
-
//
|
|
254
|
-
// already had. Anything branching on `plan.tier === "free"` must be read under
|
|
251
|
+
// same capability set. Only the grant differs — a newcomer draws
|
|
252
|
+
// FREE_SIGNUP_GRANT_CREDITS, a returner draws nothing and keeps whatever
|
|
253
|
+
// balance it already had. Anything branching on `plan.tier === "free"` must be read under
|
|
255
254
|
// BOTH meanings: code written when this could only mean "churned org that can
|
|
256
255
|
// spend nothing" now also applies to a funded newcomer.
|
|
257
256
|
//
|
|
@@ -8,6 +8,8 @@ export * from "./capability-discovery.js";
|
|
|
8
8
|
export * from "./user-capability-routing.js";
|
|
9
9
|
export * from "./plan-capabilities.js";
|
|
10
10
|
export * from "./plan-limits.js";
|
|
11
|
+
export * from "./sending-seats.js";
|
|
12
|
+
export * from "./sending-seat-capacity.js";
|
|
11
13
|
export * from "./plain-support-events.js";
|
|
12
14
|
export * from "./provider-funding-errors.js";
|
|
13
15
|
export * from "./publishing-limits.js";
|
|
@@ -8,6 +8,8 @@ export * from "./capability-discovery.js";
|
|
|
8
8
|
export * from "./user-capability-routing.js";
|
|
9
9
|
export * from "./plan-capabilities.js";
|
|
10
10
|
export * from "./plan-limits.js";
|
|
11
|
+
export * from "./sending-seats.js";
|
|
12
|
+
export * from "./sending-seat-capacity.js";
|
|
11
13
|
export * from "./plain-support-events.js";
|
|
12
14
|
export * from "./provider-funding-errors.js";
|
|
13
15
|
export * from "./publishing-limits.js";
|
|
@@ -70,6 +70,13 @@ export type PlanLimits = {
|
|
|
70
70
|
maxActionsPerRun: number;
|
|
71
71
|
};
|
|
72
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* One platform-wide Table import ceiling. A file can fill an empty physical
|
|
75
|
+
* Table exactly to its retained-row capacity on every plan; imports into a
|
|
76
|
+
* non-empty Table remain subject to that Table's remaining capacity and the
|
|
77
|
+
* workspace-wide capacity envelope.
|
|
78
|
+
*/
|
|
79
|
+
export declare const TABLE_IMPORT_ROW_LIMIT: 3000000;
|
|
73
80
|
export { VERCEL_REQUEST_BODY_LIMIT_BYTES } from "./import-limits.js";
|
|
74
81
|
/**
|
|
75
82
|
* The per-rung limit matrix. `ai_live` deliberately equals `tool_live` at every
|
|
@@ -165,7 +172,7 @@ export declare const PLAN_LIMITS: {
|
|
|
165
172
|
};
|
|
166
173
|
};
|
|
167
174
|
readonly import: {
|
|
168
|
-
readonly maxRowsPerFile:
|
|
175
|
+
readonly maxRowsPerFile: 3000000;
|
|
169
176
|
readonly maxFileBytes: number;
|
|
170
177
|
readonly maxDirectCsvBodyBytes: 10000000;
|
|
171
178
|
};
|
|
@@ -275,7 +282,7 @@ export declare const PLAN_LIMITS: {
|
|
|
275
282
|
};
|
|
276
283
|
};
|
|
277
284
|
readonly import: {
|
|
278
|
-
readonly maxRowsPerFile:
|
|
285
|
+
readonly maxRowsPerFile: 3000000;
|
|
279
286
|
readonly maxFileBytes: number;
|
|
280
287
|
readonly maxDirectCsvBodyBytes: 10000000;
|
|
281
288
|
};
|
|
@@ -385,7 +392,7 @@ export declare const PLAN_LIMITS: {
|
|
|
385
392
|
};
|
|
386
393
|
};
|
|
387
394
|
readonly import: {
|
|
388
|
-
readonly maxRowsPerFile:
|
|
395
|
+
readonly maxRowsPerFile: 3000000;
|
|
389
396
|
readonly maxFileBytes: number;
|
|
390
397
|
readonly maxDirectCsvBodyBytes: 10000000;
|
|
391
398
|
};
|
|
@@ -495,7 +502,7 @@ export declare const PLAN_LIMITS: {
|
|
|
495
502
|
};
|
|
496
503
|
};
|
|
497
504
|
readonly import: {
|
|
498
|
-
readonly maxRowsPerFile:
|
|
505
|
+
readonly maxRowsPerFile: 3000000;
|
|
499
506
|
readonly maxFileBytes: number;
|
|
500
507
|
readonly maxDirectCsvBodyBytes: 10000000;
|
|
501
508
|
};
|
|
@@ -605,7 +612,7 @@ export declare const PLAN_LIMITS: {
|
|
|
605
612
|
};
|
|
606
613
|
};
|
|
607
614
|
readonly import: {
|
|
608
|
-
readonly maxRowsPerFile:
|
|
615
|
+
readonly maxRowsPerFile: 3000000;
|
|
609
616
|
readonly maxFileBytes: number;
|
|
610
617
|
readonly maxDirectCsvBodyBytes: 10000000;
|
|
611
618
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveBasePricingPlan } from "./billing.js";
|
|
2
|
+
import { WORKSPACE_TABLE_CAPACITY } from "./table-capacity.js";
|
|
2
3
|
export const LIMITS_TIER_ORDER = [
|
|
3
4
|
"free",
|
|
4
5
|
"starter",
|
|
@@ -9,6 +10,13 @@ export const LIMITS_TIER_ORDER = [
|
|
|
9
10
|
const HOUR = 60 * 60;
|
|
10
11
|
const DAY = 24 * HOUR;
|
|
11
12
|
const MIB = 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* One platform-wide Table import ceiling. A file can fill an empty physical
|
|
15
|
+
* Table exactly to its retained-row capacity on every plan; imports into a
|
|
16
|
+
* non-empty Table remain subject to that Table's remaining capacity and the
|
|
17
|
+
* workspace-wide capacity envelope.
|
|
18
|
+
*/
|
|
19
|
+
export const TABLE_IMPORT_ROW_LIMIT = WORKSPACE_TABLE_CAPACITY.tableRowLimit;
|
|
12
20
|
// The platform request-body ceiling belongs to the same question as the import
|
|
13
21
|
// ceilings below ("how big can an import be"), but it must stay importable from a
|
|
14
22
|
// browser bundle, so it is defined in ./import-limits and re-exported here.
|
|
@@ -38,13 +46,7 @@ export const PLAN_LIMITS = {
|
|
|
38
46
|
import: { requestsPerMinute: 10, orgRequests: { limit: 100, windowSeconds: HOUR } },
|
|
39
47
|
},
|
|
40
48
|
import: {
|
|
41
|
-
|
|
42
|
-
// the staged upload path. The old ceiling was set when every import had to
|
|
43
|
-
// squeeze through a request body; now the bytes go straight to object
|
|
44
|
-
// storage and the worker streams them, so the limit can describe what a
|
|
45
|
-
// trial workspace should be allowed to load rather than what the transport
|
|
46
|
-
// could survive.
|
|
47
|
-
maxRowsPerFile: 250_000,
|
|
49
|
+
maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
|
|
48
50
|
maxFileBytes: 100 * MIB,
|
|
49
51
|
maxDirectCsvBodyBytes: 10_000_000,
|
|
50
52
|
},
|
|
@@ -93,7 +95,7 @@ export const PLAN_LIMITS = {
|
|
|
93
95
|
import: { requestsPerMinute: 30, orgRequests: { limit: 500, windowSeconds: HOUR } },
|
|
94
96
|
},
|
|
95
97
|
import: {
|
|
96
|
-
maxRowsPerFile:
|
|
98
|
+
maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
|
|
97
99
|
maxFileBytes: 250 * MIB,
|
|
98
100
|
maxDirectCsvBodyBytes: 10_000_000,
|
|
99
101
|
},
|
|
@@ -126,7 +128,7 @@ export const PLAN_LIMITS = {
|
|
|
126
128
|
import: { requestsPerMinute: 60, orgRequests: { limit: 1_000, windowSeconds: HOUR } },
|
|
127
129
|
},
|
|
128
130
|
import: {
|
|
129
|
-
maxRowsPerFile:
|
|
131
|
+
maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
|
|
130
132
|
maxFileBytes: 250 * MIB,
|
|
131
133
|
maxDirectCsvBodyBytes: 10_000_000,
|
|
132
134
|
},
|
|
@@ -159,7 +161,7 @@ export const PLAN_LIMITS = {
|
|
|
159
161
|
import: { requestsPerMinute: 120, orgRequests: { limit: 2_000, windowSeconds: HOUR } },
|
|
160
162
|
},
|
|
161
163
|
import: {
|
|
162
|
-
maxRowsPerFile:
|
|
164
|
+
maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
|
|
163
165
|
maxFileBytes: 250 * MIB,
|
|
164
166
|
maxDirectCsvBodyBytes: 10_000_000,
|
|
165
167
|
},
|
|
@@ -192,7 +194,7 @@ export const PLAN_LIMITS = {
|
|
|
192
194
|
import: { requestsPerMinute: 240, orgRequests: { limit: 5_000, windowSeconds: HOUR } },
|
|
193
195
|
},
|
|
194
196
|
import: {
|
|
195
|
-
maxRowsPerFile:
|
|
197
|
+
maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
|
|
196
198
|
maxFileBytes: 250 * MIB,
|
|
197
199
|
maxDirectCsvBodyBytes: 10_000_000,
|
|
198
200
|
},
|
|
@@ -28,6 +28,13 @@ export declare const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
|
|
|
28
28
|
export declare const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
|
|
29
29
|
/** Representative, not billed: a typical .com at MANAGED_DOMAIN_MARKUP. */
|
|
30
30
|
export declare const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13050;
|
|
31
|
+
export declare const SENDING_SEAT_USD_CENTS: {
|
|
32
|
+
readonly linkedin: 3000;
|
|
33
|
+
readonly whatsapp: 3000;
|
|
34
|
+
readonly phone_number: 1000;
|
|
35
|
+
readonly email_sender: 100;
|
|
36
|
+
};
|
|
37
|
+
export type SendingSeatKey = keyof typeof SENDING_SEAT_USD_CENTS;
|
|
31
38
|
export declare const VOICE_NUMBER_CREDITS_PER_MONTH = 5000;
|
|
32
39
|
export declare const VOICE_CREDITS_PER_MINUTE = 90;
|
|
33
40
|
export declare const VOICE_CREDITS_PER_AMD_CALL = 38;
|
|
@@ -45,6 +45,21 @@ export const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
|
|
|
45
45
|
export const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
|
|
46
46
|
/** Representative, not billed: a typical .com at MANAGED_DOMAIN_MARKUP. */
|
|
47
47
|
export const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13_050;
|
|
48
|
+
// --- sending seats (USD, Stripe-billed) --------------------------------------
|
|
49
|
+
//
|
|
50
|
+
// The seat model (OXP-15). These are DOLLARS, not credits, and they do not
|
|
51
|
+
// replace the credit-denominated seat charges above — an organization
|
|
52
|
+
// grandfathered at the cutover keeps paying those. See the seed for why both
|
|
53
|
+
// exist at once.
|
|
54
|
+
//
|
|
55
|
+
// Stripe's Price object is what actually charges the card; these are what the
|
|
56
|
+
// product quotes and what the margin gate checks against vendor COGS.
|
|
57
|
+
export const SENDING_SEAT_USD_CENTS = {
|
|
58
|
+
linkedin: 3_000,
|
|
59
|
+
whatsapp: 3_000,
|
|
60
|
+
phone_number: 1_000,
|
|
61
|
+
email_sender: 100,
|
|
62
|
+
};
|
|
48
63
|
// --- voice ------------------------------------------------------------------
|
|
49
64
|
export const VOICE_NUMBER_CREDITS_PER_MONTH = 5_000;
|
|
50
65
|
export const VOICE_CREDITS_PER_MINUTE = 90;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this workspace have room to connect one more sender of a given kind?
|
|
3
|
+
*
|
|
4
|
+
* Pure arithmetic, deliberately. The rule that decides whether a customer can
|
|
5
|
+
* send is the single most consequential branch in the billing model, and it has
|
|
6
|
+
* to be readable and exhaustively testable without a database, a Stripe account,
|
|
7
|
+
* or a webhook.
|
|
8
|
+
*
|
|
9
|
+
* THE RULE. Capacity comes from two places and they ADD:
|
|
10
|
+
*
|
|
11
|
+
* capacity = purchased (Stripe seats) + legacy (grandfathered entitlement)
|
|
12
|
+
* available = capacity - allocated (senders already connected)
|
|
13
|
+
*
|
|
14
|
+
* GRANDFATHERING IS AN ENTITLEMENT, NOT A BYPASS. A legacy organization is not
|
|
15
|
+
* one that skips this function; it is one that arrives here holding a legacy
|
|
16
|
+
* grant which SATISFIES the check. Written the other way — an `if (isLegacy)
|
|
17
|
+
* return true` somewhere upstream — the most important gate in the billing model
|
|
18
|
+
* would carry a permanent exemption that no query can enumerate and that stops
|
|
19
|
+
* being applied correctly the moment the gate changes. Here, the gate always
|
|
20
|
+
* runs, and "why was this allowed" always has an answer.
|
|
21
|
+
*
|
|
22
|
+
* UNLIMITED IS REPRESENTABLE. `legacy: null` means unlimited for that kind. It
|
|
23
|
+
* exists because whether grandfathered organizations keep their cutover count or
|
|
24
|
+
* get unlimited capacity is a business decision that belongs in data, not in a
|
|
25
|
+
* code branch — so the answer can change with an UPDATE rather than a release.
|
|
26
|
+
*/
|
|
27
|
+
export type SendingSeatHolding = {
|
|
28
|
+
/** Seats bought through Stripe. Stripe's item quantity is the truth. */
|
|
29
|
+
readonly purchased: number;
|
|
30
|
+
/** Grandfathered capacity. null means unlimited; 0 means none. */
|
|
31
|
+
readonly legacy: number | null;
|
|
32
|
+
/** Senders of this kind currently connected. */
|
|
33
|
+
readonly allocated: number;
|
|
34
|
+
};
|
|
35
|
+
export type SendingSeatCapacity = {
|
|
36
|
+
readonly allowed: boolean;
|
|
37
|
+
/** Remaining capacity, or null when the holding is unlimited. */
|
|
38
|
+
readonly available: number | null;
|
|
39
|
+
/** Total capacity, or null when unlimited. */
|
|
40
|
+
readonly capacity: number | null;
|
|
41
|
+
readonly allocated: number;
|
|
42
|
+
/**
|
|
43
|
+
* Which grant covers the NEXT connection — the answer to "why am I allowed to
|
|
44
|
+
* do this", which a support conversation always eventually needs.
|
|
45
|
+
*/
|
|
46
|
+
readonly coveredBy: "legacy_entitlement" | "purchased_seat" | "none";
|
|
47
|
+
};
|
|
48
|
+
export declare function resolveSendingSeatCapacity(holding: SendingSeatHolding): SendingSeatCapacity;
|
|
49
|
+
/**
|
|
50
|
+
* May this organization reduce its purchased seats to `nextPurchased`?
|
|
51
|
+
*
|
|
52
|
+
* Refused when it would strand a connected sender. Never reduce-then-orphan: an
|
|
53
|
+
* orphaned sender is a customer still paying a provider for something OXYGEN has
|
|
54
|
+
* quietly stopped honouring, and they find out when messages stop arriving.
|
|
55
|
+
*
|
|
56
|
+
* Returns the number that must be disconnected first, so the refusal can say
|
|
57
|
+
* what to actually do rather than only saying no.
|
|
58
|
+
*/
|
|
59
|
+
export declare function seatReductionShortfall(holding: SendingSeatHolding, nextPurchased: number): number;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this workspace have room to connect one more sender of a given kind?
|
|
3
|
+
*
|
|
4
|
+
* Pure arithmetic, deliberately. The rule that decides whether a customer can
|
|
5
|
+
* send is the single most consequential branch in the billing model, and it has
|
|
6
|
+
* to be readable and exhaustively testable without a database, a Stripe account,
|
|
7
|
+
* or a webhook.
|
|
8
|
+
*
|
|
9
|
+
* THE RULE. Capacity comes from two places and they ADD:
|
|
10
|
+
*
|
|
11
|
+
* capacity = purchased (Stripe seats) + legacy (grandfathered entitlement)
|
|
12
|
+
* available = capacity - allocated (senders already connected)
|
|
13
|
+
*
|
|
14
|
+
* GRANDFATHERING IS AN ENTITLEMENT, NOT A BYPASS. A legacy organization is not
|
|
15
|
+
* one that skips this function; it is one that arrives here holding a legacy
|
|
16
|
+
* grant which SATISFIES the check. Written the other way — an `if (isLegacy)
|
|
17
|
+
* return true` somewhere upstream — the most important gate in the billing model
|
|
18
|
+
* would carry a permanent exemption that no query can enumerate and that stops
|
|
19
|
+
* being applied correctly the moment the gate changes. Here, the gate always
|
|
20
|
+
* runs, and "why was this allowed" always has an answer.
|
|
21
|
+
*
|
|
22
|
+
* UNLIMITED IS REPRESENTABLE. `legacy: null` means unlimited for that kind. It
|
|
23
|
+
* exists because whether grandfathered organizations keep their cutover count or
|
|
24
|
+
* get unlimited capacity is a business decision that belongs in data, not in a
|
|
25
|
+
* code branch — so the answer can change with an UPDATE rather than a release.
|
|
26
|
+
*/
|
|
27
|
+
function assertCount(label, value) {
|
|
28
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
29
|
+
throw new Error(`${label} must be a non-negative integer, got ${value}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function resolveSendingSeatCapacity(holding) {
|
|
33
|
+
assertCount("purchased", holding.purchased);
|
|
34
|
+
assertCount("allocated", holding.allocated);
|
|
35
|
+
if (holding.legacy !== null)
|
|
36
|
+
assertCount("legacy", holding.legacy);
|
|
37
|
+
// Unlimited legacy short-circuits: no arithmetic can exhaust it, and
|
|
38
|
+
// representing it as a very large number would eventually be compared,
|
|
39
|
+
// subtracted, or displayed, and would then be wrong in a way nobody catches.
|
|
40
|
+
if (holding.legacy === null) {
|
|
41
|
+
return {
|
|
42
|
+
allowed: true,
|
|
43
|
+
available: null,
|
|
44
|
+
capacity: null,
|
|
45
|
+
allocated: holding.allocated,
|
|
46
|
+
coveredBy: "legacy_entitlement",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const capacity = holding.purchased + holding.legacy;
|
|
50
|
+
// Floors at 0 rather than going negative. Allocated CAN exceed capacity — a
|
|
51
|
+
// grandfathered org whose entitlement is later reduced, or a seat cancelled
|
|
52
|
+
// while senders stay connected — and a negative "available" would read as a
|
|
53
|
+
// debt the customer can pay off by disconnecting, which is not what it means.
|
|
54
|
+
const available = Math.max(0, capacity - holding.allocated);
|
|
55
|
+
return {
|
|
56
|
+
allowed: available > 0,
|
|
57
|
+
available,
|
|
58
|
+
capacity,
|
|
59
|
+
allocated: holding.allocated,
|
|
60
|
+
// Legacy capacity is consumed FIRST. Any other order would bill a
|
|
61
|
+
// grandfathered customer for a seat while their free entitlement sat unused.
|
|
62
|
+
coveredBy: available === 0
|
|
63
|
+
? "none"
|
|
64
|
+
: holding.allocated < holding.legacy
|
|
65
|
+
? "legacy_entitlement"
|
|
66
|
+
: "purchased_seat",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* May this organization reduce its purchased seats to `nextPurchased`?
|
|
71
|
+
*
|
|
72
|
+
* Refused when it would strand a connected sender. Never reduce-then-orphan: an
|
|
73
|
+
* orphaned sender is a customer still paying a provider for something OXYGEN has
|
|
74
|
+
* quietly stopped honouring, and they find out when messages stop arriving.
|
|
75
|
+
*
|
|
76
|
+
* Returns the number that must be disconnected first, so the refusal can say
|
|
77
|
+
* what to actually do rather than only saying no.
|
|
78
|
+
*/
|
|
79
|
+
export function seatReductionShortfall(holding, nextPurchased) {
|
|
80
|
+
assertCount("nextPurchased", nextPurchased);
|
|
81
|
+
// Unlimited legacy capacity means purchased seats are not load-bearing and
|
|
82
|
+
// dropping them can never strand anything.
|
|
83
|
+
if (holding.legacy === null)
|
|
84
|
+
return 0;
|
|
85
|
+
const nextCapacity = nextPurchased + holding.legacy;
|
|
86
|
+
return Math.max(0, holding.allocated - nextCapacity);
|
|
87
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending seats: the recurring USD subscription a workspace holds in order to
|
|
3
|
+
* connect a sender (OXP-15, founder-ratified 2026-08-25).
|
|
4
|
+
*
|
|
5
|
+
* WHAT A SEAT IS. Capacity, not usage. One seat entitles one connected sender of
|
|
6
|
+
* that kind — a LinkedIn account, a WhatsApp number, a phone number, or an email
|
|
7
|
+
* mailbox. Sends themselves stay free: the seat is the whole price for the
|
|
8
|
+
* channel, which is why LINKEDIN_ACTION_CREDITS is all zeroes. Buying a seat is
|
|
9
|
+
* therefore buying the right to keep something connected for a month, and the
|
|
10
|
+
* count a customer needs is simply the number of senders they intend to run.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT THE CREDIT MODEL. Until the seat cutover the same charge was
|
|
13
|
+
* debited from the credit balance monthly (`seat.linkedin_account` = 10,000
|
|
14
|
+
* credits). That coupled two unrelated things: a fixed monthly cost that we owe
|
|
15
|
+
* a vendor whether or not the customer does anything, and a variable enrichment
|
|
16
|
+
* budget the customer chooses how to spend. A workspace that ran a big
|
|
17
|
+
* enrichment could not send, and a workspace that only sent looked like it was
|
|
18
|
+
* consuming a plan it never used. Seats put the fixed cost on a card and leave
|
|
19
|
+
* credits for the variable part.
|
|
20
|
+
*
|
|
21
|
+
* BOTH MODELS ARE LIVE AT ONCE, ON PURPOSE. Organizations that existed at the
|
|
22
|
+
* cutover are grandfathered and keep paying in credits. Nothing about their bill
|
|
23
|
+
* changes. See `SENDING_SEAT_LEGACY_CHARGE_KEYS` for the exact correspondence,
|
|
24
|
+
* and note the direction of the rule: a legacy organization is not one that
|
|
25
|
+
* "skips the seat check", it is one that holds a legacy entitlement which
|
|
26
|
+
* SATISFIES the seat check. The gate always runs.
|
|
27
|
+
*/
|
|
28
|
+
import { SENDING_SEAT_USD_CENTS, type SendingSeatKey } from "./pricing-snapshot.generated.js";
|
|
29
|
+
export { SENDING_SEAT_USD_CENTS };
|
|
30
|
+
export type { SendingSeatKey };
|
|
31
|
+
export declare const SENDING_SEAT_KEYS: readonly ["linkedin", "whatsapp", "phone_number", "email_sender"];
|
|
32
|
+
export type SendingSeatDefinition = {
|
|
33
|
+
readonly key: SendingSeatKey;
|
|
34
|
+
/** Singular, sentence-case, as it appears on the billing row. */
|
|
35
|
+
readonly label: string;
|
|
36
|
+
readonly pluralLabel: string;
|
|
37
|
+
readonly monthlyPriceCents: number;
|
|
38
|
+
/** What holding one lets the customer connect. */
|
|
39
|
+
readonly entitles: string;
|
|
40
|
+
/**
|
|
41
|
+
* The credit-denominated charge this seat replaces for organizations onboarded
|
|
42
|
+
* after the cutover. null where no credit equivalent ever existed.
|
|
43
|
+
*/
|
|
44
|
+
readonly legacyChargeKey: string | null;
|
|
45
|
+
/** Doppler-held Stripe Price ID env var; Stripe is what actually charges. */
|
|
46
|
+
readonly stripePriceEnvVar: string;
|
|
47
|
+
};
|
|
48
|
+
export declare const SENDING_SEAT_CATALOG: {
|
|
49
|
+
readonly [K in SendingSeatKey]: SendingSeatDefinition;
|
|
50
|
+
};
|
|
51
|
+
export declare const SENDING_SEAT_LEGACY_CHARGE_KEYS: readonly string[];
|
|
52
|
+
export declare function isSendingSeatKey(value: unknown): value is SendingSeatKey;
|
|
53
|
+
export declare function sendingSeatMonthlyPriceCents(key: SendingSeatKey): number;
|
|
54
|
+
/**
|
|
55
|
+
* Price for `quantity` seats of one kind, in USD cents.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately linear with no volume break. A tier boundary here would have to
|
|
58
|
+
* be mirrored into Stripe's Price object to be real, and a discount that exists
|
|
59
|
+
* in our quote but not on the invoice is a support ticket, not a discount.
|
|
60
|
+
*/
|
|
61
|
+
export declare function sendingSeatSubtotalCents(key: SendingSeatKey, quantity: number): number;
|
|
62
|
+
/** "$30.00" — seat prices are whole cents, so this never rounds. */
|
|
63
|
+
export declare function formatSeatPrice(cents: number): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending seats: the recurring USD subscription a workspace holds in order to
|
|
3
|
+
* connect a sender (OXP-15, founder-ratified 2026-08-25).
|
|
4
|
+
*
|
|
5
|
+
* WHAT A SEAT IS. Capacity, not usage. One seat entitles one connected sender of
|
|
6
|
+
* that kind — a LinkedIn account, a WhatsApp number, a phone number, or an email
|
|
7
|
+
* mailbox. Sends themselves stay free: the seat is the whole price for the
|
|
8
|
+
* channel, which is why LINKEDIN_ACTION_CREDITS is all zeroes. Buying a seat is
|
|
9
|
+
* therefore buying the right to keep something connected for a month, and the
|
|
10
|
+
* count a customer needs is simply the number of senders they intend to run.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT THE CREDIT MODEL. Until the seat cutover the same charge was
|
|
13
|
+
* debited from the credit balance monthly (`seat.linkedin_account` = 10,000
|
|
14
|
+
* credits). That coupled two unrelated things: a fixed monthly cost that we owe
|
|
15
|
+
* a vendor whether or not the customer does anything, and a variable enrichment
|
|
16
|
+
* budget the customer chooses how to spend. A workspace that ran a big
|
|
17
|
+
* enrichment could not send, and a workspace that only sent looked like it was
|
|
18
|
+
* consuming a plan it never used. Seats put the fixed cost on a card and leave
|
|
19
|
+
* credits for the variable part.
|
|
20
|
+
*
|
|
21
|
+
* BOTH MODELS ARE LIVE AT ONCE, ON PURPOSE. Organizations that existed at the
|
|
22
|
+
* cutover are grandfathered and keep paying in credits. Nothing about their bill
|
|
23
|
+
* changes. See `SENDING_SEAT_LEGACY_CHARGE_KEYS` for the exact correspondence,
|
|
24
|
+
* and note the direction of the rule: a legacy organization is not one that
|
|
25
|
+
* "skips the seat check", it is one that holds a legacy entitlement which
|
|
26
|
+
* SATISFIES the seat check. The gate always runs.
|
|
27
|
+
*/
|
|
28
|
+
import { SENDING_SEAT_USD_CENTS, } from "./pricing-snapshot.generated.js";
|
|
29
|
+
export { SENDING_SEAT_USD_CENTS };
|
|
30
|
+
export const SENDING_SEAT_KEYS = [
|
|
31
|
+
"linkedin",
|
|
32
|
+
"whatsapp",
|
|
33
|
+
"phone_number",
|
|
34
|
+
"email_sender",
|
|
35
|
+
];
|
|
36
|
+
export const SENDING_SEAT_CATALOG = {
|
|
37
|
+
linkedin: {
|
|
38
|
+
key: "linkedin",
|
|
39
|
+
label: "LinkedIn seat",
|
|
40
|
+
pluralLabel: "LinkedIn seats",
|
|
41
|
+
monthlyPriceCents: SENDING_SEAT_USD_CENTS.linkedin,
|
|
42
|
+
entitles: "one connected LinkedIn account",
|
|
43
|
+
legacyChargeKey: "seat.linkedin_account",
|
|
44
|
+
stripePriceEnvVar: "STRIPE_PRICE_SEAT_LINKEDIN_USD",
|
|
45
|
+
},
|
|
46
|
+
whatsapp: {
|
|
47
|
+
key: "whatsapp",
|
|
48
|
+
label: "WhatsApp seat",
|
|
49
|
+
pluralLabel: "WhatsApp seats",
|
|
50
|
+
monthlyPriceCents: SENDING_SEAT_USD_CENTS.whatsapp,
|
|
51
|
+
entitles: "one connected WhatsApp number",
|
|
52
|
+
legacyChargeKey: "seat.whatsapp_account",
|
|
53
|
+
stripePriceEnvVar: "STRIPE_PRICE_SEAT_WHATSAPP_USD",
|
|
54
|
+
},
|
|
55
|
+
phone_number: {
|
|
56
|
+
key: "phone_number",
|
|
57
|
+
label: "Phone number",
|
|
58
|
+
pluralLabel: "Phone numbers",
|
|
59
|
+
monthlyPriceCents: SENDING_SEAT_USD_CENTS.phone_number,
|
|
60
|
+
entitles: "one rented phone number",
|
|
61
|
+
// Voice numbers were their own credit commitment rather than a seat charge.
|
|
62
|
+
legacyChargeKey: null,
|
|
63
|
+
stripePriceEnvVar: "STRIPE_PRICE_SEAT_PHONE_USD",
|
|
64
|
+
},
|
|
65
|
+
email_sender: {
|
|
66
|
+
key: "email_sender",
|
|
67
|
+
label: "Email sender",
|
|
68
|
+
pluralLabel: "Email senders",
|
|
69
|
+
monthlyPriceCents: SENDING_SEAT_USD_CENTS.email_sender,
|
|
70
|
+
entitles: "one connected sending mailbox, warm-up included",
|
|
71
|
+
legacyChargeKey: "commitment.sending_mailbox",
|
|
72
|
+
stripePriceEnvVar: "STRIPE_PRICE_SEAT_EMAIL_USD",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
export const SENDING_SEAT_LEGACY_CHARGE_KEYS = SENDING_SEAT_KEYS
|
|
76
|
+
.map((key) => SENDING_SEAT_CATALOG[key].legacyChargeKey)
|
|
77
|
+
.filter((chargeKey) => chargeKey !== null);
|
|
78
|
+
export function isSendingSeatKey(value) {
|
|
79
|
+
return typeof value === "string"
|
|
80
|
+
&& SENDING_SEAT_KEYS.includes(value);
|
|
81
|
+
}
|
|
82
|
+
export function sendingSeatMonthlyPriceCents(key) {
|
|
83
|
+
return SENDING_SEAT_CATALOG[key].monthlyPriceCents;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Price for `quantity` seats of one kind, in USD cents.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately linear with no volume break. A tier boundary here would have to
|
|
89
|
+
* be mirrored into Stripe's Price object to be real, and a discount that exists
|
|
90
|
+
* in our quote but not on the invoice is a support ticket, not a discount.
|
|
91
|
+
*/
|
|
92
|
+
export function sendingSeatSubtotalCents(key, quantity) {
|
|
93
|
+
if (!Number.isInteger(quantity) || quantity < 0) {
|
|
94
|
+
throw new Error(`seat quantity must be a non-negative integer, got ${quantity}`);
|
|
95
|
+
}
|
|
96
|
+
return SENDING_SEAT_CATALOG[key].monthlyPriceCents * quantity;
|
|
97
|
+
}
|
|
98
|
+
/** "$30.00" — seat prices are whole cents, so this never rounds. */
|
|
99
|
+
export function formatSeatPrice(cents) {
|
|
100
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
101
|
+
}
|
|
@@ -776,6 +776,17 @@ export type ValidateSequenceOptions = {
|
|
|
776
776
|
* route refuses that transition outright).
|
|
777
777
|
*/
|
|
778
778
|
draft?: boolean;
|
|
779
|
+
/**
|
|
780
|
+
* This definition belongs to a STARTED sequence, where a wait may be zeroed
|
|
781
|
+
* but never deleted. Every in-flight enrollment tracks its position as an
|
|
782
|
+
* INTEGER index into steps[] (`current_step_index`), so removing a step
|
|
783
|
+
* renumbers the ones after it and silently walks live leads onto the wrong
|
|
784
|
+
* step — a re-send or a skipped message. Setting a wait to 0 keeps the array
|
|
785
|
+
* shape, and a 0 wait is a no-op the dispatcher passes straight through.
|
|
786
|
+
* Default false: a draft has no enrollments to shift, so its builder deletes
|
|
787
|
+
* the step outright rather than leaving a dead one behind.
|
|
788
|
+
*/
|
|
789
|
+
allowZeroWaits?: boolean;
|
|
779
790
|
};
|
|
780
791
|
/** One step whose copy is still empty, for the launch-readiness blocker. */
|
|
781
792
|
export type SequenceMissingCopy = {
|
|
@@ -816,19 +827,28 @@ export declare function sequenceWaitStepJitterMs(step: SequenceWaitStep, enrollm
|
|
|
816
827
|
*/
|
|
817
828
|
export declare function sequenceWaitStepDelayWithJitterMs(step: SequenceWaitStep, enrollmentId: string): number;
|
|
818
829
|
/**
|
|
819
|
-
* Whether `after` changes nothing about `before` except
|
|
820
|
-
*
|
|
830
|
+
* Whether `after` changes nothing about `before` except how long its existing
|
|
831
|
+
* wait steps wait. This is the one journey edit a LIVE sequence accepts, and it
|
|
832
|
+
* is deliberately narrower than "the delays are the same".
|
|
833
|
+
*
|
|
834
|
+
* TWO things make it safe, and both are load-bearing:
|
|
835
|
+
*
|
|
836
|
+
* 1. SPEND. A wait dispatches nothing, so estimateSequenceActions (the start
|
|
837
|
+
* route's estimator) skips it entirely: re-timing cannot move the planned
|
|
838
|
+
* actions, the channel mix, or the credit estimate the launch approval was
|
|
839
|
+
* granted against.
|
|
821
840
|
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
841
|
+
* 2. POSITION. Every in-flight enrollment stores its place as an INTEGER index
|
|
842
|
+
* into steps[] (`current_step_index`, walked directly by the dispatcher).
|
|
843
|
+
* Inserting or deleting ANY step — a wait included — renumbers everything
|
|
844
|
+
* after it and marches live leads onto the wrong step: a message re-sent, or
|
|
845
|
+
* one silently skipped. So the step ids and their ORDER must be identical;
|
|
846
|
+
* only the fields of a wait already in place may change. Removing a delay on
|
|
847
|
+
* a live sequence is spelled "set it to 0" (see allowZeroWaits), which the
|
|
848
|
+
* dispatcher passes straight through and the builder renders as "No delay".
|
|
829
849
|
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
850
|
+
* A draft is exempt from both: it has no enrollments to shift and no approval
|
|
851
|
+
* to bypass, so its builder inserts and deletes waits freely.
|
|
832
852
|
*/
|
|
833
853
|
export declare function sequenceDefinitionsDifferOnlyInTiming(before: SequenceDefinition, after: SequenceDefinition): boolean;
|
|
834
854
|
/**
|
|
@@ -1065,7 +1065,7 @@ raw, index, options, issues) {
|
|
|
1065
1065
|
case "wait": {
|
|
1066
1066
|
const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
|
|
1067
1067
|
const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
|
|
1068
|
-
if ((days ?? 0) + (hours ?? 0) <= 0) {
|
|
1068
|
+
if ((days ?? 0) + (hours ?? 0) <= 0 && !options.allowZeroWaits) {
|
|
1069
1069
|
issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
|
|
1070
1070
|
}
|
|
1071
1071
|
const jitterHours = optionalWaitJitterHours(raw.jitter_hours, `${path}.jitter_hours`, issues);
|
|
@@ -1835,14 +1835,7 @@ export function sequenceWaitStepJitterMs(step, enrollmentId) {
|
|
|
1835
1835
|
export function sequenceWaitStepDelayWithJitterMs(step, enrollmentId) {
|
|
1836
1836
|
return sequenceWaitStepDelayMs(step) + sequenceWaitStepJitterMs(step, enrollmentId);
|
|
1837
1837
|
}
|
|
1838
|
-
/**
|
|
1839
|
-
* Branch fields that name another step. A signal branch spells its arms `then`/
|
|
1840
|
-
* `else`; a connection branch spells them `then_id`/`else_id`. Both are erased
|
|
1841
|
-
* from the skeleton and replaced by one normalized pair, so an arm that is absent
|
|
1842
|
-
* on one side and explicitly null on the other still compares equal.
|
|
1843
|
-
*/
|
|
1844
|
-
const BRANCH_TARGET_KEYS = ["then", "else", "then_id", "else_id"];
|
|
1845
|
-
/** Key-sorted JSON, so two structurally equal definitions compare equal whatever order their keys were built in. */
|
|
1838
|
+
/** Key-sorted JSON, so two structurally equal steps compare equal whatever order their keys were built in. */
|
|
1846
1839
|
function stableJson(value) {
|
|
1847
1840
|
if (Array.isArray(value))
|
|
1848
1841
|
return `[${value.map(stableJson).join(",")}]`;
|
|
@@ -1855,65 +1848,51 @@ function stableJson(value) {
|
|
|
1855
1848
|
return JSON.stringify(value) ?? "null";
|
|
1856
1849
|
}
|
|
1857
1850
|
/**
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1851
|
+
* Whether `after` changes nothing about `before` except how long its existing
|
|
1852
|
+
* wait steps wait. This is the one journey edit a LIVE sequence accepts, and it
|
|
1853
|
+
* is deliberately narrower than "the delays are the same".
|
|
1861
1854
|
*
|
|
1862
|
-
*
|
|
1863
|
-
* a step, so an arm whose delay was edited legitimately repoints from one wait id
|
|
1864
|
-
* to another (setBranchArmDelay in the web builder does exactly that) — a naive
|
|
1865
|
-
* filter would read that as a rerouted branch.
|
|
1866
|
-
*/
|
|
1867
|
-
function sequenceTimingSkeleton(definition) {
|
|
1868
|
-
const steps = Array.isArray(definition?.steps) ? definition.steps : [];
|
|
1869
|
-
const indexById = new Map(steps.map((step, index) => [step.id, index]));
|
|
1870
|
-
// The first non-wait step at or after `id`. A dangling target, an absent one,
|
|
1871
|
-
// and a trailing run of waits all end the path — all resolve to null.
|
|
1872
|
-
const throughWaits = (id) => {
|
|
1873
|
-
if (typeof id !== "string")
|
|
1874
|
-
return null;
|
|
1875
|
-
const from = indexById.get(id);
|
|
1876
|
-
if (from === undefined)
|
|
1877
|
-
return null;
|
|
1878
|
-
for (let k = from; k < steps.length; k++) {
|
|
1879
|
-
const step = steps[k];
|
|
1880
|
-
if (step.kind !== "wait")
|
|
1881
|
-
return step.id;
|
|
1882
|
-
}
|
|
1883
|
-
return null;
|
|
1884
|
-
};
|
|
1885
|
-
const actions = steps
|
|
1886
|
-
.filter((step) => step.kind !== "wait")
|
|
1887
|
-
.map((step) => {
|
|
1888
|
-
const fields = { ...step };
|
|
1889
|
-
const branch = step;
|
|
1890
|
-
for (const key of BRANCH_TARGET_KEYS)
|
|
1891
|
-
delete fields[key];
|
|
1892
|
-
return {
|
|
1893
|
-
...fields,
|
|
1894
|
-
arm_then: throughWaits(branch.then ?? branch.then_id),
|
|
1895
|
-
arm_else: throughWaits(branch.else ?? branch.else_id),
|
|
1896
|
-
};
|
|
1897
|
-
});
|
|
1898
|
-
return stableJson(actions);
|
|
1899
|
-
}
|
|
1900
|
-
/**
|
|
1901
|
-
* Whether `after` changes nothing about `before` except its timing — the delays
|
|
1902
|
-
* between steps, including the lead-in delay in front of the very first one.
|
|
1855
|
+
* TWO things make it safe, and both are load-bearing:
|
|
1903
1856
|
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
* planned actions, the channel mix, or the credit estimate the approval was
|
|
1909
|
-
* granted against. What a delay does change is when the next step fires — exactly
|
|
1910
|
-
* the knob a founder reaches for after watching day one of a live campaign.
|
|
1857
|
+
* 1. SPEND. A wait dispatches nothing, so estimateSequenceActions (the start
|
|
1858
|
+
* route's estimator) skips it entirely: re-timing cannot move the planned
|
|
1859
|
+
* actions, the channel mix, or the credit estimate the launch approval was
|
|
1860
|
+
* granted against.
|
|
1911
1861
|
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1862
|
+
* 2. POSITION. Every in-flight enrollment stores its place as an INTEGER index
|
|
1863
|
+
* into steps[] (`current_step_index`, walked directly by the dispatcher).
|
|
1864
|
+
* Inserting or deleting ANY step — a wait included — renumbers everything
|
|
1865
|
+
* after it and marches live leads onto the wrong step: a message re-sent, or
|
|
1866
|
+
* one silently skipped. So the step ids and their ORDER must be identical;
|
|
1867
|
+
* only the fields of a wait already in place may change. Removing a delay on
|
|
1868
|
+
* a live sequence is spelled "set it to 0" (see allowZeroWaits), which the
|
|
1869
|
+
* dispatcher passes straight through and the builder renders as "No delay".
|
|
1870
|
+
*
|
|
1871
|
+
* A draft is exempt from both: it has no enrollments to shift and no approval
|
|
1872
|
+
* to bypass, so its builder inserts and deletes waits freely.
|
|
1914
1873
|
*/
|
|
1915
1874
|
export function sequenceDefinitionsDifferOnlyInTiming(before, after) {
|
|
1916
|
-
|
|
1875
|
+
const oldSteps = Array.isArray(before?.steps) ? before.steps : [];
|
|
1876
|
+
const newSteps = Array.isArray(after?.steps) ? after.steps : [];
|
|
1877
|
+
if (oldSteps.length !== newSteps.length)
|
|
1878
|
+
return false;
|
|
1879
|
+
return oldSteps.every((oldStep, index) => {
|
|
1880
|
+
const newStep = newSteps[index];
|
|
1881
|
+
// Same step, same slot — anything else renumbers a live enrollment's cursor.
|
|
1882
|
+
if (oldStep.id !== newStep.id || oldStep.kind !== newStep.kind)
|
|
1883
|
+
return false;
|
|
1884
|
+
if (oldStep.kind !== "wait")
|
|
1885
|
+
return stableJson(oldStep) === stableJson(newStep);
|
|
1886
|
+
// A wait may be re-timed, and nothing else about it may move.
|
|
1887
|
+
const timingFree = (step) => {
|
|
1888
|
+
const fields = { ...step };
|
|
1889
|
+
delete fields.days;
|
|
1890
|
+
delete fields.hours;
|
|
1891
|
+
delete fields.jitter_hours;
|
|
1892
|
+
return stableJson(fields);
|
|
1893
|
+
};
|
|
1894
|
+
return timingFree(oldStep) === timingFree(newStep);
|
|
1895
|
+
});
|
|
1917
1896
|
}
|
|
1918
1897
|
/**
|
|
1919
1898
|
* Render a sequence-copy template against a row's values. Delegates to the shared
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.851.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.
|
|
1
|
+
export const OXYGEN_VERSION = "1.851.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:
|