Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This project manages **Vapi voice agent configurations** as code. All resources

**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.

**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.
**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.

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

Expand Down
2 changes: 1 addition & 1 deletion docs/learnings/simulations.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ Returns the org's voice-simulation concurrency budget:

**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.

**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.
**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.

**Recommendations:**
1. **Skip `--force` against fresh orgs** that haven't had their stock fixtures touched. The pending-deletions warning is harmless without `--force`.
Expand Down
8 changes: 8 additions & 0 deletions docs/learnings/yaml-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ The blank line after `---` is conventional; the strict requirement is just that

`.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.

The list is **bidirectional**:

- **Pull** skips matched ids (never writes them to disk, never tracks them in state).
- **Push** (and `apply`) skips matched ids in the load pass β€” they are filtered out before drift detection, validation, or any API call.
- **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.
- **`--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.
- **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.

The recovery flow when a sync surfaces "drift" you didn't expect β€” typically prompted by "was that not in the .vapi-ignore?":

1. **Inspect first**, don't edit. Diff the file against `main` to see whether the path was already ignored:
Expand Down
56 changes: 56 additions & 0 deletions improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ you which stack PR closes the row.**
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | RESOLVED 2026-04-30 (Stack D) |
| 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | RESOLVED 2026-04-30 (Stack D) |
| 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Partial β€” Stack D heuristic |
| 21 | `.vapi-ignore` was pull-only (push could silently delete) | `--force` push DELETEd dashboard-only opt-outs | None | RESOLVED 2026-05-11 (#TBD) |

---

Expand Down Expand Up @@ -974,6 +975,61 @@ flag that the heuristic is partial.

---

## 21. `.vapi-ignore` was pull-only β€” push and orphan-detect ignored the list

**[RESOLVED 2026-05-11] (#TBD)**

**Discovered:** during the symmetric-ignore plan review β€” a `--force` push
in a fresh customer org would happily DELETE a dashboard assistant that the
repo had explicitly opted out of managing via `.vapi-ignore`, just because
the local file was absent. Data-safety incident waiting to happen.

### Problem

`.vapi-ignore` (gitignore-flavored opt-out list at
`resources/<org>/.vapi-ignore`) was honored on `pull` only. `push` and
`apply` loaded all on-disk resources unconditionally, validated them, and
sent them. Orphan-detect computed "in state but not in local files" without
consulting the ignore list, so a state-mapped resource whose local file had
been removed would be queued for DELETE under `--force` β€” even when its id
was explicitly listed in `.vapi-ignore`.

### Current behavior (Verified)

- `src/resources.ts` `loadResources()` accepts `{ ignorePatterns }`; matched
ids emit `🚫 <id> (matched .vapi-ignore: <pattern>)` and are filtered out
before duplicate detection or parsing.
- `src/push.ts` reads `const ignorePatterns = FORCE_DELETE ? [] : loadIgnorePatterns()`
and passes it into every `loadResources` call. `--force` bypasses the
load-filter for deliberate overrides.
- `src/delete.ts` `findOrphanedResources()` accepts an `ignoredIds: Set<string>`;
matched ids are excluded from the orphan list. `deleteOrphanedResources`
computes the matched set per type and emits
`🚫 <type>/<id> retained (matched .vapi-ignore β€” orphan-protected)` so the
retention is visible. Orphan-protect ALWAYS honors the list β€” `--force`
does not bypass it.
- `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.

### Risk

Silent dashboard deletion of a resource the repo had explicitly declined to
manage. Hardest possible class of mistake to recover from in production.

### Resolution

Symmetric load-filter + orphan-protect + reference validator, with `--force`
bypassing the load-filter but never bypassing orphan-protect. Test coverage
in `tests/vapi-ignore-push.test.ts` (T1–T5 spawn-fixture integration tests +
in-process unit tests for each helper).

### Status

RESOLVED 2026-05-11 (#TBD β€” PR number updates when opened).

---

## Out of scope (intentionally not improvements)

- **State file is identity-only and not git-ignored.** It's intentionally
Expand Down
17 changes: 15 additions & 2 deletions resources/.vapi-ignore.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
# .vapi-ignore β€” explicit opt-out for resources this repo does NOT manage.
#
# Resources matching any pattern below are skipped during `npm run pull --
# <org>` β€” never written to disk, never tracked in state. Use this for:
# Matched resources are skipped in BOTH directions:
# - `npm run pull -- <org>` never writes them to disk / tracks them.
# - `npm run push -- <org>` (and `npm run apply`) skips them during the
# load pass; ids matching the ignore list are filtered out before
# drift detection, validation, or any API call. Orphan-detection ALSO
# honors the list β€” a `--force` push will not silently DELETE a
# dashboard resource just because its local file is absent and its
# id matches `.vapi-ignore`.
# - `--force` on push bypasses the load-filter (so a deliberate override
# can flow through) BUT the 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.
#
# Use this for:
# - Resources owned by another team or repo
# - Legacy/broken resources you don't want to touch
# - Resource types you don't care about (e.g. an entire `simulations/**`)
Expand Down
2 changes: 1 addition & 1 deletion src/apply.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execSync } from "child_process";
import { join, dirname, resolve } from "path";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
8 changes: 4 additions & 4 deletions src/call.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { existsSync, readFileSync } from "fs";
import { join, dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { execSync } from "child_process";
import * as readline from "readline";
import { existsSync, readFileSync } from "fs";
import { createRequire } from "module";
import { dirname, join, resolve } from "path";
import * as readline from "readline";
import { fileURLToPath } from "url";
import type { Environment, StateFile } from "./types.ts";

const require = createRequire(import.meta.url);
Expand Down
2 changes: 1 addition & 1 deletion src/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolve } from "path";
import { fileURLToPath } from "url";
import { VAPI_ENV, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import { VAPI_BASE_URL, VAPI_ENV, VAPI_TOKEN } from "./config.ts";
import { loadState } from "./state.ts";

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "fs";
import { join, basename, dirname, resolve, relative } from "path";
import { basename, dirname, join, relative, resolve } from "path";
import { fileURLToPath } from "url";
import type { Environment, ResourceType } from "./types.ts";
import { VALID_RESOURCE_TYPES } from "./types.ts";
Expand Down
104 changes: 96 additions & 8 deletions src/delete.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { vapiDelete, VapiApiError } from "./api.ts";
import { FORCE_DELETE } from "./config.ts";
import { VapiApiError, vapiDelete } from "./api.ts";
import { FORCE_DELETE, loadIgnorePatterns, matchesIgnore } from "./config.ts";
import { extractReferencedIds } from "./resolver.ts";
import { FOLDER_MAP } from "./resources.ts";
import type {
ResourceFile,
ResourceState,
StateFile,
LoadedResources,
OrphanedResource,
ResourceFile,
ResourceState,
ResourceType,
StateFile,
} from "./types.ts";

// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -17,18 +18,45 @@ import type {
export function findOrphanedResources(
loadedResourceIds: string[],
stateResourceIds: Record<string, ResourceState>,
ignoredIds?: Set<string>,
): OrphanedResource[] {
const orphaned: OrphanedResource[] = [];

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

return orphaned;
}

// Compute the set of state-tracked ids that match the current .vapi-ignore
// for a given resource type. Used by `deleteOrphanedResources` to wire
// orphan-protect and to emit the "retained" log lines.
function computeIgnoredIds(
type: ResourceType,
stateSection: Record<string, ResourceState>,
patterns: string[],
): { ignored: Set<string>; matched: Array<{ id: string; pattern: string }> } {
const ignored = new Set<string>();
const matched: Array<{ id: string; pattern: string }> = [];
if (patterns.length === 0) return { ignored, matched };
const folder = FOLDER_MAP[type];
for (const resourceId of Object.keys(stateSection)) {
const pattern = matchesIgnore(folder, resourceId, patterns);
if (pattern) {
ignored.add(resourceId);
matched.push({ id: resourceId, pattern });
}
}
return { ignored, matched };
}

// ─────────────────────────────────────────────────────────────────────────────
// Reference Checking - Find resources that reference a given resource
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -158,62 +186,122 @@ export async function deleteOrphanedResources(
const shouldCheck = (type: ResourceType) =>
!typesToDelete || typesToDelete.includes(type);

// Orphan-protect: always honor .vapi-ignore here, even under `--force`,
// so an opt-out can't be silently DELETEd.
const patterns = loadIgnorePatterns();
const ignoredByType: Record<
ResourceType,
{ ignored: Set<string>; matched: Array<{ id: string; pattern: string }> }
> = {
tools: computeIgnoredIds("tools", state.tools, patterns),
structuredOutputs: computeIgnoredIds(
"structuredOutputs",
state.structuredOutputs,
patterns,
),
assistants: computeIgnoredIds("assistants", state.assistants, patterns),
squads: computeIgnoredIds("squads", state.squads, patterns),
personalities: computeIgnoredIds(
"personalities",
state.personalities,
patterns,
),
scenarios: computeIgnoredIds("scenarios", state.scenarios, patterns),
simulations: computeIgnoredIds("simulations", state.simulations, patterns),
simulationSuites: computeIgnoredIds(
"simulationSuites",
state.simulationSuites,
patterns,
),
evals: computeIgnoredIds("evals", state.evals, patterns),
};

const retainedLogLines: string[] = [];
for (const [type, { matched }] of Object.entries(ignoredByType) as Array<
[ResourceType, (typeof ignoredByType)[ResourceType]]
>) {
for (const { id } of matched) {
// Only emit the retained line when the id WOULD have been an orphan
// (i.e., not present in the loaded set). A still-loaded id is not at
// risk of orphan deletion and logging it would be noise.
const loadedIds = loadedResources[type].map((r) => r.resourceId);
if (!loadedIds.includes(id)) {
retainedLogLines.push(
` 🚫 ${type}/${id} retained (matched .vapi-ignore β€” orphan-protected)`,
);
}
}
}

// Find orphaned resources (only for applicable types)
const orphanedTools = shouldCheck("tools")
? findOrphanedResources(
loadedResources.tools.map((t) => t.resourceId),
state.tools,
ignoredByType.tools.ignored,
)
: [];
const orphanedOutputs = shouldCheck("structuredOutputs")
? findOrphanedResources(
loadedResources.structuredOutputs.map((o) => o.resourceId),
state.structuredOutputs,
ignoredByType.structuredOutputs.ignored,
)
: [];
const orphanedAssistants = shouldCheck("assistants")
? findOrphanedResources(
loadedResources.assistants.map((a) => a.resourceId),
state.assistants,
ignoredByType.assistants.ignored,
)
: [];
const orphanedSquads = shouldCheck("squads")
? findOrphanedResources(
loadedResources.squads.map((s) => s.resourceId),
state.squads,
ignoredByType.squads.ignored,
)
: [];
const orphanedPersonalities = shouldCheck("personalities")
? findOrphanedResources(
loadedResources.personalities.map((p) => p.resourceId),
state.personalities,
ignoredByType.personalities.ignored,
)
: [];
const orphanedScenarios = shouldCheck("scenarios")
? findOrphanedResources(
loadedResources.scenarios.map((s) => s.resourceId),
state.scenarios,
ignoredByType.scenarios.ignored,
)
: [];
const orphanedSimulations = shouldCheck("simulations")
? findOrphanedResources(
loadedResources.simulations.map((s) => s.resourceId),
state.simulations,
ignoredByType.simulations.ignored,
)
: [];
const orphanedSimulationSuites = shouldCheck("simulationSuites")
? findOrphanedResources(
loadedResources.simulationSuites.map((s) => s.resourceId),
state.simulationSuites,
ignoredByType.simulationSuites.ignored,
)
: [];
const orphanedEvals = shouldCheck("evals")
? findOrphanedResources(
loadedResources.evals.map((e) => e.resourceId),
state.evals,
ignoredByType.evals.ignored,
)
: [];

if (retainedLogLines.length > 0) {
for (const line of retainedLogLines) console.log(line);
}

// Collect all orphaned resources (in reverse dependency order for deletion)
const allOrphaned = [
...orphanedEvals.map((r) => ({
Expand Down
2 changes: 1 addition & 1 deletion src/drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
// has nothing to compare against β€” only PATCH (update) is drift-sensitive.
// ─────────────────────────────────────────────────────────────────────────────

import { hashPayload } from "./state-serialize.ts";
import { VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import { hashPayload } from "./state-serialize.ts";
import type { ResourceState } from "./types.ts";

export interface DriftCheckResult {
Expand Down
Loading