Skip to content

Commit 5b3b5b9

Browse files
Merge pull request #41 from VapiAI/fix/interactive-apply-cli
Fix interactive apply CLI to match push/pull flow Co-authored-by: Vitali <vitali.korezki@gmail.com>
2 parents 460a120 + fec8f16 commit 5b3b5b9

37 files changed

Lines changed: 2824 additions & 604 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ tmp/
2727
# Operator-local; not shared.
2828
.vapi-state.*.snapshots/
2929

30+
# Per-developer drift baseline store (.vapi-state-hash/<org>/<uuid>).
31+
# Holds "the last platform state I saw" per resource. Local, not shared —
32+
# committing it would make one developer's baseline everyone's.
33+
.vapi-state-hash/
34+
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+
3039
# Local agent state
3140
.claude/
3241
.agent/

AGENTS.md

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ This project manages **Vapi voice agent configurations** as code. All resources
3434
| Enforcing call time limits / graceful call ending | `docs/learnings/call-duration.md` |
3535
| Voice provider field cheat-sheet (Cartesia vs 11labs vs OpenAI etc.) | `docs/learnings/voice-providers.md` |
3636
| YAML authoring conventions, .vapi-ignore lifecycle | `docs/learnings/yaml-conventions.md` |
37+
| What pull/push/apply do in every drift & existence scenario | `docs/learnings/sync-behavior.md` |
3738

3839
**Where new knowledge goes:**
3940

@@ -71,6 +72,7 @@ If you're unsure where something goes, default to `docs/learnings/`. The README
7172
| Push with new resources | `npm run push -- <org> --allow-new-files` — bypass orphan-YAML gate. **AI agents**: do NOT auto-pass this flag; confirm with the human first (see push section below) |
7273
| Test a call | `npm run call -- <org> -a <assistant-name>` or `-s <squad-name>` |
7374
| Run a simulation suite | `npm run sim -- <org> --suite <name> --target <assistant-name>` |
75+
| Migrate a legacy state file | `npm run migrate` — one-shot, all orgs; required once after upgrading to the hash-store engine |
7476

7577
---
7678

@@ -82,6 +84,8 @@ Three commands deploy changes to the Vapi platform. Pick the safest one that fit
8284

8385
Pulls the platform's current state, merges with your local files, then pushes the merged result. This protects you from racing dashboard edits made between your last pull and your push. Use this for ~99% of deployments.
8486

