Skip to content

Commit 4de7f6d

Browse files
committed
feat(drift): enhance conflict resolution and backup handling
This commit introduces several improvements to the drift resolution process and backup file management. Key changes include: - Updated the default drift resolution mode to "defer," allowing for per-resource conflict resolution during the push stage. - Implemented a mechanism to create dashboard backup copies for manual merging when conflicts arise, ensuring that these files are ignored by resource discovery and version control. - Enhanced the user experience by removing the umbrella conflict resolution prompt, instead prompting for decisions on a per-resource basis. - Added utility functions to identify backup files and ensure they are treated as non-resources, preventing accidental overwrites or duplicates. These changes streamline the workflow for handling drift conflicts and improve the overall reliability of resource synchronization.
1 parent ecb0d8a commit 4de7f6d

9 files changed

Lines changed: 311 additions & 71 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ tmp/
3232
# committing it would make one developer's baseline everyone's.
3333
.vapi-state-hash/
3434

35+
# Dashboard-backup copies written by the push conflict prompt for manual
36+
# merging (<name>.<TIMESTAMP>.bkp.md|yml). Scratch material, never committed.
37+
*.bkp.*
38+
3539
# Local agent state
3640
.claude/
3741
.agent/

src/apply.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,24 @@ export async function runApply(): Promise<void> {
3636
assertStateMigrated(join(BASE_DIR, `.vapi-state.${env}.json`));
3737
}
3838

39-
// Apply's job is to push local up; default pull drift resolution to "ours"
40-
// unless the operator explicitly passes --resolve=theirs|fail (CI).
39+
// Default drift resolution is "defer": pull preserves the local file AND
40+
// the drift baseline for conflicted resources, and the push stage asks a
41+
// per-resource question for exactly the resources that actually diverged
42+
// (its stdio is inherited, so the prompt reaches the operator). No umbrella
43+
// decision is made up front. Explicit --resolve=ours|theirs|fail keeps the
44+
// non-interactive (CI) semantics — including ours → push --overwrite.
4145
const pullArgsList = allArgs.filter((a) => a !== "--force");
4246
if (!pullArgsList.some((a) => a.startsWith("--resolve="))) {
43-
pullArgsList.push("--resolve=ours");
47+
pullArgsList.push("--resolve=defer");
4448
}
4549
const resolveArg = pullArgsList.find((a) => a.startsWith("--resolve="));
46-
const resolveMode = resolveArg?.slice("--resolve=".length) ?? "ours";
50+
const resolveMode = resolveArg?.slice("--resolve=".length) ?? "defer";
4751

4852
const pullArgs = pullArgsList.join(" ");
4953

