Skip to content

Commit 7cbcb50

Browse files
authored
fix(.vapi-ignore): honor patterns on push and apply, not just pull (#26)
`.vapi-ignore` is now bidirectional. Four wired pieces: 1. **Load-filter** — `src/resources.ts:loadResources` accepts `{ ignorePatterns }`; matched ids emit `🚫 <id> (matched .vapi-ignore: <pattern>)` and are filtered out before duplicate detection, validation, or any API call. 2. **Push wire** — `src/push.ts` reads `const ignorePatterns = FORCE_DELETE ? [] : loadIgnorePatterns()` once and passes it into every `loadResources` call. `--force` bypasses the load-filter for deliberate overrides. 3. **Orphan-protect (CRITICAL DATA SAFETY)** — `src/delete.ts` `findOrphanedResources` accepts `ignoredIds: Set<string>`; ids matched by `.vapi-ignore` are excluded from the orphan list, and `deleteOrphanedResources` emits a `🚫 <type>/<id> retained (matched .vapi-ignore — orphan-protected)` line for each retained id. **Orphan-protect ALWAYS honors the ignore list, even under `--force`** — this prevents a `--force` push from silently DELETE'ing a dashboard resource the repo has explicitly opted out of managing. 4. **Ref-validate** — `src/validate.ts:validateNoIgnoredReferences` walks each loaded resource's referenced ids and emits an `error`-severity finding for any ref pointing at an ignored id; `--strict` push aborts before any API call. Docs updated (`AGENTS.md`, `docs/learnings/yaml-conventions.md`, `docs/learnings/simulations.md`, `resources/.vapi-ignore.example`) to describe the bidirectional semantics, the `--force` bypass and the ref-validate rule. `improvements.md` gets a `[RESOLVED 2026-05-11]` entry under #21. Tests: `tests/vapi-ignore-push.test.ts` pins T1–T5 spawn-fixture integration tests + in-process unit tests for each helper. All 128 tests pass; biome + tsc clean.
1 parent 822411b commit 7cbcb50

37 files changed

Lines changed: 1212 additions & 118 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This project manages **Vapi voice agent configurations** as code. All resources
1010

1111
**Template-safe first run:** In a fresh clone, prefer `npm run pull -- <org> --bootstrap` to refresh `.vapi-state.<org>.json` and credential mappings without materializing the target org's resources into `resources/<org>/`. `npm run push -- <org>` will auto-run the same bootstrap sync when it detects empty or stale state for the resources being applied.
1212

13-
**Excluding resources from sync (`.vapi-ignore`):** To prevent specific resources from being pulled at all (e.g. assistants owned by another team or legacy resources you don't want to manage), create `resources/<org>/.vapi-ignore` with gitignore-style patterns. See `resources/.vapi-ignore.example` for syntax and examples. Ignored resources are silently skipped on every pull and never tracked in state — distinct from "locally deleted" which keeps an entry in state.
13+
**Excluding resources from sync (`.vapi-ignore`):** To prevent specific resources from being touched in either direction (e.g. assistants owned by another team or legacy resources you don't want to manage), create `resources/<org>/.vapi-ignore` with gitignore-style patterns. See `resources/.vapi-ignore.example` for syntax and examples. The list is **bidirectional**: matched ids are skipped on pull (never written), on push and `apply` (never sent), and orphan-protected (a `--force` push will not DELETE a dashboard resource whose id matches the ignore). `--force` on push bypasses the load-filter so a deliberate override can flow through, but orphan-protect still applies. A resource that references an ignored resource (e.g. a squad pointing at `assistants/foo` while `assistants/foo` is ignored) is a validation ERROR — `--strict` push aborts before any API call.
1414

1515
**Learnings & recipes:** Before configuring resources or debugging issues, read the relevant file in **`docs/learnings/`**. Load only what you need:
1616

docs/learnings/simulations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ Returns the org's voice-simulation concurrency budget:
356356

357357
**Why it matters for gitops:** in a freshly-scaffolded org (no local `simulations/personalities/` directory), the engine sees these 7 personalities as orphans on every push — "exist on the dashboard, not in local files." Without `--force` this is just log noise. With `--force`, the push halts on the FIRST delete attempt against an immortal default and exits non-zero, before reaching any legitimate orphans you actually wanted to clean.
358358

359-
**Also note:** `.vapi-ignore` does **not** suppress these from the "pending deletions" warning. `matchesIgnore` is only called during pull operations (`src/pull.ts:695`), not during the push-time deletion-detection sweep. Adding `simulations/personalities/**` to `.vapi-ignore` quiets future pulls from materializing the defaults locally, but the push warning persists.
359+
**Also note:** `.vapi-ignore` is now **bidirectional** (symmetric on pull, push, and orphan-detect). Adding `simulations/personalities/**` to `.vapi-ignore` quiets future pulls from materializing the defaults locally AND orphan-protects them: ignored ids are excluded from the "pending deletions" sweep and a `🚫 personalities/<id> retained (matched .vapi-ignore — orphan-protected)` line is emitted instead. `--force` does NOT override this — the orphan-protect always wins. A resource that references an ignored resource is a hard validation error; `--strict` push aborts before any API call.
360360

361361
**Recommendations:**
362362
1. **Skip `--force` against fresh orgs** that haven't had their stock fixtures touched. The pending-deletions warning is harmless without `--force`.

docs/learnings/yaml-conventions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ The blank line after `---` is conventional; the strict requirement is just that
187187

188188
`.vapi-ignore` lives at `resources/<org>/.vapi-ignore` and excludes specific resources from pull and push so the dashboard stays the source of truth for them. See `AGENTS.md` (line 13) for the basic gitignore-style syntax.
189189

190+
The list is **bidirectional**:
191+
192+
- **Pull** skips matched ids (never writes them to disk, never tracks them in state).
193+
- **Push** (and `apply`) skips matched ids in the load pass — they are filtered out before drift detection, validation, or any API call.
194+
- **Orphan-detect** during push honors the list: a `--force` push will NOT silently DELETE a dashboard resource whose id matches the ignore, even if the state file maps it. Operators see a `🚫 <type>/<id> retained (matched .vapi-ignore — orphan-protected)` line so the retention is visible.
195+
- **`--force`** on push bypasses the load-filter so a deliberate override can flow through (mirrors pull's `--force`), but the orphan-protect still applies — there is no `--force` escape hatch for the delete path.
196+
- **References to ignored resources are a hard validation error.** A squad pointing at `assistants/foo` while `assistants/foo` is in `.vapi-ignore` produces an `❌ squads/<id> references assistants/foo, which is in .vapi-ignore` finding. `--strict` push aborts before any API call.
197+
190198
The recovery flow when a sync surfaces "drift" you didn't expect — typically prompted by "was that not in the .vapi-ignore?":
191199

192200
1. **Inspect first**, don't edit. Diff the file against `main` to see whether the path was already ignored:

improvements.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ you which stack PR closes the row.**
7272
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | RESOLVED 2026-04-30 (Stack D) |
7373
| 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | RESOLVED 2026-04-30 (Stack D) |
7474
| 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Partial — Stack D heuristic |
75+
| 21 | `.vapi-ignore` was pull-only (push could silently delete) | `--force` push DELETEd dashboard-only opt-outs | None | RESOLVED 2026-05-11 (#TBD) |
7576

7677
---
7778

@@ -974,6 +975,61 @@ flag that the heuristic is partial.
974975

975976
---
976977

978+
## 21. `.vapi-ignore` was pull-only — push and orphan-detect ignored the list
979+
980+
**[RESOLVED 2026-05-11] (#TBD)**
981+
982+
**Discovered:** during the symmetric-ignore plan review — a `--force` push
983+
in a fresh customer org would happily DELETE a dashboard assistant that the
984+
repo had explicitly opted out of managing via `.vapi-ignore`, just because
985+
the local file was absent. Data-safety incident waiting to happen.
986+
987+
### Problem
988+
989+
`.vapi-ignore` (gitignore-flavored opt-out list at
990+
`resources/<org>/.vapi-ignore`) was honored on `pull` only. `push` and
991+
`apply` loaded all on-disk resources unconditionally, validated them, and
992+
sent them. Orphan-detect computed "in state but not in local files" without
993+
consulting the ignore list, so a state-mapped resource whose local file had
994+
been removed would be queued for DELETE under `--force` — even when its id
995+
was explicitly listed in `.vapi-ignore`.
996+
997+
### Current behavior (Verified)
998+
999+
- `src/resources.ts` `loadResources()` accepts `{ ignorePatterns }`; matched
1000+
ids emit `🚫 <id> (matched .vapi-ignore: <pattern>)` and are filtered out
1001+
before duplicate detection or parsing.
1002+
- `src/push.ts` reads `const ignorePatterns = FORCE_DELETE ? [] : loadIgnorePatterns()`
1003+
and passes it into every `loadResources` call. `--force` bypasses the
1004+
load-filter for deliberate overrides.
1005+
- `src/delete.ts` `findOrphanedResources()` accepts an `ignoredIds: Set<string>`;
1006+
matched ids are excluded from the orphan list. `deleteOrphanedResources`
1007+
computes the matched set per type and emits
1008+
`🚫 <type>/<id> retained (matched .vapi-ignore — orphan-protected)` so the
1009+
retention is visible. Orphan-protect ALWAYS honors the list — `--force`
1010+
does not bypass it.
1011+
- `src/validate.ts` `validateNoIgnoredReferences()` walks each loaded
1012+
resource's referenced ids and emits an `error`-severity finding for any
1013+
ref pointing at an ignored id. `--strict` push aborts before any API call.
1014+
1015+
### Risk
1016+
1017+
Silent dashboard deletion of a resource the repo had explicitly declined to
1018+
manage. Hardest possible class of mistake to recover from in production.
1019+
1020+
### Resolution
1021+
1022+
Symmetric load-filter + orphan-protect + reference validator, with `--force`
1023+
bypassing the load-filter but never bypassing orphan-protect. Test coverage
1024+
in `tests/vapi-ignore-push.test.ts` (T1–T5 spawn-fixture integration tests +
1025+
in-process unit tests for each helper).
1026+
1027+
### Status
1028+
1029+
RESOLVED 2026-05-11 (#TBD — PR number updates when opened).
1030+
1031+
---
1032+
9771033
## Out of scope (intentionally not improvements)
9781034

9791035
- **State file is identity-only and not git-ignored.** It's intentionally

resources/.vapi-ignore.example

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
# .vapi-ignore — explicit opt-out for resources this repo does NOT manage.
22
#
3-
# Resources matching any pattern below are skipped during `npm run pull --
4-
# <org>` — never written to disk, never tracked in state. Use this for:
3+
# Matched resources are skipped in BOTH directions:
4+
# - `npm run pull -- <org>` never writes them to disk / tracks them.
5+
# - `npm run push -- <org>` (and `npm run apply`) skips them during the
6+
# load pass; ids matching the ignore list are filtered out before
7+
# drift detection, validation, or any API call. Orphan-detection ALSO
8+
# honors the list — a `--force` push will not silently DELETE a
9+
# dashboard resource just because its local file is absent and its
10+
# id matches `.vapi-ignore`.
11+
# - `--force` on push bypasses the load-filter (so a deliberate override
12+
# can flow through) BUT the orphan-protect still applies.
13+
# - A resource that references an ignored resource (e.g. a squad
14+
# pointing at `assistants/foo` while `assistants/foo` is ignored) is
15+
# a validation ERROR — `--strict` push aborts before any API call.
16+
#
17+
# Use this for:
518
# - Resources owned by another team or repo
619
# - Legacy/broken resources you don't want to touch
720
# - Resource types you don't care about (e.g. an entire `simulations/**`)

src/apply.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from "child_process";
2-
import { join, dirname, resolve } from "path";
2+
import { dirname, join, resolve } from "path";
33
import { fileURLToPath } from "url";
44

55
// ─────────────────────────────────────────────────────────────────────────────

src/call.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { existsSync, readFileSync } from "fs";
2-
import { join, dirname, resolve } from "path";
3-
import { fileURLToPath } from "url";
41
import { execSync } from "child_process";
5-
import * as readline from "readline";
2+
import { existsSync, readFileSync } from "fs";
63
import { createRequire } from "module";
4+
import { dirname, join, resolve } from "path";
5+
import * as readline from "readline";
6+
import { fileURLToPath } from "url";
77
import type { Environment, StateFile } from "./types.ts";
88

99
const require = createRequire(import.meta.url);

src/cleanup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { resolve } from "path";
22
import { fileURLToPath } from "url";
3-
import { VAPI_ENV, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
3+
import { VAPI_BASE_URL, VAPI_ENV, VAPI_TOKEN } from "./config.ts";
44
import { loadState } from "./state.ts";
55

66
// ─────────────────────────────────────────────────────────────────────────────

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readFileSync } from "fs";
2-
import { join, basename, dirname, resolve, relative } from "path";
2+
import { basename, dirname, join, relative, resolve } from "path";
33
import { fileURLToPath } from "url";
44
import type { Environment, ResourceType } from "./types.ts";
55
import { VALID_RESOURCE_TYPES } from "./types.ts";

src/delete.ts

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { vapiDelete, VapiApiError } from "./api.ts";
2-
import { FORCE_DELETE } from "./config.ts";
1+
import { VapiApiError, vapiDelete } from "./api.ts";
2+
import { FORCE_DELETE, loadIgnorePatterns, matchesIgnore } from "./config.ts";
33
import { extractReferencedIds } from "./resolver.ts";
4+
import { FOLDER_MAP } from "./resources.ts";
45
import type {
5-
ResourceFile,
6-
ResourceState,
7-
StateFile,
86
LoadedResources,
97
OrphanedResource,
8+
ResourceFile,
9+
ResourceState,
1010
ResourceType,
11+
StateFile,
1112
} from "./types.ts";
1213

1314
// ─────────────────────────────────────────────────────────────────────────────
@@ -17,18 +18,45 @@ import type {
1718
export function findOrphanedResources(
1819
loadedResourceIds: string[],
1920
stateResourceIds: Record<string, ResourceState>,
21+
ignoredIds?: Set<string>,
2022
): OrphanedResource[] {
2123
const orphaned: OrphanedResource[] = [];
2224

2325
for (const [resourceId, entry] of Object.entries(stateResourceIds)) {
24-
if (!loadedResourceIds.includes(resourceId)) {
25-
orphaned.push({ resourceId, uuid: entry.uuid });
26-
}
26+
if (loadedResourceIds.includes(resourceId)) continue;
27+
// Data-safety: an id absent from local files BUT listed in .vapi-ignore
28+
// is an opt-out, not an orphan. Excluding here prevents `--force` push
29+
// from silently DELETE'ing dashboard resources the repo has explicitly
30+
// declined to manage.
31+
if (ignoredIds?.has(resourceId)) continue;
32+
orphaned.push({ resourceId, uuid: entry.uuid });
2733
}
2834

2935
return orphaned;
3036
}
3137

38+
// Compute the set of state-tracked ids that match the current .vapi-ignore
39+
// for a given resource type. Used by `deleteOrphanedResources` to wire
40+
// orphan-protect and to emit the "retained" log lines.
41+
function computeIgnoredIds(
42+
type: ResourceType,
43+
stateSection: Record<string, ResourceState>,
44+
patterns: string[],
45+
): { ignored: Set<string>; matched: Array<{ id: string; pattern: string }> } {
46+
const ignored = new Set<string>();
47+
const matched: Array<{ id: string; pattern: string }> = [];
48+
if (patterns.length === 0) return { ignored, matched };
49+
const folder = FOLDER_MAP[type];
50+
for (const resourceId of Object.keys(stateSection)) {
51+
const pattern = matchesIgnore(folder, resourceId, patterns);
52+
if (pattern) {
53+
ignored.add(resourceId);
54+
matched.push({ id: resourceId, pattern });
55+
}
56+
}
57+
return { ignored, matched };
58+
}
59+
3260
// ─────────────────────────────────────────────────────────────────────────────
3361
// Reference Checking - Find resources that reference a given resource
3462
// ─────────────────────────────────────────────────────────────────────────────
@@ -158,62 +186,122 @@ export async function deleteOrphanedResources(
158186
const shouldCheck = (type: ResourceType) =>
159187
!typesToDelete || typesToDelete.includes(type);
160188

189+
// Orphan-protect: always honor .vapi-ignore here, even under `--force`,
190+
// so an opt-out can't be silently DELETEd.
191+
const patterns = loadIgnorePatterns();
192+
const ignoredByType: Record<
193+
ResourceType,
194+
{ ignored: Set<string>; matched: Array<{ id: string; pattern: string }> }
195+
> = {
196+
tools: computeIgnoredIds("tools", state.tools, patterns),
197+
structuredOutputs: computeIgnoredIds(
198+
"structuredOutputs",
199+
state.structuredOutputs,
200+
patterns,
201+
),
202+
assistants: computeIgnoredIds("assistants", state.assistants, patterns),
203+
squads: computeIgnoredIds("squads", state.squads, patterns),
204+
personalities: computeIgnoredIds(
205+
"personalities",
206+
state.personalities,
207+
patterns,
208+
),
209+
scenarios: computeIgnoredIds("scenarios", state.scenarios, patterns),
210+
simulations: computeIgnoredIds("simulations", state.simulations, patterns),
211+
simulationSuites: computeIgnoredIds(
212+
"simulationSuites",
213+
state.simulationSuites,
214+
patterns,
215+
),
216+
evals: computeIgnoredIds("evals", state.evals, patterns),
217+
};
218+
219+
const retainedLogLines: string[] = [];
220+
for (const [type, { matched }] of Object.entries(ignoredByType) as Array<
221+
[ResourceType, (typeof ignoredByType)[ResourceType]]
222+
>) {
223+
for (const { id } of matched) {
224+
// Only emit the retained line when the id WOULD have been an orphan
225+
// (i.e., not present in the loaded set). A still-loaded id is not at
226+
// risk of orphan deletion and logging it would be noise.
227+
const loadedIds = loadedResources[type].map((r) => r.resourceId);
228+
if (!loadedIds.includes(id)) {
229+
retainedLogLines.push(
230+
` 🚫 ${type}/${id} retained (matched .vapi-ignore — orphan-protected)`,
231+
);
232+
}
233+
}
234+
}
235+
161236
// Find orphaned resources (only for applicable types)
162237
const orphanedTools = shouldCheck("tools")
163238
? findOrphanedResources(
164239
loadedResources.tools.map((t) => t.resourceId),
165240
state.tools,
241+
ignoredByType.tools.ignored,
166242
)
167243
: [];
168244
const orphanedOutputs = shouldCheck("structuredOutputs")
169245
? findOrphanedResources(
170246
loadedResources.structuredOutputs.map((o) => o.resourceId),
171247
state.structuredOutputs,
248+
ignoredByType.structuredOutputs.ignored,
172249
)
173250
: [];
174251
const orphanedAssistants = shouldCheck("assistants")
175252
? findOrphanedResources(
176253
loadedResources.assistants.map((a) => a.resourceId),
177254
state.assistants,
255+
ignoredByType.assistants.ignored,
178256
)
179257
: [];
180258
const orphanedSquads = shouldCheck("squads")
181259
? findOrphanedResources(
182260
loadedResources.squads.map((s) => s.resourceId),
183261
state.squads,
262+
ignoredByType.squads.ignored,
184263
)
185264
: [];
186265
const orphanedPersonalities = shouldCheck("personalities")
187266
? findOrphanedResources(
188267
loadedResources.personalities.map((p) => p.resourceId),
189268
state.personalities,
269+
ignoredByType.personalities.ignored,
190270
)
191271
: [];
192272
const orphanedScenarios = shouldCheck("scenarios")
193273
? findOrphanedResources(
194274
loadedResources.scenarios.map((s) => s.resourceId),
195275
state.scenarios,
276+
ignoredByType.scenarios.ignored,
196277
)
197278
: [];
198279
const orphanedSimulations = shouldCheck("simulations")
199280
? findOrphanedResources(
200281
loadedResources.simulations.map((s) => s.resourceId),
201282
state.simulations,
283+
ignoredByType.simulations.ignored,
202284
)
203285
: [];
204286
const orphanedSimulationSuites = shouldCheck("simulationSuites")
205287
? findOrphanedResources(
206288
loadedResources.simulationSuites.map((s) => s.resourceId),
207289
state.simulationSuites,
290+
ignoredByType.simulationSuites.ignored,
208291
)
209292
: [];
210293
const orphanedEvals = shouldCheck("evals")
211294
? findOrphanedResources(
212295
loadedResources.evals.map((e) => e.resourceId),
213296
state.evals,
297+
ignoredByType.evals.ignored,
214298
)
215299
: [];
216300

301+
if (retainedLogLines.length > 0) {
302+
for (const line of retainedLogLines) console.log(line);
303+
}
304+
217305
// Collect all orphaned resources (in reverse dependency order for deletion)
218306
const allOrphaned = [
219307
...orphanedEvals.map((r) => ({

0 commit comments

Comments
 (0)