Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/guides/promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ dependency is missing from source". Use personality files under
`simulations/personalities/` in any org you promote out of, gated or not
(`improvements.md` #38).

## Gradual rollout (optional)

Promote into an org one traffic step at a time instead of all at once. Add
`rollout:` to the destination org:

```yaml
# promotion.yml
orgs:
example-production:
rollout:
steps: [1, 5, 10, 25, 50, 100] # percent of calls on the new version
intervalMinutes: 30 # at least this long between steps
```

`rollout: {}` takes those defaults. Steps must increase, end at 100 and have
at most three decimal places; `intervalMinutes` is at least 15.

The org needs traffic splitting, which Vapi enables per org during its beta.
An `--apply` run checks every rollout org first and stops, with nothing
promoted, if one doesn't have it.

## Rolling Back a Promotion

Treat a promotion rollback as a new, auditable Git change: revert the source
Expand Down
7 changes: 7 additions & 0 deletions promotion.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ orgs:
support-line: bind
example-production:
baseUrl: https://api.vapi.ai
# Optional gradual rollout: a promotion into this org publishes each
# changed assistant at 0%, then moves its calls to the new version in
# these steps (percent), at most one step per intervalMinutes. Needs
# traffic splitting enabled for the org. See docs/guides/promotion.md.
# rollout:
# steps: [1, 5, 10, 25, 50, 100]
# intervalMinutes: 30
bindings:
credentials:
default: bind
Expand Down
2 changes: 1 addition & 1 deletion src/check-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { OrgConnection } from "./org-connection.ts";
import { childRun, connectionLoad, tokensParse } from "./org-connection.ts";
import { userAgentGet, vapiClientGet } from "./user-agent.ts";
import type { VapiConnection } from "./vapi-client.ts";
import { DEFAULT_BASE_URL } from "./vapi-client.ts";

const USAGE = [
"Usage:",
Expand All @@ -58,7 +59,6 @@ const USAGE = [

const DEFAULT_PAYLOAD_DIR = "tmp/check-payloads";
const DEFAULT_BUDGET_MINUTES = 60;
const DEFAULT_BASE_URL = "https://api.vapi.ai";
const TOKENS_ENV = "VAPI_CHECK_TOKENS";

interface CheckArgs {
Expand Down
32 changes: 31 additions & 1 deletion src/promote-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
promotionGatePlanLine,
promotionGateRun,
} from "./promotion-gate.ts";
import type { RolloutApi } from "./rollout-api.ts";
import { rolloutApiCreate } from "./rollout-api.ts";
import {
promotionConfigParse,
promotionPlanApply,
Expand Down Expand Up @@ -53,6 +55,8 @@ export interface PromotionDeps {
check: CheckDefinition,
connection: OrgConnection,
) => Promise<PromotionGateResult>;
// The traffic-allocation client for an org with a gradual rollout.
rolloutApi: (connection: OrgConnection) => RolloutApi;
}

// Gated orgs' checks, and the orgs whose check already passed in this run.
Expand Down Expand Up @@ -148,6 +152,29 @@ function orgConnection(
});
}

// Every org a transition here would roll out into must have traffic
// splitting, a beta the API gates per org. Checked once, before anything is
// promoted, so a missing flag stops the run instead of one transition in.
async function rolloutOrgsProbe(
config: PromotionConfig,
transitions: PromotionTransition[],
tokens: Map<string, string>,
deps: PromotionDeps,
): Promise<void> {
const orgs = new Set(
transitions
.map((transition) => transition.target)
.filter((target) => config.orgs[target]?.rollout),
);
for (const org of orgs) {
const api = deps.rolloutApi(orgConnection(config, org, tokens));
if (!(await api.allocationsProbe()))
throw new Error(
`Traffic splitting is not enabled for ${org}, so its rollout: setting in promotion.yml can't run. Ask Vapi to enable traffic splitting for the org, or remove rollout: from ${org}.`,
);
}
}

function orgScriptRun(
script: string,
org: string,
Expand Down Expand Up @@ -308,6 +335,7 @@ export async function promotionCommandRun(
childRun: orgScriptRun,
checkRun: (check, connection) =>
promotionGateRun(ROOT_DIR, check, connection),
rolloutApi: rolloutApiCreate,
...overrides,
};
const parsed = argumentsParse(args);
Expand All @@ -331,7 +359,9 @@ export async function promotionCommandRun(
// Applying a deletion removes the intermediate org's state entry. Carry the
// reviewed authorization forward so the same deletion can reach later orgs.
const deletionAuthorizedSources = new Set<string>();
for (const transition of transitionsBuild(config, parsed)) {
const transitions = transitionsBuild(config, parsed);
if (parsed.apply) await rolloutOrgsProbe(config, transitions, tokens, deps);
for (const transition of transitions) {
const sourceKey = `${transition.pipeline}:${transition.source}`;
const deleted = await transitionRun(
config,
Expand Down
2 changes: 1 addition & 1 deletion src/promotion-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import { checkRunAll, MAX_CONCURRENT, MIN_START_MS } from "./check-run.ts";
import type { OrgConnection } from "./org-connection.ts";
import type { PromotionConfig } from "./promotion.ts";
import { userAgentGet, vapiClientGet } from "./user-agent.ts";
import { DEFAULT_BASE_URL } from "./vapi-client.ts";

export interface PromotionGateResult {
outcome: CheckOutcome;
reason: string;
url?: string;
}

const DEFAULT_BASE_URL = "https://api.vapi.ai";
// Gate checks must fit well inside the promotion step's timeout, so a check
// that can't finish is rejected when the config loads, not killed mid-run.
export const GATE_BUDGET_MINUTES = 300;
Expand Down
75 changes: 74 additions & 1 deletion src/promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,23 @@ export interface PromotionBindings {
phoneNumbers: PromotionBindingMap;
}

// A gradual rollout: a promotion into the org publishes changed assistants at
// 0% and then moves their traffic to the new version step by step, one step
// per `intervalMinutes`, instead of all at once.
export interface PromotionRollout {
// Percent of calls on the new version at each step; the last is 100.
steps: number[];
// Minimum minutes between steps. The rollout workflow ticks every 15.
intervalMinutes: number;
}

export interface PromotionOrg {
baseUrl?: string;
bindings: PromotionBindings;
// A vapi-checks.yml check that must pass in this org before anything is
// promoted out of it.
check?: string;
rollout?: PromotionRollout;
}

export interface PromotionPipeline {
Expand Down Expand Up @@ -145,7 +156,66 @@ export function promotionBindingsParse(value: unknown): PromotionBindings {
};
}

const PROMOTION_ORG_KEYS = ["baseUrl", "bindings", "check"];
const PROMOTION_ORG_KEYS = ["baseUrl", "bindings", "check", "rollout"];

export const ROLLOUT_STEPS_DEFAULT = [1, 5, 10, 25, 50, 100];
export const ROLLOUT_INTERVAL_MINUTES_DEFAULT = 30;
// The rollout workflow's cron period; a shorter interval could not be honored.
export const ROLLOUT_INTERVAL_MINUTES_MIN = 15;
const ROLLOUT_KEYS = ["steps", "intervalMinutes"];

// The API stores shares as integer thousandths of a percent.
export function millipercentage(percent: number): number {
return Math.round(percent * 1000);
}

export function promotionRolloutParse(
value: unknown,
slug: string,
): PromotionRollout {
const raw = object(value ?? {}, `org ${slug}.rollout`);
for (const key of Object.keys(raw))
if (!ROLLOUT_KEYS.includes(key))
throw new Error(
`org ${slug}.rollout has unknown key "${key}" (allowed: ${ROLLOUT_KEYS.join(", ")})`,
);
const steps = raw.steps ?? ROLLOUT_STEPS_DEFAULT;
if (
!Array.isArray(steps) ||
steps.length === 0 ||
!steps.every((step) => typeof step === "number" && Number.isFinite(step))
)
throw new Error(`org ${slug}.rollout.steps must be a list of percentages`);
for (const [index, step] of steps.entries()) {
if (
step < 0.001 ||
step > 100 ||
// 1.005 * 1000 is 1004.9999999999999 in floating point, so compare
// with a tolerance rather than exactly.
Math.abs(step * 1000 - millipercentage(step)) > 1e-6
)
throw new Error(
`org ${slug}.rollout.steps: ${step} must be between 0.001 and 100, with at most three decimal places`,
);
if (index > 0 && step <= steps[index - 1]!)
throw new Error(
`org ${slug}.rollout.steps must increase: ${step} follows ${steps[index - 1]}`,
);
}
if (steps[steps.length - 1] !== 100)
throw new Error(`org ${slug}.rollout.steps must end at 100`);
const intervalMinutes =
raw.intervalMinutes ?? ROLLOUT_INTERVAL_MINUTES_DEFAULT;
if (
typeof intervalMinutes !== "number" ||
!Number.isInteger(intervalMinutes) ||
intervalMinutes < ROLLOUT_INTERVAL_MINUTES_MIN
)
throw new Error(
`org ${slug}.rollout.intervalMinutes must be a whole number of at least ${ROLLOUT_INTERVAL_MINUTES_MIN}`,
);
return { steps: [...steps], intervalMinutes };
}

export function promotionConfigParse(content: string): PromotionConfig {
const raw = object(parseYaml(content), "promotion.yml");
Expand Down Expand Up @@ -174,6 +244,9 @@ export function promotionConfigParse(content: string): PromotionConfig {
baseUrl: typeof org.baseUrl === "string" ? org.baseUrl : undefined,
bindings: promotionBindingsParse(org.bindings),
...(typeof org.check === "string" ? { check: org.check } : {}),
...("rollout" in org
? { rollout: promotionRolloutParse(org.rollout, slug) }
: {}),
};
}
const pipelinesRaw = object(raw.pipelines, "promotion.yml pipelines");
Expand Down
Loading
Loading