50-
// Pull --resolve=ours means "keep local and push it up" — push needs
51-
// --overwrite so its pre-PATCH drift gate doesn't block the same intent.
54+
// Explicit --resolve=ours means "keep local and push it up, no questions" —
55+
// push needs --overwrite so its pre-PATCH drift gate doesn't block (or
56+
// re-ask about) the same intent.
5257
const pushArgsList = [...allArgs];
5358
if (
5459
resolveMode === "ours" &&
@@ -60,7 +65,7 @@ export async function runApply(): Promise<void> {
6065
const pushArgs = pushArgsList.join(" ");
6166

6267
if (!env || !SLUG_RE.test(env)) {
63-
console.error("Usage: npm run apply <org> [--force] [--allow-new-files] [--resolve=ours|theirs|fail] [<file...>]");
68+
console.error("Usage: npm run apply <org> [--force] [--allow-new-files] [--resolve=defer|ours|theirs|fail] [<file...>]");
6469
console.error("");
6570
console.error(" Pull → Merge → Push (safe bidirectional sync)");
6671
console.error("");
@@ -83,7 +88,10 @@ export async function runApply(): Promise<void> {
8388
" state entry is genuinely new — see src/new-file-gate.ts.",
8489
);
8590
console.error(
86-
" --resolve=ours On 3-way drift, keep local and push up (default; adds --overwrite on push).",
91+
" --resolve=defer On 3-way drift, ask per resource at push time (default).",
92+
);
93+
console.error(
94+
" --resolve=ours On 3-way drift, keep local and push up, no questions (adds --overwrite on push).",
8795
);
8896
console.error(
8997
" --resolve=theirs On 3-way drift, overwrite local with dashboard.",

src/delete.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { VapiApiError, vapiDelete } from "./api.ts";
2-
import { FORCE_DELETE, loadIgnorePatterns, matchesIgnore } from "./config.ts";
2+
import { FORCE_DELETE, loadIgnorePatterns, matchesIgnore, VAPI_ENV } from "./config.ts";
3+
import { deleteBaseline } from "./hash-store.ts";
34
import { extractReferencedIds } from "./resolver.ts";
45
import { FOLDER_MAP } from "./resources.ts";
56
import type {
@@ -435,6 +436,7 @@ export async function deleteOrphanedResources(
435436
console.log(` 🗑️ Deleting ${type}: ${resourceId} (${uuid})`);
436437
await vapiDelete(`${DELETE_ENDPOINT_MAP[stateKey]}/${uuid}`);
437438
delete state[stateKey][resourceId];
439+
await deleteBaseline(VAPI_ENV, uuid);
438440
deleted++;
439441
} catch (error) {
440442
const msg =

src/drift.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,13 @@ export interface DriftCheckResult {
6969
ok: boolean;
7070
reason: "no-baseline" | "match" | "drift-overwritten" | "drift-blocked";
7171
message?: string;
72-
// Hash of the *current* platform payload — caller may want to update
73-
// state's `lastPulledHash` after a successful push so subsequent pushes
74-
// start from the platform's current state, not the stale pre-overwrite hash.
72+
// Hash of the *current* platform payload in the canonical baseline basis.
7573
platformHash?: string;
74+
// The raw platform payload fetched for the check. Returned so the caller
75+
// can reuse it (e.g. for the pre-push rollback snapshot) instead of firing
76+
// a second GET against the same endpoint. Undefined when the check
77+
// short-circuited before fetching (no baseline) or the resource 404'd.
78+
platformPayload?: unknown;
7679
}
7780

7881
async function fetchPlatformPayload(endpoint: string): Promise<unknown | null> {
@@ -138,7 +141,7 @@ export async function checkDriftForUpdate(options: {
138141
canonicalizeForHash(remote as VapiResource, state, credReverse),
139142
);
140143
if (platformHash === baseline) {
141-
return { ok: true, reason: "match", platformHash };
144+
return { ok: true, reason: "match", platformHash, platformPayload: remote };
142145
}
143146

144147
// On-disk hash in the same basis as the baseline. Absent a local file
@@ -157,7 +160,7 @@ export async function checkDriftForUpdate(options: {
157160
// upgraded customer repo carries baselines written in an older hash basis,
158161
// so every untouched resource hit `both-diverged` on its first push.
159162
if (localHash === platformHash) {
160-
return { ok: true, reason: "match", platformHash };
163+
return { ok: true, reason: "match", platformHash, platformPayload: remote };
161164
}
162165

163166
const direction = classifyDrift({
@@ -172,6 +175,7 @@ export async function checkDriftForUpdate(options: {
172175
ok: true,
173176
reason: "drift-overwritten",
174177
platformHash,
178+
platformPayload: remote,
175179
message:
176180
` ⚠️ drift on ${resourceLabel} ${resourceId} ${directionTag}: platform changed since last pull, ` +
177181
`overwriting (--overwrite). ${formatDriftLabel(direction)}`,
@@ -182,6 +186,7 @@ export async function checkDriftForUpdate(options: {
182186
ok: false,
183187
reason: "drift-blocked",
184188
platformHash,
189+
platformPayload: remote,
185190
message:
186191
` ❌ drift detected on ${resourceLabel} ${resourceId} ${directionTag}: ` +
187192
`platform hash (${platformHash.slice(0, 8)}...) differs from baseline ` +

src/interactive.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
44
import { dirname, extname, join, relative } from "path";
55
import { fileURLToPath } from "url";
66
import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js";
7+
// slug-utils is config-free (no config.ts side effects) — safe to import in
8+
// the launcher, which runs before any org/token is selected.
9+
import { isBackupCopyFile } from "./slug-utils.ts";
710
import type { StateFile } from "./types.ts";
811

912
// ─────────────────────────────────────────────────────────────────────────────
@@ -314,6 +317,9 @@ function scanLocalResources(slug: string): LocalResource[] {
314317
const walk = (dir: string): void => {
315318
for (const entry of readdirSync(dir, { withFileTypes: true })) {
316319
if (entry.name.startsWith(".")) continue;
320+
// Dashboard-backup copies are merge scratch material, never
321+
// selectable resources.
322+
if (isBackupCopyFile(entry.name)) continue;
317323
const fullPath = join(dir, entry.name);
318324
if (entry.isDirectory()) {
319325
walk(fullPath);
@@ -1033,35 +1039,17 @@ export async function runInteractiveApply(): Promise<void> {
10331039
}
10341040

10351041
// ── Execute apply ─────────────────────────────────────────────────────
1036-
const driftResolve = await select({
1037-
message:
1038-
"If local and dashboard both changed since last pull, which version wins?",
1039-
choices: [
1040-
{
1041-
name: "Keep my local version and push it up",
1042-
value: "ours" as const,
1043-
},
1044-
{
1045-
name: "Take dashboard version (lose my local edits)",
1046-
value: "theirs" as const,
1047-
},
1048-
{ name: c.dim("← Cancel"), value: "cancel" as const },
1049-
],
1050-
default: "ours",
1051-
});
1052-
1053-
if (driftResolve === "cancel") {
1054-
console.log(c.dim("\n Cancelled.\n"));
1055-
return;
1056-
}
1057-
1042+
// No umbrella "which version wins?" question here. Apply runs with
1043+
// --resolve=defer (its default): conflicts are detected per resource via
1044+
// the hash-store baseline, and the push stage prompts for exactly the
1045+
// resources that actually diverged — clean ones push silently.
10581046
const useForce = await confirm({
10591047
message:
10601048
"Enable force mode? (deletions: resources removed locally will also be deleted remotely)",
10611049
default: false,
10621050
});
10631051

1064-
const args = ["src/apply.ts", slug, `--resolve=${driftResolve}`];
1052+
const args = ["src/apply.ts", slug];
10651053
if (useForce) args.push("--force");
10661054

10671055
if (!applyAll) {

src/pull.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ import {
3434
formatRecanonicalizeReport,
3535
recanonicalizeStateKeys,
3636
} from "./recanonicalize.ts";
37-
import { FOLDER_MAP, hashLocalResource, resolvePullScopeFromFilePaths } from "./resources.ts";
38-
import { extractBaseSlug, slugify } from "./slug-utils.ts";
37+
import {
38+
FOLDER_MAP,
39+
hashLocalResource,
40+
resolvePullScopeFromFilePaths,
41+
} from "./resources.ts";
42+
import { extractBaseSlug, isBackupCopyFile, slugify } from "./slug-utils.ts";
3943
import { hashPayload, loadState, saveState, upsertState } from "./state.ts";
4044
import type { ResourceState, ResourceType, StateFile } from "./types.ts";
4145

@@ -243,6 +247,11 @@ export function listExistingResourceIds(resourceType: ResourceType): string[] {
243247
continue;
244248
}
245249

250+
// Dashboard-backup siblings are merge reference material, not resources.
251+
if (isBackupCopyFile(entry.name)) {
252+
continue;
253+
}
254+
246255
const relativePath = relative(dir, fullPath);
247256
ids.push(relativePath.replace(/\.(yml|yaml|md)$/, ""));
248257
}
@@ -408,6 +417,34 @@ async function writeResourceFile(
408417
return filePath;
409418
}
410419

420+
// Write the dashboard version of a resource as a sibling
421+
// `<resourceId>.<TIMESTAMP>.bkp.<ext>` next to the local file, in the same
422+
// serialized form a pull would produce — so a hand-merge diffs cleanly
423+
// against the local file. The timestamp keeps repeated conflicts from
424+
// overwriting earlier copies; the `.bkp.` infix keeps the file invisible to
425+
// all resource discovery (see `isBackupCopyFile`) and gitignored (`*.bkp.*`).
426+
// Used by the push conflict prompt's "create local copy + manual merge"
427+
// choice.
428+
export async function writeDashboardBackup(
429+
resourceType: ResourceType,
430+
resourceId: string,
431+
platformPayload: VapiResource,
432+
state: StateFile,
433+
): Promise<string> {
434+
const credReverse = credentialReverseMap(state);
435+
const withCredNames = canonicalizeForHash(platformPayload, state, credReverse);
436+
// Filesystem-safe ISO timestamp: 2026-06-04T19-22-33 (no colons, no ms).
437+
const timestamp = new Date()
438+
.toISOString()
439+
.replace(/\.\d{3}Z$/, "")
440+
.replace(/:/g, "-");
441+
return writeResourceFile(
442+
resourceType,
443+
`${resourceId}.${timestamp}.bkp`,
444+
withCredNames,
445+
);
446+
}
447+
411448
// ─────────────────────────────────────────────────────────────────────────────
412449
// Pull Functions
413450
// ─────────────────────────────────────────────────────────────────────────────
@@ -418,7 +455,11 @@ export interface PullStats {
418455
skipped: number;
419456
}
420457

421-
export type DriftResolveMode = "ours" | "theirs" | "fail";
458+
// `defer` preserves the local file AND the drift baseline (no rewrite, no
459+
// error): the conflict evidence survives the pull so push's per-resource
460+
// interactive prompt can ask about exactly the resources that diverged.
461+
// `ours`/`theirs`/`fail` keep their non-interactive (CI) semantics.
462+
export type DriftResolveMode = "ours" | "theirs" | "fail" | "defer";
422463

423464
export interface BothDivergedResource {
424465
resourceType: ResourceType;
@@ -446,9 +487,10 @@ function parseResolveMode(explicit?: DriftResolveMode): DriftResolveMode | undef
446487
const arg = process.argv.find((a) => a.startsWith("--resolve="));
447488
if (!arg) return undefined;
448489
const mode = arg.slice("--resolve=".length);
449-
if (mode === "ours" || mode === "theirs" || mode === "fail") return mode;
490+
if (mode === "ours" || mode === "theirs" || mode === "fail" || mode === "defer")
491+
return mode;
450492
throw new Error(
451-
`Invalid --resolve value: ${mode}. Use --resolve=ours|theirs|fail`,
493+
`Invalid --resolve value: ${mode}. Use --resolve=ours|theirs|fail|defer`,
452494
);
453495
}
454496

@@ -839,6 +881,21 @@ async function resolveBothDivergedResources(options: {
839881
const { state, bothDiverged, resolveMode } = options;
840882
if (bothDiverged.length === 0) return { exitCode: 0 };
841883

884+
if (resolveMode === "defer") {
885+
// Leave everything as-is: local file preserved (the per-type loop already
886+
// skipped the write), baseline untouched. Push's per-resource drift
887+
// prompt will ask about exactly these resources.
888+
console.log(
889+
`\n ⏳ ${bothDiverged.length} resource(s) have 3-way drift — deferred to push's per-resource prompt:`,
890+
);
891+
for (const entry of bothDiverged) {
892+
console.log(
893+
` - ${FOLDER_MAP[entry.resourceType]}/${entry.resourceId}`,
894+
);
895+
}
896+
return { exitCode: 0 };
897+
}
898+
842899
if (resolveMode === "fail") {
843900
console.error(
844901
`\n❌ ${bothDiverged.length} resource(s) have 3-way drift (--resolve=fail).`,

0 commit comments

Comments
 (0)