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
13 changes: 11 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function parseApiMessage(body: string): string {
const MAX_RETRIES = 5;
const INITIAL_DELAY_MS = 2000;
const REQUEST_DELAY_MS = 700; // Delay between requests to avoid rate limits
const MAX_RETRY_JITTER_MS = 1000;

let lastRequestTime = 0;

Expand All @@ -90,6 +91,14 @@ function shouldRetry(status: number): boolean {
return status === 429 || (status >= 500 && status < 600);
}

function retryDelayMs(attempt: number): number {
const base = INITIAL_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.floor(
Math.random() * Math.min(base * 0.25, MAX_RETRY_JITTER_MS),
);
return base + jitter;
}

export async function vapiRequest<T = VapiResponse>(
method: "POST" | "PATCH",
endpoint: string,
Expand Down Expand Up @@ -127,7 +136,7 @@ export async function vapiRequest<T = VapiResponse>(
}

if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
const delay = retryDelayMs(attempt);
const reason =
response.status === 429
? "Rate limited"
Expand Down Expand Up @@ -169,7 +178,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
}

if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
const delay = retryDelayMs(attempt);
const reason =
response.status === 429
? "Rate limited"
Expand Down
49 changes: 49 additions & 0 deletions src/assistant-references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Vapi resources are open-ended JSON config objects. This helper intentionally
// visits only documented resource-reference locations; a generic recursive
// `assistantId` rewrite could corrupt customer metadata or function schemas.
type VapiConfigObject = Record<string, unknown>;

function isConfigObject(value: unknown): value is VapiConfigObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function visitDestinations(
value: unknown,
visit: (owner: VapiConfigObject, assistantId: string) => void,
): void {
if (!Array.isArray(value)) return;

for (const destination of value) {
if (!isConfigObject(destination)) continue;
if (typeof destination.assistantId !== "string") continue;
visit(destination, destination.assistantId);
}
}

/** Visit assistant references in supported Vapi resource shapes. */
export function visitAssistantIdReferences(
data: VapiConfigObject,
visit: (owner: VapiConfigObject, assistantId: string) => void,
): void {
visitDestinations(data.destinations, visit);

if (!Array.isArray(data.members)) return;
for (const member of data.members) {
if (!isConfigObject(member)) continue;

if (typeof member.assistantId === "string") {
visit(member, member.assistantId);
}
visitDestinations(member.assistantDestinations, visit);

const overrides = member.assistantOverrides;
if (!isConfigObject(overrides)) continue;
const appendedTools = overrides["tools:append"];
if (!Array.isArray(appendedTools)) continue;

for (const tool of appendedTools) {
if (!isConfigObject(tool) || tool.type !== "handoff") continue;
visitDestinations(tool.destinations, visit);
}
}
}
49 changes: 5 additions & 44 deletions src/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// ─────────────────────────────────────────────────────────────────────────────

import { replaceCredentialRefs } from "./credentials.ts";
import { visitAssistantIdReferences } from "./assistant-references.ts";
import type { ResourceType, StateFile } from "./types.ts";

export interface VapiResource {
Expand Down Expand Up @@ -82,7 +83,7 @@ export function resolveReferencesToResourceIds(
const scenariosMap = buildReverseMap(state, "scenarios");
const simulationsMap = buildReverseMap(state, "simulations");

const resolved = { ...resource };
const resolved: Record<string, unknown> = structuredClone(resource);

// Resolve toolIds in model
if (resolved.model && typeof resolved.model === "object") {
Expand Down Expand Up @@ -116,49 +117,9 @@ export function resolveReferencesToResourceIds(
delete resolved.assistantIds;
}

// Resolve assistantId in tool destinations (handoff tools)
if (Array.isArray(resolved.destinations)) {
resolved.destinations = (
resolved.destinations as Record<string, unknown>[]
).map((dest) => {
if (typeof dest.assistantId === "string") {
return {
...dest,
assistantId: assistantsMap.get(dest.assistantId) ?? dest.assistantId,
};
}
return dest;
});
}

// Resolve members[].assistantId in squads
if (Array.isArray(resolved.members)) {
resolved.members = (resolved.members as Record<string, unknown>[]).map(
(member) => {
const resolvedMember = { ...member };
if (typeof member.assistantId === "string") {
resolvedMember.assistantId =
assistantsMap.get(member.assistantId) ?? member.assistantId;
}
// Resolve assistantDestinations[].assistantId
if (Array.isArray(member.assistantDestinations)) {
resolvedMember.assistantDestinations = (
member.assistantDestinations as Record<string, unknown>[]
).map((dest) => {
if (typeof dest.assistantId === "string") {
return {
...dest,
assistantId:
assistantsMap.get(dest.assistantId) ?? dest.assistantId,
};
}
return dest;
});
}
return resolvedMember;
},
);
}
visitAssistantIdReferences(resolved, (owner, assistantId) => {
owner.assistantId = assistantsMap.get(assistantId) ?? assistantId;
});

// Resolve personalityId in simulations
if (typeof resolved.personalityId === "string") {
Expand Down
27 changes: 22 additions & 5 deletions src/promote-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { PromotionConfig, PromotionPipeline } from "./promotion.ts";
import type {
PromotionConfig,
PromotionPipeline,
PromotionPlan,
} from "./promotion.ts";
import {
promotionConfigParse,
promotionPlanApply,
Expand Down Expand Up @@ -193,6 +197,22 @@ function stateLoad(org: string) {
return promotionStateParse(readFileSync(path, "utf8"));
}

export function promotionApplyArguments(plan: PromotionPlan): string[] {
const args = ["--resolve=ours"];
if (plan.changes.some((change) => change.kind === "delete")) {
args.push("--force");
}
if (plan.changes.some((change) => change.kind === "create")) {
args.push("--allow-new-files");
}
args.push(
...plan.changes.map(
(change) => `resources/${plan.target}/${change.path}`,
),
);
return args;
}

async function transitionRun(
config: PromotionConfig,
transition: PromotionTransition,
Expand Down Expand Up @@ -232,14 +252,11 @@ async function transitionRun(
if (plan.changes.length === 0) console.log(" no changes");
if (!apply || plan.changes.length === 0) return false;
await promotionPlanApply(plan);
const changedPaths = plan.changes.map(
(change) => `resources/${transition.target}/${change.path}`,
);
childRun(
"src/apply.ts",
transition.target,
connectionLoad(config, transition.target, tokens),
["--force", "--allow-new-files", "--resolve=ours", ...changedPaths],
promotionApplyArguments(plan),
);
return plan.changes.some((change) => change.kind === "delete");
}
Expand Down
46 changes: 45 additions & 1 deletion src/promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,40 @@ function renderContent(data: unknown, path: string, body?: string): string {
return extname(path) === ".md" ? `---\n${yaml}---\n${body ?? ""}` : yaml;
}

function normalizeMarkdownBody(body: string | undefined): string {
return (body ?? "").replace(/\r\n/g, "\n").replace(/\n+$/g, "");
}

async function promotionContentMatches(
existing: PromotionResource,
desiredData: unknown,
desiredBody: string | undefined,
options: PromotionPlanOptions,
): Promise<boolean> {
if (extname(existing.path) === ".ts") return false;

const current = await resourceData(
existing,
options.rootDir,
options.target,
);
const desiredComparable = referencesCanonicalize(
desiredData,
options.targetState,
);
const currentComparable = referencesCanonicalize(
current.data,
options.targetState,
);
if (!isDeepStrictEqual(desiredComparable, currentComparable)) return false;

return (
extname(existing.path) !== ".md" ||
normalizeMarkdownBody(desiredBody) ===
normalizeMarkdownBody(current.body)
);
}

function dependenciesFind(
value: unknown,
sourceState: StateFile,
Expand Down Expand Up @@ -613,7 +647,17 @@ export async function promotionPlanBuild(
? file.content
: renderContent(transformed, file.path, parsed.body);
const existing = targetByPath.get(file.path);
if (!existing || existing.content !== content)
const contentMatches = existing
? extname(file.path) === ".ts"
? existing.content === content
: await promotionContentMatches(
existing,
transformed,
parsed.body,
options,
)
: false;
if (!existing || !contentMatches)
changes.push({
kind: existing ? "update" : "create",
path: file.path,
Expand Down
69 changes: 52 additions & 17 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,23 +153,60 @@ export function preserveExplicitOursPaths(
// API Functions
// ─────────────────────────────────────────────────────────────────────────────

const TARGETED_PULL_CONCURRENCY = 5;

function isVapiResource(value: unknown): value is VapiResource {
return (
value !== null &&
typeof value === "object" &&
"id" in value &&
typeof value.id === "string"
);
}

export async function mapWithConcurrency<Input, Output>(
values: readonly Input[],
concurrency: number,
mapper: (value: Input, index: number) => Promise<Output>,
): Promise<Output[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("Concurrency must be a positive integer");
}

const results = new Array<Output>(values.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < values.length) {
const index = nextIndex++;
results[index] = await mapper(values[index]!, index);
}
}

const workerCount = Math.min(concurrency, values.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}

export async function fetchAllResources(
resourceType: ResourceType,
): Promise<VapiResource[]> {
const endpoint = ENDPOINT_MAP[resourceType];
const endpoint =
resourceType === "assistants"
? `${ENDPOINT_MAP[resourceType]}?limit=1000`
: ENDPOINT_MAP[resourceType];
const data = await vapiGet<unknown>(endpoint);

// Handle paginated response format (e.g., structured-output returns { results: [], metadata: {} })
if (
data &&
typeof data === "object" &&
"results" in data &&
Array.isArray((data as Record<string, unknown>).results)
Array.isArray(data.results)
) {
return (data as { results: VapiResource[] }).results;
return data.results.filter(isVapiResource);
}

return data as VapiResource[];
return Array.isArray(data) ? data.filter(isVapiResource) : [];
}

export async function fetchResourceById(
Expand Down Expand Up @@ -587,18 +624,15 @@ export async function pullResourceType(
} = options;
console.log(`\n📥 Pulling ${resourceType}...`);

const allResources = (await fetchAllResources(resourceType)) ?? [];

if (!Array.isArray(allResources)) {
console.log(` ⚠️ No ${resourceType} found (API returned non-array)`);
return { created: 0, updated: 0, skipped: 0 };
}

let resources = allResources;
let resources: VapiResource[];
if (resourceIds?.length) {
const requestedIds = new Set(resourceIds);
resources = allResources.filter((resource) =>
requestedIds.has(resource.id),
const requestedResources = await mapWithConcurrency(
resourceIds,
TARGETED_PULL_CONCURRENCY,
(id) => fetchResourceById(resourceType, id),
);
resources = requestedResources.filter(
(resource): resource is VapiResource => resource !== null,
);
const foundIds = new Set(resources.map((resource) => resource.id));
const missingIds = resourceIds.filter((id) => !foundIds.has(id));
Expand All @@ -612,6 +646,7 @@ export async function pullResourceType(
);
}
} else {
resources = await fetchAllResources(resourceType);
console.log(` Found ${resources.length} ${resourceType} in Vapi`);
}

Expand Down Expand Up @@ -671,7 +706,7 @@ export async function pullResourceType(
// These are explicit opt-outs — the resource exists on the dashboard but
// this repo does not manage it. Do NOT track in state (so a future
// un-ignore pulls cleanly and so state doesn't accumulate stale entries).
if (!bootstrap && !force) {
if (!force) {
const matched = matchesIgnore(folderPath, resourceId);
if (matched) {
console.log(` 🚫 ${resourceId} (matched .vapi-ignore: ${matched})`);
Expand Down Expand Up @@ -1155,7 +1190,7 @@ export async function runPull(options: PullOptions = {}): Promise<PullResult> {
);
}

const ignorePatterns = !bootstrap && !force ? loadIgnorePatterns() : [];
const ignorePatterns = !force ? loadIgnorePatterns() : [];
if (ignorePatterns.length > 0) {
console.log(
`\n🚫 ${ignorePatterns.length} pattern(s) loaded from .vapi-ignore — matching resources will be skipped`,
Expand Down
Loading