|
| 1 | +// ───────────────────────────────────────────────────────────────────────────── |
| 2 | +// Canonical content basis for resources. |
| 3 | +// |
| 4 | +// A platform resource (UUID references, credential UUIDs, prompt inline) and a |
| 5 | +// local file (resourceId references, credential names, prompt in the md body) |
| 6 | +// are two encodings of the SAME logical content. Drift detection only works if |
| 7 | +// every site hashes them in ONE shared basis. That basis is defined here. |
| 8 | +// |
| 9 | +// This module is deliberately dependency-light (credentials + types only) so |
| 10 | +// that pull.ts, push.ts, audit.ts, AND drift.ts can all import it without the |
| 11 | +// import cycle that previously trapped this logic inside pull.ts (pull imports |
| 12 | +// drift, so drift could not import back — it grew a divergent copy instead). |
| 13 | +// ───────────────────────────────────────────────────────────────────────────── |
| 14 | + |
| 15 | +import { replaceCredentialRefs } from "./credentials.ts"; |
| 16 | +import type { ResourceType, StateFile } from "./types.ts"; |
| 17 | + |
| 18 | +export interface VapiResource { |
| 19 | + id: string; |
| 20 | + name?: string; |
| 21 | + [key: string]: unknown; |
| 22 | +} |
| 23 | + |
| 24 | +// Fields to remove before hashing/writing (server-managed or computed). |
| 25 | +// Single source of truth — drift's old private `SERVER_FIELDS` was an exact |
| 26 | +// duplicate of this and drifted out of sync risk-free only by luck. |
| 27 | +export const EXCLUDED_FIELDS = [ |
| 28 | + "id", |
| 29 | + "orgId", |
| 30 | + "createdAt", |
| 31 | + "updatedAt", |
| 32 | + "analyticsMetadata", |
| 33 | + "isDeleted", |
| 34 | + // Computed/derived fields that shouldn't be synced back |
| 35 | + "isServerUrlSecretSet", // Computed: indicates if server URL secret is set |
| 36 | + "workflowIds", // Server-managed: workflows are a separate resource type |
| 37 | +]; |
| 38 | + |
| 39 | +export function cleanResource(resource: VapiResource): Record<string, unknown> { |
| 40 | + const cleaned: Record<string, unknown> = {}; |
| 41 | + |
| 42 | + // Preserve `null` values: the API uses `null` to represent an intentionally |
| 43 | + // cleared field (e.g. `voicemailMessage: null`), which is semantically |
| 44 | + // different from an absent field. Stripping it on pull would cause the next |
| 45 | + // push to drop the clear and re-apply any prior value still on the server. |
| 46 | + for (const [key, value] of Object.entries(resource)) { |
| 47 | + if (!EXCLUDED_FIELDS.includes(key) && value !== undefined) { |
| 48 | + cleaned[key] = value; |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + return cleaned; |
| 53 | +} |
| 54 | + |
| 55 | +// uuid -> resourceId, for a single resource type's state section. |
| 56 | +export function buildReverseMap( |
| 57 | + state: StateFile, |
| 58 | + resourceType: ResourceType, |
| 59 | +): Map<string, string> { |
| 60 | + const map = new Map<string, string>(); |
| 61 | + const stateSection = state[resourceType]; |
| 62 | + |
| 63 | + for (const [resourceId, entry] of Object.entries(stateSection)) { |
| 64 | + map.set(entry.uuid, resourceId); |
| 65 | + } |
| 66 | + |
| 67 | + return map; |
| 68 | +} |
| 69 | + |
| 70 | +// ───────────────────────────────────────────────────────────────────────────── |
| 71 | +// Reference Resolution (UUID -> resourceId) |
| 72 | +// ───────────────────────────────────────────────────────────────────────────── |
| 73 | + |
| 74 | +export function resolveReferencesToResourceIds( |
| 75 | + resource: Record<string, unknown>, |
| 76 | + state: StateFile, |
| 77 | +): Record<string, unknown> { |
| 78 | + const toolsMap = buildReverseMap(state, "tools"); |
| 79 | + const assistantsMap = buildReverseMap(state, "assistants"); |
| 80 | + const structuredOutputsMap = buildReverseMap(state, "structuredOutputs"); |
| 81 | + const personalitiesMap = buildReverseMap(state, "personalities"); |
| 82 | + const scenariosMap = buildReverseMap(state, "scenarios"); |
| 83 | + const simulationsMap = buildReverseMap(state, "simulations"); |
| 84 | + |
| 85 | + const resolved = { ...resource }; |
| 86 | + |
| 87 | + // Resolve toolIds in model |
| 88 | + if (resolved.model && typeof resolved.model === "object") { |
| 89 | + const model = { ...(resolved.model as Record<string, unknown>) }; |
| 90 | + if (Array.isArray(model.toolIds)) { |
| 91 | + model.toolIds = model.toolIds.map( |
| 92 | + (uuid: string) => toolsMap.get(uuid) ?? uuid, |
| 93 | + ); |
| 94 | + } |
| 95 | + resolved.model = model; |
| 96 | + } |
| 97 | + |
| 98 | + // Resolve structuredOutputIds in artifactPlan |
| 99 | + if (resolved.artifactPlan && typeof resolved.artifactPlan === "object") { |
| 100 | + const artifactPlan = { |
| 101 | + ...(resolved.artifactPlan as Record<string, unknown>), |
| 102 | + }; |
| 103 | + if (Array.isArray(artifactPlan.structuredOutputIds)) { |
| 104 | + artifactPlan.structuredOutputIds = artifactPlan.structuredOutputIds.map( |
| 105 | + (uuid: string) => structuredOutputsMap.get(uuid) ?? uuid, |
| 106 | + ); |
| 107 | + } |
| 108 | + resolved.artifactPlan = artifactPlan; |
| 109 | + } |
| 110 | + |
| 111 | + // Resolve assistantIds in structured outputs (API returns camelCase) |
| 112 | + if (Array.isArray(resolved.assistantIds)) { |
| 113 | + resolved.assistant_ids = (resolved.assistantIds as string[]).map( |
| 114 | + (uuid: string) => assistantsMap.get(uuid) ?? uuid, |
| 115 | + ); |
| 116 | + delete resolved.assistantIds; |
| 117 | + } |
| 118 | + |
| 119 | + // Resolve assistantId in tool destinations (handoff tools) |
| 120 | + if (Array.isArray(resolved.destinations)) { |
| 121 | + resolved.destinations = ( |
| 122 | + resolved.destinations as Record<string, unknown>[] |
| 123 | + ).map((dest) => { |
| 124 | + if (typeof dest.assistantId === "string") { |
| 125 | + return { |
| 126 | + ...dest, |
| 127 | + assistantId: assistantsMap.get(dest.assistantId) ?? dest.assistantId, |
| 128 | + }; |
| 129 | + } |
| 130 | + return dest; |
| 131 | + }); |
| 132 | + } |
| 133 | + |
| 134 | + // Resolve members[].assistantId in squads |
| 135 | + if (Array.isArray(resolved.members)) { |
| 136 | + resolved.members = (resolved.members as Record<string, unknown>[]).map( |
| 137 | + (member) => { |
| 138 | + const resolvedMember = { ...member }; |
| 139 | + if (typeof member.assistantId === "string") { |
| 140 | + resolvedMember.assistantId = |
| 141 | + assistantsMap.get(member.assistantId) ?? member.assistantId; |
| 142 | + } |
| 143 | + // Resolve assistantDestinations[].assistantId |
| 144 | + if (Array.isArray(member.assistantDestinations)) { |
| 145 | + resolvedMember.assistantDestinations = ( |
| 146 | + member.assistantDestinations as Record<string, unknown>[] |
| 147 | + ).map((dest) => { |
| 148 | + if (typeof dest.assistantId === "string") { |
| 149 | + return { |
| 150 | + ...dest, |
| 151 | + assistantId: |
| 152 | + assistantsMap.get(dest.assistantId) ?? dest.assistantId, |
| 153 | + }; |
| 154 | + } |
| 155 | + return dest; |
| 156 | + }); |
| 157 | + } |
| 158 | + return resolvedMember; |
| 159 | + }, |
| 160 | + ); |
| 161 | + } |
| 162 | + |
| 163 | + // Resolve personalityId in simulations |
| 164 | + if (typeof resolved.personalityId === "string") { |
| 165 | + resolved.personalityId = |
| 166 | + personalitiesMap.get(resolved.personalityId) ?? resolved.personalityId; |
| 167 | + } |
| 168 | + |
| 169 | + // Resolve scenarioId in simulations |
| 170 | + if (typeof resolved.scenarioId === "string") { |
| 171 | + resolved.scenarioId = |
| 172 | + scenariosMap.get(resolved.scenarioId) ?? resolved.scenarioId; |
| 173 | + } |
| 174 | + |
| 175 | + // Resolve simulationIds in simulation suites |
| 176 | + if (Array.isArray(resolved.simulationIds)) { |
| 177 | + resolved.simulationIds = (resolved.simulationIds as string[]).map( |
| 178 | + (uuid: string) => simulationsMap.get(uuid) ?? uuid, |
| 179 | + ); |
| 180 | + } |
| 181 | + |
| 182 | + return resolved; |
| 183 | +} |
| 184 | + |
| 185 | +// ───────────────────────────────────────────────────────────────────────────── |
| 186 | +// Canonicalization for content-hashing |
| 187 | +// ───────────────────────────────────────────────────────────────────────────── |
| 188 | + |
| 189 | +/** |
| 190 | + * Single source of truth for the canonical hash basis of a platform resource. |
| 191 | + * |
| 192 | + * Encodes the full pipeline: `cleanResource → resolveReferencesToResourceIds |
| 193 | + * → replaceCredentialRefs → _platformDefault marker injection`. |
| 194 | + * |
| 195 | + * ALL hash sites MUST use this helper — pull-write fallback, the pull |
| 196 | + * classifier, the audit (audit.ts/checkContentDrift), and the push drift check |
| 197 | + * (drift.ts). Any divergence (a missing step, a different mutation order) makes |
| 198 | + * the recomputed `platformHash` disagree with the stored `lastPulledHash` from |
| 199 | + * a prior pull, producing permanent phantom `both-diverged` reports that only |
| 200 | + * `--overwrite` can clear. |
| 201 | + * |
| 202 | + * Note: this is the *pre-write* canonical form (in-memory). At write sites, |
| 203 | + * `lastPulledHash` should still be sourced from `hashLocalResource(...)` after |
| 204 | + * the file is on disk, because YAML round-trip / MD frontmatter serialization |
| 205 | + * is not guaranteed to be identity-preserving. This helper is the correct |
| 206 | + * fallback when no file write happened (bootstrap mode) or when reading a |
| 207 | + * platform payload for classifier/audit/drift purposes. |
| 208 | + */ |
| 209 | +export function canonicalizeForHash( |
| 210 | + resource: VapiResource, |
| 211 | + state: StateFile, |
| 212 | + credReverse: Map<string, string>, |
| 213 | +): Record<string, unknown> { |
| 214 | + const cleaned = cleanResource(resource); |
| 215 | + const resolved = resolveReferencesToResourceIds(cleaned, state); |
| 216 | + const withCredNames = replaceCredentialRefs(resolved, credReverse); |
| 217 | + const isPlatformDefault = |
| 218 | + resource.orgId === null || resource.orgId === undefined; |
| 219 | + if (isPlatformDefault) { |
| 220 | + withCredNames._platformDefault = true; |
| 221 | + } |
| 222 | + return withCredNames; |
| 223 | +} |
0 commit comments