-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathdrift.ts
More file actions
206 lines (189 loc) · 8.54 KB
/
Copy pathdrift.ts
File metadata and controls
206 lines (189 loc) · 8.54 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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
// ─────────────────────────────────────────────────────────────────────────────
// 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 { vapiGet, VapiApiError } from "./api.ts";
import { canonicalizeForHash, type VapiResource } from "./canonical.ts";
import { credentialReverseMap } from "./credentials.ts";
import { readBaseline } from "./hash-store.ts";
import { hashLocalResource } from "./resources.ts";
import { hashPayload } from "./state-serialize.ts";
import type { ResourceType, StateFile } from "./types.ts";
export type DriftDirection =
| "clean"
| "dashboard-ahead"
| "local-ahead"
| "both-diverged"
| "no-baseline";
export interface ClassifyDriftInput {
localHash: string;
lastPulledHash?: string;
platformHash: string;
}
export function classifyDrift(input: ClassifyDriftInput): DriftDirection {
const { localHash, lastPulledHash, platformHash } = input;
if (!lastPulledHash) return "no-baseline";
// INVARIANT: live local + platform agreement is NEVER drift, whatever the
// (possibly stale) baseline says. A stale baseline happens legitimately —
// e.g. a push made the dashboard match local while the baseline still held
// the previous pull's hash. Callers treat `clean` as "refresh the baseline
// and move on", which self-heals the stale pointer.
if (localHash === platformHash) return "clean";
const localMatches = localHash === lastPulledHash;
const platformMatches = platformHash === lastPulledHash;
if (localMatches && platformMatches) return "clean";
if (localMatches && !platformMatches) return "dashboard-ahead";
if (!localMatches && platformMatches) return "local-ahead";
return "both-diverged";
}
export function formatDriftLabel(direction: DriftDirection): string {
switch (direction) {
case "dashboard-ahead":
return "[dashboard-ahead — dashboard changed, local unchanged; run npm run pull to sync it down]";
case "local-ahead":
return "[local-ahead — run npm run push to propagate local edits up]";
case "both-diverged":
return "[both-diverged — 3-way conflict, pass --resolve=ours|theirs|fail]";
case "no-baseline":
return "[direction unknown — no lastPulledHash baseline; pull --bootstrap first]";
case "clean":
return "[no drift]";
}
}
export interface DriftCheckResult {
ok: boolean;
reason: "no-baseline" | "match" | "drift-overwritten" | "drift-blocked";
message?: string;
// Hash of the *current* platform payload in the canonical baseline basis.
platformHash?: string;
// The raw platform payload fetched for the check. Returned so the caller
// can reuse it (e.g. for the pre-push rollback snapshot) instead of firing
// a second GET against the same endpoint. Undefined when the check
// short-circuited before fetching (no baseline) or the resource 404'd.
platformPayload?: unknown;
}
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).
try {
return await vapiGet(endpoint);
} catch (error) {
if (error instanceof VapiApiError && error.statusCode === 404) return null;
throw error;
}
}
export async function checkDriftForUpdate(options: {
endpoint: string; // e.g. "/assistant/<uuid>"
resourceLabel: string; // for log lines
resourceType: ResourceType;
resourceId: string; // local resource id
state: StateFile;
env: string; // org slug — keys the hash store
overwrite: boolean;
}): Promise<DriftCheckResult> {
const {
endpoint,
resourceLabel,
resourceType,
resourceId,
state,
env,
overwrite,
} = options;
// The drift baseline ("last platform state I saw") lives in the hash store,
// keyed by the resource's UUID, not in the state file anymore.
const entry = state[resourceType]?.[resourceId];
const baseline = entry ? readBaseline(env, entry.uuid) : undefined;
if (!baseline) {
return {
ok: true,
reason: "no-baseline",
message:
` ⚠️ drift check skipped for ${resourceLabel} ${resourceId}: ` +
`no baseline in .vapi-state-hash. Run \`npm run pull\` to establish one.`,
};
}
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" };
}
// Hash all three points (platform, local, baseline) in ONE basis — the
// canonical form defined in canonical.ts that pull also uses to write
// `lastPulledHash`. Drift previously carried its own field-strip copy that
// omitted reference/credential resolution, so any tool- or credential-bearing
// resource hashed differently here than at pull time → phantom both-diverged.
const credReverse = credentialReverseMap(state);
const platformHash = hashPayload(
canonicalizeForHash(remote as VapiResource, state, credReverse),
);
if (platformHash === baseline) {
return { ok: true, reason: "match", platformHash, platformPayload: remote };
}
// On-disk hash in the same basis as the baseline. Absent a local file
// (rare on an update path), fall back to the baseline so the direction is
// dashboard-ahead rather than a phantom both-diverged.
const localHash =
hashLocalResource(resourceType, resourceId, state.variables) ?? baseline;
// Local and platform are byte-identical → there is nothing to reconcile and
// the PATCH is a no-op. NEVER block here, even if the baseline disagrees
// with both (a stale or older-basis baseline must not manufacture a conflict
// when the two LIVE sides already agree). `classifyDrift` encodes the same
// invariant, but this early return also short-circuits before the direction
// bookkeeping. This is the fix for the phantom-drift class: a freshly
// upgraded customer repo carries baselines written in an older hash basis,
// so every untouched resource hit `both-diverged` on its first push.
if (localHash === platformHash) {
return { ok: true, reason: "match", platformHash, platformPayload: remote };
}
const direction = classifyDrift({
localHash,
lastPulledHash: baseline,
platformHash,
});
const directionTag = `[${direction}]`;
if (overwrite) {
return {
ok: true,
reason: "drift-overwritten",
platformHash,
platformPayload: remote,
message:
` ⚠️ drift on ${resourceLabel} ${resourceId} ${directionTag}: platform changed since last pull, ` +
`overwriting (--overwrite). ${formatDriftLabel(direction)}`,
};
}
return {
ok: false,
reason: "drift-blocked",
platformHash,
platformPayload: remote,
message:
` ❌ drift detected on ${resourceLabel} ${resourceId} ${directionTag}: ` +
`platform hash (${platformHash.slice(0, 8)}...) differs from baseline ` +
`(${baseline.slice(0, 8)}...). ` +
`${formatDriftLabel(direction)} ` +
`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";