-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathdrift.ts
More file actions
132 lines (120 loc) · 5.08 KB
/
Copy pathdrift.ts
File metadata and controls
132 lines (120 loc) · 5.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// ─────────────────────────────────────────────────────────────────────────────
// Drift detection
//
// Before each PATCH, GET the current platform payload, hash it, and compare
// to the `lastPulledHash` recorded in state. If the hashes differ, the
// dashboard has drifted away from the version we last pulled — refuse to
// push without `--overwrite`.
//
// Behavior matrix:
// - No `lastPulledHash` (e.g., legacy state, first push after schema
// migration): log "drift unknown — proceeding" and continue. Don't block.
// - Hashes match: continue silently.
// - Hashes differ + no --overwrite: refuse the push, return false.
// - Hashes differ + --overwrite: log "overwriting drift" and continue.
//
// The check fires GET against the same endpoint the apply function would
// PATCH. We don't centralize it inside `vapiRequest` because POST (create)
// has nothing to compare against — only PATCH (update) is drift-sensitive.
// ─────────────────────────────────────────────────────────────────────────────
import { VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import { hashPayload } from "./state-serialize.ts";
import type { ResourceState } from "./types.ts";
export interface DriftCheckResult {
ok: boolean;
reason: "no-baseline" | "match" | "drift-overwritten" | "drift-blocked";
message?: string;
// Hash of the *current* platform payload — caller may want to update
// state's `lastPulledHash` after a successful push so subsequent pushes
// start from the platform's current state, not the stale pre-overwrite hash.
platformHash?: string;
}
async function fetchPlatformPayload(endpoint: string): Promise<unknown | null> {
// GET against the same path the PATCH would target. 404 means the resource
// was deleted on the dashboard — let the upsert path handle it (the existing
// 404 → "stale mapping, drop and skip" recovery in
// upsertResourceWithStateRecovery covers this case).
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
method: "GET",
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
});
if (response.status === 404) return null;
if (!response.ok) {
const text = await response.text();
throw new Error(`Drift GET ${endpoint} → ${response.status}: ${text}`);
}
return response.json();
}
// Strip server-managed fields before hashing so the platform's payload hash
// matches the last-pulled-hash basis (which excluded them via cleanResource).
const SERVER_FIELDS = new Set([
"id",
"orgId",
"createdAt",
"updatedAt",
"analyticsMetadata",
"isDeleted",
"isServerUrlSecretSet",
"workflowIds",
]);
function stripServerFields(payload: unknown): unknown {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return payload;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
if (!SERVER_FIELDS.has(k)) out[k] = v;
}
return out;
}
export async function checkDriftForUpdate(options: {
endpoint: string; // e.g. "/assistant/<uuid>"
resourceLabel: string; // for log lines
resourceId: string; // local resource id
state: ResourceState;
overwrite: boolean;
}): Promise<DriftCheckResult> {
const { endpoint, resourceLabel, resourceId, state, overwrite } = options;
if (!state.lastPulledHash) {
return {
ok: true,
reason: "no-baseline",
message:
` ⚠️ drift check skipped for ${resourceLabel} ${resourceId}: ` +
`no lastPulledHash in state. Run \`npm run pull\` to establish a baseline.`,
};
}
const remote = await fetchPlatformPayload(endpoint);
if (remote === null) {
// Resource was deleted on the dashboard — defer to the upsert recovery
// path. Drift is not the right framing here.
return { ok: true, reason: "no-baseline" };
}
const platformHash = hashPayload(stripServerFields(remote));
if (platformHash === state.lastPulledHash) {
return { ok: true, reason: "match", platformHash };
}
if (overwrite) {
return {
ok: true,
reason: "drift-overwritten",
platformHash,
message:
` ⚠️ drift on ${resourceLabel} ${resourceId}: platform changed since last pull, ` +
`overwriting (--overwrite).`,
};
}
return {
ok: false,
reason: "drift-blocked",
platformHash,
message:
` ❌ drift detected on ${resourceLabel} ${resourceId}: ` +
`platform hash (${platformHash.slice(0, 8)}...) differs from last-pulled ` +
`(${state.lastPulledHash.slice(0, 8)}...). ` +
`Re-run pull, resolve locally, or push with --overwrite to take ownership.`,
};
}
// Re-export the pure helper from state-serialize so call sites can import
// from drift.ts but tests can import the pure version directly.
export { checkPronunciationDictDrop } from "./state-serialize.ts";