87+
Conflict handling is **per resource, never umbrella**: apply defaults to `--resolve=defer`, so the pull stage preserves local files and the drift baselines for genuinely conflicted resources, and the push stage then asks one interactive question per conflicted resource (push mine / keep dashboard / save a `.bkp` copy for manual merge). Clean and one-sided changes flow silently in their obvious direction. The full scenario matrix lives in `docs/learnings/sync-behavior.md`. Explicit `--resolve=ours|theirs|fail` keep non-interactive (CI) semantics.
88+
8589
```bash
8690
npm run validate -- <org> # schema check first, no network call
8791
npm run apply -- <org> # full-org apply
@@ -94,10 +98,12 @@ Runs the engine's local validators against every YAML/MD file in the org without
9498

9599
### `npm run pull -- <org>` — refresh from dashboard (default: plain pull)
96100

97-
**Default to plain pull, NOT --force.** Plain `npm run pull -- <org>` shows:
98-
- Per-resource direction labels (dashboard-ahead / local-ahead / both-diverged / clean)
99-
- End-of-pull summary with counts per direction
100-
- A hard gate (`--resolve` flag) on 3-way conflicts before they silently lose data
101+
**Default to plain pull, NOT --force.** Plain `npm run pull -- <org>`:
102+
- Syncs one-sided changes in their obvious direction: `dashboard-ahead` (local unchanged, dashboard edited) is written down ⬇️; `local-ahead` (you edited, dashboard unchanged) is preserved ⬆️ for the next push
103+
- Prints an end-of-pull summary with counts per drift direction
104+
- Hard-gates true 3-way conflicts behind `--resolve=ours|theirs|fail|defer` before they silently lose data (`defer` leaves the conflict for push's per-resource prompt — apply's default)
105+
106+
Drift direction is computed against the per-developer baseline store `.vapi-state-hash/<org>/<uuid>` (gitignored) — the hash of the last platform content *you* pulled or pushed. The committed state file holds only `name → uuid`. See `docs/learnings/sync-behavior.md` for every scenario.
101107

102108
`--force` skips all of this and just overwrites local with dashboard. Use it ONLY when you literally need to nuke local and re-materialize dashboard truth (rare). Plain pull is the DEFAULT for both humans and agents; `--force` is the escape hatch.
103109

@@ -107,25 +113,35 @@ Runs the engine's local validators against every YAML/MD file in the org without
107113
|------|---------|
108114
| `📝` | Engine wrote/updated a file on disk (clean / no-baseline path) |
109115
| `` | Engine created a NEW file on disk (first-time pull of this resource) |
110-
| `✏️` | Locally modified file detected by git, preserved as-is |
116+
| `✏️` | Locally modified file detected by git, preserved as-is (no-baseline path) |
111117
| `⬆️` | `local-ahead` — local has unpushed edits, needs to flow UP to dashboard (preserved) |
112-
| `⬇️` | `--resolve=theirs` — overwrote local with dashboard (flowed DOWN) |
118+
| `⬇️` | Dashboard version flowed DOWN over local: `dashboard-ahead` sync-down (local was unchanged) or `--resolve=theirs` (local edits lost) |
119+
| `` | `--resolve=defer` — 3-way conflict left intact for push's per-resource prompt |
113120
| `🔒` | Platform-default resource (read-only, immutable) |
114-
| `🚫` | Matched `.vapi-ignore` (not tracked locally) |
121+
| `🚫` | Matched `.vapi-ignore` (not tracked locally), or a `.bkp` backup copy refused as a resource |
115122
| `🗑️` | Locally deleted (deletion intent recorded in state) |
116123

117-
Mental model: `` flows UP (push), `⬇️` flows DOWN (pull-overwrite), `📝` is the engine doing routine file I/O.
124+
Push adds two more: `` (conflict prompt → kept dashboard, push skipped) and `📄` (conflict prompt → dashboard copy saved as `<name>.<TIMESTAMP>.bkp.<ext>` for manual merge).
118125

119-
**First-adoption noise on customer repos.** The first `npm run audit -- <org>` after upgrading to a version with the content-drift rule will surface `[content-drift] [no-baseline]` info findings for every resource whose state row predates `lastPulledHash` tracking. These are info-severity (do NOT block CI), but the noise is real. Two ways to clear:
120-
- `npm run pull -- <org> --bootstrap` — refreshes state with the current platform-hash baseline, no resource materialization. One-shot fix for the whole fleet.
121-
- Or just let them sit — each plain `npm run pull` after this writes `lastPulledHash` for whichever resources it touches, so the no-baseline rows naturally drain over the next few sync cycles.
126+
Mental model: `⬆️` flows UP (push), `⬇️` flows DOWN (pull), `📝` is the engine doing routine file I/O.
127+
128+
**One-time migration on engine upgrade.** Repos that predate the hash-store engine carry `lastPulledHash`/`lastPushedHash`/timestamps inside `.vapi-state.<org>.json`. `pull`, `push`, and `apply` refuse to run on that legacy shape — run `npm run migrate` once (no org argument, no token needed): it slims every state file to pure `name → uuid` and seeds each org's `.vapi-state-hash/` baselines from the legacy hashes, so drift detection keeps working without a re-pull. Idempotent. Fresh clones / new developers have no baselines yet (the store is per-dev and gitignored) — run a plain `pull` first to seed them; until then drift checks log a no-baseline warning and proceed.
122129

123130
### `npm run push -- <org>` — raw push, no pre-pull
124131

125132
Skips the merge pass. Only use when (a) you literally just ran `pull` and (b) you're certain no one has touched the dashboard since. In a multi-developer environment or when dashboard editors are in play, default to `apply` instead. Stale local state can clobber recent dashboard edits or PATCH against UUIDs that no longer exist.
126133

127134
If you do use `push`, dry-run first: `npm run push -- <org> --dry-run`.
128135

136+
#### Per-resource drift gate (before every PATCH)
137+
138+
Before updating a resource, push GETs its current dashboard payload, hashes it, and compares against the stored baseline (`.vapi-state-hash/<org>/<uuid>`):
139+
140+
- **Hashes match** → your local edit is the natural next step in the change chain → pushed silently, and the baseline is refreshed from the PATCH **response** (what the platform actually stored).
141+
- **Hashes differ** → someone else published changes since your last sync. In a terminal, push asks **for that resource only**: ① push my local version (take ownership) ② keep the dashboard version (skip, local untouched) ③ save the dashboard version as `<name>.<TIMESTAMP>.bkp.<ext>` beside your file and skip, for a manual merge. In CI / piped runs the resource is blocked instead (use `--overwrite` to push unconditionally).
142+
143+
Backup copies (`*.bkp.*`, gitignored) are merge reference material only — invisible to the loader, the orphan gate, audit, the interactive picker, and explicit CLI paths.
144+
129145
#### Orphan-YAML gate (default-on, refuses ambiguous pushes)
130146

131147
`push` refuses by default when any local YAML file lacks a corresponding entry in `.vapi-state.<org>.json`. The engine can't disambiguate "intentionally new resource" vs "rename of an existing resource" vs "stale cruft" from the file alone. Silently treating every orphan as a create has been the spawn-source for duplicate-resource cascades on customer dashboards.
@@ -157,7 +173,7 @@ The same gate fires inside `apply` (which runs pull → merge → push). If pull
157173
4. Verify with `npm run call -- <org> -a <name>` and/or `npm run sim -- <org> --suite <name> --target <name>`
158174
5. If something looks wrong: `npm run rollback -- <org> --list``--to <timestamp>`
159175

160-
**Why this matters:** the gitops engine tracks resource UUIDs in `.vapi-state.<org>.json`. Bare `push` trusts that file is current. If the dashboard has changed since your last pull — someone edited an assistant in the UI, a teammate ran a different push, a structured output got linked via another path — your local state can be stale by minutes. `apply` refreshes state before mutating, eliminating that entire class of race.
176+
**Why this matters:** the gitops engine tracks resource UUIDs in `.vapi-state.<org>.json` (pure `name → uuid`) and the last-seen platform content hash per resource in the per-developer `.vapi-state-hash/<org>/<uuid>` store. The pre-PATCH drift gate compares that baseline against the live dashboard, so out-of-band edits — someone edited an assistant in the UI, a teammate ran a different push, a structured output got linked via another path — are detected per resource and surfaced as a per-resource question instead of being silently clobbered. `apply` additionally refreshes local files before mutating, eliminating the stale-state race entirely.
161177

162178
---
163179

@@ -945,16 +961,16 @@ For the **complete schema** of all available properties on each resource type, c
945961

946962
### Renaming an existing resource
947963

948-
The engine has a `name_mismatch` guard that auto-bootstraps state from the dashboard before applying changes. **Editing `.vapi-state.<org>.json` by hand to repoint a renamed file at the existing dashboard UUID does not work** — the bootstrap runs first, overwrites your manual edit, and the rename gets treated as "delete the old resource + create a new one."
964+
**The local filename is a stable handle, decoupled from the dashboard `name`.** State maps `<filename-slug> → UUID`. As long as a file exists for a state entry, the engine keeps that filename — you can name a file `blub-blub-blub.md` and it stays put regardless of what the resource is called on the dashboard.
949965

950966
What this means in practice for renames:
951967

952968
| Approach | What happens |
953969
|---|---|
954-
| Rename the file locally + `npm run push -- <org>` | New UUID is minted for the renamed file; the old UUID becomes orphaned in the dashboard. Run `npm run cleanup -- <org> --force` (or `npm run push -- <org> --force <file>`) to delete the orphan. |
955-
| Rename in the dashboard first, then `npm run pull -- <org>` | UUID is preserved. The pulled file lands with the new name and the existing UUID suffix; no orphan. |
970+
| Rename the resource in the dashboard, then `npm run pull -- <org>` | UUID preserved. The local filename is **unchanged** — pull only updates the file's content (the new `name:` lands in the frontmatter). No second file, no orphan. This holds even under `--force` / "overwrite" — overwrite replaces content, never the filename. |
971+
| Rename the file locally + `npm run push -- <org>` | The renamed file has no state entry, so the orphan-YAML gate fires (it can't tell a rename from a new resource). Re-key state instead: rename in the dashboard first and pull, or accept a new UUID by pushing with `--allow-new-files` and cleaning up the old orphan via `npm run cleanup -- <org> --force`. |
956972

957-
If preserving the UUID matters (e.g. it's referenced from a phone number, outbound campaign, or external integration), rename via the dashboard first and pull. Otherwise, accept the new UUID and clean up the orphan.
973+
Because dashboard renames no longer change the local filename, the old "rename in the dashboard to preserve the UUID" dance is unnecessary — the UUID is always preserved on pull for any already-tracked resource.
958974

959975
---
960976

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ See `docs/learnings/voice-providers.md` for related "property X should not exist
6565
- Call time limits / graceful ending → `docs/learnings/call-duration.md`
6666
- Voice provider field cheat-sheet → `docs/learnings/voice-providers.md`
6767
- YAML authoring conventions, .vapi-ignore lifecycle → `docs/learnings/yaml-conventions.md`
68+
- Pull/push/apply behavior per drift & existence scenario → `docs/learnings/sync-behavior.md`
6869

6970
This list mirrors the "Learnings & recipes" table in `AGENTS.md`. Keep both in sync — if you add a new learnings file, update both files plus `docs/learnings/README.md`.
7071

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Every command works in two modes:
8080
| `npm run rollback` || `npm run rollback -- <org> --list` or `--to <ISO>` | Restore from a snapshot in `.vapi-state.<org>.snapshots/` (one is written before every push/apply). |
8181
| `npm run call` || `npm run call -- <org> -a <name>` or `-s <squad>` | Start an interactive WebSocket call against an assistant or squad. |
8282
| `npm run sim` || `npm run sim -- <org> --suite <name> --target <name>` | Run a simulation suite (or specific simulations) against a deployed assistant/squad. |
83+
| `npm run migrate` || `npm run migrate` | One-time, all orgs at once: slim legacy state files to pure `name → uuid` and seed the per-developer `.vapi-state-hash/` baseline store from the old hashes. Required once after upgrading to the hash-store engine — `pull`/`push`/`apply` refuse legacy-shaped state until it runs. Idempotent. |
8384
| `npm run build` ||| Type-check the codebase (`tsc --noEmit`). |
8485
| `npm test` ||| Run regression tests (`node:test`). |
8586

@@ -290,7 +291,8 @@ npm run push -- <org> --dry-run
290291
Resources are scoped by organization (not fixed `dev`/`stg`/`prod` names). Each org gets:
291292

292293
- `.env.<org>` — API token and base URL
293-
- `.vapi-state.<org>.json` — resource ID ↔ UUID mappings
294+
- `.vapi-state.<org>.json` — resource name ↔ UUID mappings (nothing else — committed)
295+
- `.vapi-state-hash/<org>/<uuid>` — last-seen platform content hash per resource, used for drift detection (per-developer, gitignored)
294296
- `resources/<org>/` — all resource files
295297

296298
```
@@ -365,12 +367,20 @@ npm run pull -- my-org
365367
# ✨ new-tool -> resources/my-org/tools/new-tool.yml
366368
```
367369

368-
Detection works in two layers, so it covers both day-to-day and fresh-clone
370+
Detection works in three layers, so it covers both day-to-day and fresh-clone
369371
workflows:
370372

371-
1. **Git-tracked changes** — files that show up in `git status` (modified,
372-
deleted, or individually untracked) are preserved.
373-
2. **mtime fallback** — if git can't help (no commits yet, the resource tree
373+
1. **Content baseline (primary)** — each resource's last-seen platform hash
374+
lives in the per-developer `.vapi-state-hash/<org>/<uuid>` store
375+
(gitignored). Comparing local / baseline / dashboard hashes classifies
376+
every resource as clean, local-ahead (preserved ⬆️), dashboard-ahead
377+
(synced down ⬇️ — local was unchanged, nothing to lose), or both-diverged
378+
(gated behind `--resolve=ours|theirs|fail|defer`). See
379+
`docs/learnings/sync-behavior.md` for the full matrix.
380+
2. **Git-tracked changes** — when no baseline exists yet, files that show up
381+
in `git status` (modified, deleted, or individually untracked) are
382+
preserved.
383+
3. **mtime fallback** — if git can't help (no commits yet, the resource tree
374384
isn't tracked at all, or git just had nothing to say), files that are
375385
newer than `.vapi-state.<org>.json` are still preserved. This is the safety
376386
net for the "fresh clone, edit a file, run pull again" case.

docs/learnings/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Each file targets a specific topic so you can load only the context you need.
2727
| Voicemail detection / VM vs human classification | [voicemail-detection.md](voicemail-detection.md) |
2828
| Enforcing call time limits / graceful call ending | [call-duration.md](call-duration.md) |
2929
| Voice provider field cheat-sheet (Cartesia vs 11labs vs others) | [voice-providers.md](voice-providers.md) |
30+
| YAML authoring conventions, .vapi-ignore lifecycle | [yaml-conventions.md](yaml-conventions.md) |
31+
| What will pull/push/apply do in situation X? | [sync-behavior.md](sync-behavior.md) |
3032

3133
---
3234

@@ -69,3 +71,12 @@ Proven patterns and setup guides:
6971
| [multilingual.md](multilingual.md) | Three approaches to multilingual agents, provider recommendations, tool message patterns, common pitfalls |
7072
| [websocket.md](websocket.md) | Audio formats, timing rules, silence values, control messages, connection management, error codes |
7173
| [call-duration.md](call-duration.md) | Call time limits, `maxDurationSeconds`, `call.timeElapsed` hooks, graceful shutdown recipes, wrap-up patterns |
74+
75+
### Engine Behavior
76+
77+
How the gitops sync engine itself behaves:
78+
79+
| File | What it covers |
80+
|------|----------------|
81+
| [sync-behavior.md](sync-behavior.md) | The full pull/push/apply scenario matrix: state file vs hash-store baseline vs dashboard, drift directions (clean / local-ahead / dashboard-ahead / both-diverged), per-resource conflict prompt, existence cases (local-only file, dashboard-only resource, deletions either side, fresh clone, renames, legacy-state migration), `.bkp` backup copies, flag cheat sheet |
82+
| [yaml-conventions.md](yaml-conventions.md) | YAML authoring conventions, `.vapi-ignore` lifecycle |

docs/learnings/simulations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ If your post-call analytics structured output uses `type: object` with nested sc
145145

146146
## Simulation File Names After Push
147147

148-
Simulation resource files use placeholder UUIDs (`a0000000`) locally. After the first push, the gitops engine creates platform resources and maps local filenames to platform UUIDs in `.vapi-state.<env>.json`. On subsequent state syncs (bootstrap), filenames may be updated to include the platform name — this triggers `name_mismatch` warnings that are resolved automatically by re-running bootstrap.
148+
Simulation resource files use placeholder UUIDs (`a0000000`) locally. After the first push, the gitops engine creates platform resources and maps local filenames to platform UUIDs in `.vapi-state.<env>.json`. The local filename is a stable handle and is **not** rewritten to match the platform `name`once a UUID is tracked in state, pull and bootstrap preserve the existing filename and only update file content. (Older engine versions renamed files to match the platform name and emitted `name_mismatch` warnings; that behavior was removed — see `improvements.md` #22.)
149149

150150
---
151151

0 commit comments

Comments
 (0)