From 5ff84141e007cbf2d3f8a8cd537d530770fbd5a8 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Thu, 23 Jul 2026 11:13:27 -0700 Subject: [PATCH] fix: harden cross-org promotion safety --- src/api.ts | 13 +- src/assistant-references.ts | 49 +++++++ src/canonical.ts | 49 +------ src/promote-cmd.ts | 27 +++- src/promotion.ts | 46 ++++++- src/pull.ts | 69 +++++++--- src/resolver.ts | 78 ++--------- src/rollback-cmd.ts | 8 +- src/setup.ts | 23 +--- src/snapshot.ts | 9 ++ tests/apply-scoped-pull.test.ts | 23 +++- tests/promotion.test.ts | 81 ++++++++++++ tests/pull-bootstrap.test.ts | 228 ++++++++++++++++++++++++++++++++ tests/resolver.test.ts | 126 ++++++++++++++++++ tests/snapshot.test.ts | 29 ++++ 15 files changed, 698 insertions(+), 160 deletions(-) create mode 100644 src/assistant-references.ts create mode 100644 tests/pull-bootstrap.test.ts create mode 100644 tests/resolver.test.ts diff --git a/src/api.ts b/src/api.ts index a639eab..7839605 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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; @@ -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( method: "POST" | "PATCH", endpoint: string, @@ -127,7 +136,7 @@ export async function vapiRequest( } 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" @@ -169,7 +178,7 @@ export async function vapiGet(endpoint: string): Promise { } 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" diff --git a/src/assistant-references.ts b/src/assistant-references.ts new file mode 100644 index 0000000..34de282 --- /dev/null +++ b/src/assistant-references.ts @@ -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; + +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); + } + } +} diff --git a/src/canonical.ts b/src/canonical.ts index 5c49b62..20a0e5d 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -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 { @@ -82,7 +83,7 @@ export function resolveReferencesToResourceIds( const scenariosMap = buildReverseMap(state, "scenarios"); const simulationsMap = buildReverseMap(state, "simulations"); - const resolved = { ...resource }; + const resolved: Record = structuredClone(resource); // Resolve toolIds in model if (resolved.model && typeof resolved.model === "object") { @@ -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[] - ).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[]).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[] - ).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") { diff --git a/src/promote-cmd.ts b/src/promote-cmd.ts index d55c86c..ee28ee6 100644 --- a/src/promote-cmd.ts +++ b/src/promote-cmd.ts @@ -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, @@ -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, @@ -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"); } diff --git a/src/promotion.ts b/src/promotion.ts index b4dbfb3..ba58a7a 100644 --- a/src/promotion.ts +++ b/src/promotion.ts @@ -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 { + 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, @@ -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, diff --git a/src/pull.ts b/src/pull.ts index fec6cf4..cf62088 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -153,10 +153,47 @@ 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( + values: readonly Input[], + concurrency: number, + mapper: (value: Input, index: number) => Promise, +): Promise { + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error("Concurrency must be a positive integer"); + } + + const results = new Array(values.length); + let nextIndex = 0; + async function worker(): Promise { + 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 { - const endpoint = ENDPOINT_MAP[resourceType]; + const endpoint = + resourceType === "assistants" + ? `${ENDPOINT_MAP[resourceType]}?limit=1000` + : ENDPOINT_MAP[resourceType]; const data = await vapiGet(endpoint); // Handle paginated response format (e.g., structured-output returns { results: [], metadata: {} }) @@ -164,12 +201,12 @@ export async function fetchAllResources( data && typeof data === "object" && "results" in data && - Array.isArray((data as Record).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( @@ -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)); @@ -612,6 +646,7 @@ export async function pullResourceType( ); } } else { + resources = await fetchAllResources(resourceType); console.log(` Found ${resources.length} ${resourceType} in Vapi`); } @@ -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})`); @@ -1155,7 +1190,7 @@ export async function runPull(options: PullOptions = {}): Promise { ); } - 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`, diff --git a/src/resolver.ts b/src/resolver.ts index 5cde2b7..5631024 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,4 +1,5 @@ import type { ResourceState, StateFile } from "./types.ts"; +import { visitAssistantIdReferences } from "./assistant-references.ts"; // ───────────────────────────────────────────────────────────────────────────── // ID Resolution - Convert resource IDs to Vapi UUIDs @@ -179,7 +180,7 @@ export function resolveReferences( data: Record, state: StateFile, ): Record { - const resolved = JSON.parse(JSON.stringify(data)) as Record; + const resolved: Record = structuredClone(data); // Resolve toolIds at root level if (Array.isArray(resolved.toolIds)) { @@ -240,46 +241,10 @@ export function resolveReferences( } } - // Resolve assistantId in destinations[] (for handoff tools) - if (Array.isArray(resolved.destinations)) { - for (const destination of resolved.destinations as Record< - string, - unknown - >[]) { - if (typeof destination.assistantId === "string") { - const resolvedId = resolveAssistantId(destination.assistantId, state); - if (resolvedId) { - destination.assistantId = resolvedId; - } - } - } - } - - // Resolve members[].assistantId in squads - if (Array.isArray(resolved.members)) { - for (const member of resolved.members as Record[]) { - if (typeof member.assistantId === "string") { - const resolvedId = resolveAssistantId(member.assistantId, state); - if (resolvedId) { - member.assistantId = resolvedId; - } - } - // Resolve assistantDestinations[].assistantId - if (Array.isArray(member.assistantDestinations)) { - for (const dest of member.assistantDestinations as Record< - string, - unknown - >[]) { - if (typeof dest.assistantId === "string") { - const resolvedId = resolveAssistantId(dest.assistantId, state); - if (resolvedId) { - dest.assistantId = resolvedId; - } - } - } - } - } - } + visitAssistantIdReferences(resolved, (owner, assistantId) => { + const resolvedId = resolveAssistantId(assistantId, state); + if (resolvedId) owner.assistantId = resolvedId; + }); // Resolve personalityId in simulations if (typeof resolved.personalityId === "string") { @@ -402,34 +367,9 @@ export function extractReferencedIds( } } - // Check destinations[].assistantId (for handoff tools) - if (Array.isArray(data.destinations)) { - for (const destination of data.destinations as Record[]) { - if (typeof destination.assistantId === "string") { - assistants.push(cleanId(destination.assistantId)); - } - } - } - - // Check members[].assistantId in squads - if (Array.isArray(data.members)) { - for (const member of data.members as Record[]) { - if (typeof member.assistantId === "string") { - assistants.push(cleanId(member.assistantId)); - } - // Check assistantDestinations[].assistantId - if (Array.isArray(member.assistantDestinations)) { - for (const dest of member.assistantDestinations as Record< - string, - unknown - >[]) { - if (typeof dest.assistantId === "string") { - assistants.push(cleanId(dest.assistantId)); - } - } - } - } - } + visitAssistantIdReferences(data, (_owner, assistantId) => { + assistants.push(cleanId(assistantId)); + }); // Check personalityId in simulations if (typeof data.personalityId === "string") { diff --git a/src/rollback-cmd.ts b/src/rollback-cmd.ts index 2ec2db4..19dd8c5 100644 --- a/src/rollback-cmd.ts +++ b/src/rollback-cmd.ts @@ -11,7 +11,11 @@ import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts"; +import { + listSnapshotTimestamps, + loadSnapshot, + prepareRollbackPayload, +} from "./snapshot.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BASE_DIR = join(__dirname, ".."); @@ -188,7 +192,7 @@ async function main(): Promise { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", }, - body: JSON.stringify(entry.payload.platform), + body: JSON.stringify(prepareRollbackPayload(entry.payload.platform)), }); if (!response.ok) { const text = await response.text(); diff --git a/src/setup.ts b/src/setup.ts index b63d1f6..141ed6b 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -5,6 +5,7 @@ import { mkdir, readFile, rm, writeFile } from "fs/promises"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { updateEnvConnection } from "./bindings.ts"; +import { visitAssistantIdReferences } from "./assistant-references.ts"; import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js"; import { slugify } from "./slug-utils.ts"; @@ -192,25 +193,9 @@ function detectMissingDependencies( addRef("structuredOutputs", sid); } - if (Array.isArray(r.members)) { - for (const m of r.members as Record[]) { - addRef("assistants", m.assistantId); - if (Array.isArray(m.assistantDestinations)) { - for (const d of m.assistantDestinations as Record< - string, - unknown - >[]) { - addRef("assistants", d.assistantId); - } - } - } - } - - if (Array.isArray(r.destinations)) { - for (const d of r.destinations as Record[]) { - addRef("assistants", d.assistantId); - } - } + visitAssistantIdReferences(r, (_owner, assistantId) => { + addRef("assistants", assistantId); + }); if (Array.isArray(r.assistantIds)) { for (const aid of r.assistantIds) addRef("assistants", aid); diff --git a/src/snapshot.ts b/src/snapshot.ts index 30153bf..e43f3b7 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -18,6 +18,7 @@ import { existsSync } from "fs"; import { mkdir, readdir, readFile, writeFile } from "fs/promises"; import { join } from "path"; +import { cleanResource, type VapiResource } from "./canonical.ts"; import { sortedKeysReplacer } from "./state-serialize.ts"; export function snapshotsRoot(baseDir: string, env: string): string { @@ -94,6 +95,14 @@ export interface SnapshotEntry { payload: SnapshotPayload; } +export function prepareRollbackPayload( + platform: unknown, +): Record { + if (!platform || typeof platform !== "object" || Array.isArray(platform)) + throw new Error("Snapshot platform payload must be a resource object"); + return cleanResource(platform as VapiResource); +} + export async function loadSnapshot( baseDir: string, env: string, diff --git a/tests/apply-scoped-pull.test.ts b/tests/apply-scoped-pull.test.ts index bae62d0..e83ebc5 100644 --- a/tests/apply-scoped-pull.test.ts +++ b/tests/apply-scoped-pull.test.ts @@ -7,7 +7,9 @@ process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used"; const { parseResourceFilePath, resolvePullScopeFromFilePaths } = await import( "../src/resources.ts" ); -const { preserveExplicitOursPaths } = await import("../src/pull.ts"); +const { mapWithConcurrency, preserveExplicitOursPaths } = await import( + "../src/pull.ts" +); test("parseResourceFilePath: long-form assistant path", () => { const parsed = parseResourceFilePath( @@ -96,3 +98,22 @@ test("resolve ours preserves every scoped file when git misses a fresh rewrite", assert.deepEqual([...preserved].sort(), [assistant, output, tool].sort()); }); + +test("targeted fetch work is bounded by the requested concurrency", async () => { + let active = 0; + let maximumActive = 0; + const values = await mapWithConcurrency( + Array.from({ length: 12 }, (_, index) => index), + 3, + async (value) => { + active++; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active--; + return value * 2; + }, + ); + + assert.equal(maximumActive, 3); + assert.deepEqual(values, Array.from({ length: 12 }, (_, index) => index * 2)); +}); diff --git a/tests/promotion.test.ts b/tests/promotion.test.ts index 08e418c..d0e3394 100644 --- a/tests/promotion.test.ts +++ b/tests/promotion.test.ts @@ -10,6 +10,7 @@ import { promotionPlanBuild, promotionTransitionValidate, } from "../src/promotion.ts"; +import { promotionApplyArguments } from "../src/promote-cmd.ts"; import type { StateFile } from "../src/types.ts"; function state(entries: Partial = {}): StateFile { @@ -289,6 +290,86 @@ test("promotion canonicalizes source UUIDs and applies credential and phone poli } }); +test("promotion comparison canonicalizes both orgs and ignores terminal Markdown newlines", async () => { + const fx = await fixture(); + try { + const sourceAssistant = "11111111-1111-4111-8111-111111111111"; + const targetAssistant = "22222222-2222-4222-8222-222222222222"; + await put( + fx.root, + "resources/source/squads/relay.yml", + `members:\n - assistantId: ${sourceAssistant}\n`, + ); + await put( + fx.root, + "resources/target/squads/relay.yml", + `members:\n - assistantId: ${targetAssistant}\n`, + ); + await put( + fx.root, + "resources/source/assistants/agent.md", + "---\nname: Agent\n---\n# Prompt\n", + ); + await put( + fx.root, + "resources/target/assistants/agent.md", + "---\nname: Agent\n---\n# Prompt\n\n", + ); + + const plan = await promotionPlanBuild({ + rootDir: fx.root, + source: "source", + target: "target", + patterns: ["assistants/**", "squads/**"], + sourceState: state({ + assistants: { agent: { uuid: sourceAssistant } }, + }), + targetState: state({ + assistants: { agent: { uuid: targetAssistant } }, + }), + }); + + assert.deepEqual(plan.changes, []); + } finally { + await fx.cleanup(); + } +}); + +test("promotion apply arguments reflect only reviewed plan actions", () => { + assert.deepEqual( + promotionApplyArguments( + { + rootDir: "/tmp", + target: "target", + changes: [ + { kind: "update", path: "assistants/existing.md", content: "x" }, + ], + }, + ), + ["--resolve=ours", "resources/target/assistants/existing.md"], + ); + + const createArgs = promotionApplyArguments( + { + rootDir: "/tmp", + target: "target", + changes: [{ kind: "create", path: "tools/new.yml", content: "x" }], + }, + ); + assert.ok(createArgs.includes("--allow-new-files")); + assert.ok(!createArgs.includes("--force")); + + const deleteArgs = promotionApplyArguments( + { + rootDir: "/tmp", + target: "target", + changes: [{ kind: "delete", path: "tools/old.yml" }], + }, + ); + assert.ok(deleteArgs.includes("--force")); + assert.ok(!deleteArgs.includes("--allow-new-files")); +}); + test("promotion plan is dry until explicitly applied", async () => { const fx = await fixture(); try { diff --git a/tests/pull-bootstrap.test.ts b/tests/pull-bootstrap.test.ts new file mode 100644 index 0000000..80a8fba --- /dev/null +++ b/tests/pull-bootstrap.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; +import type { StateFile } from "../src/types.ts"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const environment = "pull-bootstrap-test"; + +interface AssistantResource { + id: string; + name: string; + orgId: string; +} + +function emptyState(): StateFile { + return { + credentials: {}, + assistants: {}, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; +} + +function assistant(index: number): AssistantResource { + const suffix = String(index).padStart(12, "0"); + return { + id: `00000000-0000-4000-8000-${suffix}`, + name: `Assistant ${index}`, + orgId: "org-test", + }; +} + +function startStub(options: { + list: AssistantResource[]; + direct?: AssistantResource; +}): Promise<{ worker: Worker; port: number }> { + return new Promise((resolveStart, rejectStart) => { + const source = ` + const http = require("node:http"); + const { parentPort, workerData } = require("node:worker_threads"); + const server = http.createServer((req, res) => { + const url = req.url || ""; + let body = []; + if (url === "/assistant?limit=1000") { + body = workerData.list; + } else if (url === "/assistant") { + body = workerData.list.slice(0, 100); + } else if ( + workerData.direct && + url === "/assistant/" + workerData.direct.id + ) { + body = workerData.direct; + } + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : 0; + parentPort.postMessage({ type: "listening", port }); + }); + parentPort.on("message", (message) => { + if (message && message.type === "shutdown") { + server.close(() => process.exit(0)); + } + }); + `; + const worker = new Worker(source, { + eval: true, + workerData: options, + }); + worker.once("error", rejectStart); + worker.on("message", (message: { type: string; port?: number }) => { + if (message.type === "listening" && typeof message.port === "number") { + resolveStart({ worker, port: message.port }); + } + }); + }); +} + +async function stopStub(worker: Worker): Promise { + worker.postMessage({ type: "shutdown" }); + await new Promise((resolveStop) => { + worker.once("exit", resolveStop); + setTimeout(() => { + worker + .terminate() + .then(() => resolveStop()) + .catch(() => resolveStop()); + }, 1000); + }); +} + +async function runPull(options: { + list: AssistantResource[]; + direct?: AssistantResource; + state?: StateFile; + ignore?: string; + args: string[]; +}): Promise<{ state: StateFile; stdout: string; stderr: string }> { + const directory = mkdtempSync(join(tmpdir(), "vapi-pull-bootstrap-")); + cpSync(join(repoRoot, "src"), join(directory, "src"), { recursive: true }); + cpSync(join(repoRoot, "package.json"), join(directory, "package.json")); + symlinkSync( + join(repoRoot, "node_modules"), + join(directory, "node_modules"), + "dir", + ); + const resourceRoot = join(directory, "resources", environment); + mkdirSync(resourceRoot, { recursive: true }); + if (options.ignore) { + writeFileSync(join(resourceRoot, ".vapi-ignore"), options.ignore); + } + writeFileSync( + join(directory, `.vapi-state.${environment}.json`), + `${JSON.stringify(options.state ?? emptyState(), null, 2)}\n`, + ); + + const { worker, port } = await startStub({ + list: options.list, + direct: options.direct, + }); + writeFileSync( + join(directory, `.env.${environment}`), + `VAPI_TOKEN=test-token\nVAPI_BASE_URL=http://127.0.0.1:${port}\n`, + ); + + try { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/pull.ts", environment, ...options.args], + { + cwd: directory, + encoding: "utf8", + timeout: 30_000, + env: { + ...process.env, + VAPI_TOKEN: "test-token", + VAPI_BASE_URL: `http://127.0.0.1:${port}`, + }, + }, + ); + assert.equal( + result.status, + 0, + `pull failed\nstdout=${result.stdout}\nstderr=${result.stderr}`, + ); + return { + state: JSON.parse( + readFileSync( + join(directory, `.vapi-state.${environment}.json`), + "utf8", + ), + ), + stdout: result.stdout, + stderr: result.stderr, + }; + } finally { + await stopStub(worker); + rmSync(directory, { recursive: true, force: true }); + } +} + +test("bootstrap requests the complete assistant inventory", async () => { + const result = await runPull({ + list: Array.from({ length: 101 }, (_, index) => assistant(index + 1)), + args: ["--bootstrap", "--skip-bindings", "--type", "assistants"], + }); + + assert.equal(Object.keys(result.state.assistants).length, 101); +}); + +test("targeted assistant pull uses direct GET instead of the list response", async () => { + const direct = assistant(301); + const result = await runPull({ + list: [], + direct, + args: [ + "--skip-bindings", + "--type", + "assistants", + "--id", + direct.id, + ], + }); + + assert.equal( + Object.values(result.state.assistants)[0]?.uuid, + direct.id, + ); +}); + +test("bootstrap excludes resources matched by .vapi-ignore from state", async () => { + const ignored = assistant(401); + const state = emptyState(); + state.assistants["dashboard-only"] = { uuid: ignored.id }; + + const result = await runPull({ + list: [ignored], + state, + ignore: "assistants/dashboard-only\n", + args: ["--bootstrap", "--skip-bindings", "--type", "assistants"], + }); + + assert.deepEqual(result.state.assistants, {}); + assert.match(result.stdout, /matched \.vapi-ignore/); +}); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts new file mode 100644 index 0000000..76ee449 --- /dev/null +++ b/tests/resolver.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveReferencesToResourceIds } from "../src/canonical.ts"; +import { extractReferencedIds, resolveReferences } from "../src/resolver.ts"; +import type { StateFile } from "../src/types.ts"; + +const assistantUuid = "11111111-1111-4111-8111-111111111111"; + +function state(): StateFile { + return { + credentials: {}, + assistants: { nestedAssistant: { uuid: assistantUuid } }, + structuredOutputs: {}, + tools: {}, + squads: {}, + personalities: {}, + scenarios: {}, + simulations: {}, + simulationSuites: {}, + evals: {}, + }; +} + +function nestedSquad(assistantId: string) { + return { + members: [ + { + assistantId, + assistantOverrides: { + metadata: { assistantId: "customer-metadata" }, + "tools:append": [ + { + type: "handoff", + destinations: [{ type: "assistant", assistantId }], + }, + { + type: "function", + destinations: [ + { type: "metadata", assistantId: "function-payload" }, + ], + }, + ], + }, + }, + ], + }; +} + +function nestedDestination( + resource: Record, +): Record { + const members = resource.members; + assert.ok(Array.isArray(members)); + const member = members[0]; + assert.ok(member && typeof member === "object"); + assert.ok("assistantOverrides" in member); + const overrides = member.assistantOverrides; + assert.ok(overrides && typeof overrides === "object"); + assert.ok("tools:append" in overrides); + const tools = overrides["tools:append"]; + assert.ok(Array.isArray(tools)); + const tool = tools[0]; + assert.ok(tool && typeof tool === "object"); + assert.ok("destinations" in tool); + const destinations = tool.destinations; + assert.ok(Array.isArray(destinations)); + const destination = destinations[0]; + assert.ok(destination && typeof destination === "object"); + return destination; +} + +test("resolveReferences resolves nested squad handoff assistant IDs only", () => { + const resolved = resolveReferences(nestedSquad("nestedAssistant"), state()); + const destination = nestedDestination(resolved); + assert.equal(destination.assistantId, assistantUuid); + + const members = resolved.members; + assert.ok(Array.isArray(members)); + const member = members[0]; + assert.ok(member && typeof member === "object"); + assert.equal(member.assistantId, assistantUuid); + assert.ok("assistantOverrides" in member); + const overrides = member.assistantOverrides; + assert.ok(overrides && typeof overrides === "object"); + assert.ok("metadata" in overrides); + const metadata = overrides.metadata; + assert.ok(metadata && typeof metadata === "object"); + assert.ok("assistantId" in metadata); + assert.equal(metadata.assistantId, "customer-metadata"); + assert.ok("tools:append" in overrides); + const tools = overrides["tools:append"]; + assert.ok(Array.isArray(tools)); + const functionTool = tools[1]; + assert.ok(functionTool && typeof functionTool === "object"); + assert.ok("destinations" in functionTool); + const functionDestinations = functionTool.destinations; + assert.ok(Array.isArray(functionDestinations)); + const functionDestination = functionDestinations[0]; + assert.ok(functionDestination && typeof functionDestination === "object"); + assert.ok("assistantId" in functionDestination); + assert.equal(functionDestination.assistantId, "function-payload"); +}); + +test("extractReferencedIds finds nested squad handoff assistant IDs", () => { + const references = extractReferencedIds(nestedSquad("nestedAssistant")); + assert.deepEqual(references.assistants, [ + "nestedAssistant", + "nestedAssistant", + ]); +}); + +test("canonicalization converts nested squad handoff UUIDs to aliases", () => { + const resolved = resolveReferencesToResourceIds( + nestedSquad(assistantUuid), + state(), + ); + const destination = nestedDestination(resolved); + assert.equal(destination.assistantId, "nestedAssistant"); + + const members = resolved.members; + assert.ok(Array.isArray(members)); + const member = members[0]; + assert.ok(member && typeof member === "object"); + assert.ok("assistantId" in member); + assert.equal(member.assistantId, "nestedAssistant"); +}); diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts index 839fbd8..833d25f 100644 --- a/tests/snapshot.test.ts +++ b/tests/snapshot.test.ts @@ -7,6 +7,7 @@ import { _resetRunSnapshotDir, listSnapshotTimestamps, loadSnapshot, + prepareRollbackPayload, snapshotsRoot, writeSnapshot, } from "../src/snapshot.ts"; @@ -140,3 +141,31 @@ test("loadSnapshot throws when timestamp directory is missing", async () => { rmSync(tempDir, { recursive: true, force: true }); } }); + +test("prepareRollbackPayload strips API-managed fields and preserves config", () => { + assert.deepEqual( + prepareRollbackPayload({ + id: "assistant-uuid", + orgId: "org-uuid", + createdAt: "2026-07-23T00:00:00Z", + updatedAt: "2026-07-23T01:00:00Z", + isServerUrlSecretSet: false, + name: "Agent A", + model: { + provider: "openai", + model: "gpt-4.1", + messages: [{ role: "system", content: "Keep this prompt." }], + }, + voicemailDetection: null, + }), + { + name: "Agent A", + model: { + provider: "openai", + model: "gpt-4.1", + messages: [{ role: "system", content: "Keep this prompt." }], + }, + voicemailDetection: null, + }, + ); +});