Skip to content
Open
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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Drift direction is computed against the per-developer baseline store `.vapi-stat

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

**Listing completeness (applies to every command).** Vapi list endpoints cap a response at 100 items and expose no page cursor — only `createdAt` comparison filters. The engine pages backwards through `createdAt` until it gets a short page, so pull, push's invalid-mapping detection, `delete`'s orphan sweep, `audit`, and the credential reverse-map all see the whole type instead of the first hundred. When completeness cannot be proven — an endpoint that ignores the cursor params, a payload with no `createdAt`, or the page-count backstop — the engine says so on stderr. Treat that warning as "do not infer deletion from absence for this type".

**Pull-output icon legend.** Distinct semantics in a single pulled-resource line:

| Icon | Meaning |
Expand Down
36 changes: 28 additions & 8 deletions docs/learnings/sync-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ function of which of them exist and whether their contents agree.
| **Baseline** (`B`) | `.vapi-state-hash/<org>/<uuid>` | **no** (per-developer, gitignored) | sha256 of the last platform content *you* saw (last pull or push) |
| **Dashboard resource** (`D`) | Vapi platform | n/a | What's live |

**One letter per artifact — the same letter for the thing and for its hash.**
`L` means both the local file and the hash of its contents; `D` means both the
live dashboard resource and the hash of its contents; `B` is a hash already.
Comparisons below are always between hashes.

The local **filename is organizational only** — content hashes never include
it. The file↔UUID link is owned entirely by the state entry; the baseline is
keyed by UUID, so renames don't invalidate it.
Expand All @@ -36,17 +41,17 @@ keyed by UUID, so renames don't invalidate it.
### The drift triangle

With all four artifacts present, compare three hashes — local file (`L`),
baseline (`B`), platform (`P`) — all computed in the same canonical basis
baseline (`B`), dashboard (`D`) — all computed in the same canonical basis
(`canonicalizeForHash`: server fields stripped, UUID refs → slugs, credential
UUIDs → names):

| L vs B | P vs B | Direction | Meaning |
| L vs B | D vs B | Direction | Meaning |
|---|---|---|---|
| = | = | `clean` | Nobody changed anything |
| ≠ | = | `local-ahead` | You edited locally; dashboard untouched → **your change is the natural next step UP** |
| = | ≠ | `dashboard-ahead` | Someone edited the dashboard; you didn't → **their change is the natural next step DOWN** |
| ≠ | ≠ (but L = P) | treated as `clean` | Both sides already agree; the baseline is just stale → self-heals, never blocks, never prompts |
| ≠ | ≠ (and L ≠ P) | `both-diverged` | True 3-way conflict — the only case a human is ever asked about |
| ≠ | ≠ (but L = D) | treated as `clean` | Both sides already agree; the baseline is just stale → self-heals, never blocks, never prompts |
| ≠ | ≠ (and L ≠ D) | `both-diverged` | True 3-way conflict — the only case a human is ever asked about |

> **The core principle:** a question is only ever asked **per resource**, and
> only for `both-diverged`-class situations at push time. Everything else has
Expand All @@ -61,23 +66,23 @@ UUIDs → names):
| Command | Behavior |
|---|---|
| `pull` | 📝 rewrites the file (content no-op), refreshes baseline from disk |
| `push` | `P == B` → PATCH proceeds silently (content no-op), baseline := response hash |
| `push` | `D == B` → PATCH proceeds silently (content no-op), baseline := response hash |
| `apply` | both of the above; fully silent |

### 2. Local changed, dashboard didn't (`local-ahead`)

| Command | Behavior |
|---|---|
| `pull` | ⬆️ local file preserved as-is; baseline untouched |
| `push` | `P == B` → **pushed silently, no question** (your edit is the natural next step); baseline := response hash |
| `push` | `D == B` → **pushed silently, no question** (your edit is the natural next step); baseline := response hash |
| `apply` | pull preserves → push silent |

### 3. Dashboard changed, local didn't (`dashboard-ahead`)

| Command | Behavior |
|---|---|
| `pull` | ⬇️ dashboard version **synced down over the unchanged local file**, baseline refreshed. Nothing is lost — local had no edits. (Mirror of the silent-push rule.) |
| `push` (without pulling first) | `P ≠ B` → conflict path: TTY prompt / CI block. Note: choosing "push local" here would **revert** the dashboard edit — usually you want "keep dashboard", then pull. |
| `push` (without pulling first) | `D ≠ B` → conflict path: TTY prompt / CI block. Note: choosing "push local" here would **revert** the dashboard edit — usually you want "keep dashboard", then pull. |
| `apply` | pull stage syncs it down → push stage sees clean → **silent, no prompt** |

### 4. Both changed differently (`both-diverged`) — the only real conflict
Expand All @@ -94,7 +99,7 @@ UUIDs → names):
| `apply` (default `--resolve=defer`) | pull defers → push prompts for **exactly the conflicted resources**; clean ones flow silently |
| `apply --resolve=ours` | no questions: pull re-baselines, push runs with `--overwrite` (CI semantics; dashboard edits lose) |

### 5. Both changed identically (L = P, stale baseline)
### 5. Both changed identically (L = D, stale baseline)

Both `pull` and `push` treat this as clean (live sides agree — nothing to
reconcile). The no-op write/PATCH re-seeds the baseline, self-healing the
Expand Down Expand Up @@ -141,6 +146,21 @@ additional ownership boundary.
| `push` | drift GET hits 404 → stale state mapping dropped, baseline deleted, resource **skipped this run** with a warning. The file is now case A — the next push hits the orphan gate, and `--allow-new-files` recreates it (deliberately requires re-confirmation). |
| `pull` | resource absent from the dashboard list → its state entry drops out of the rewritten state file; the local file remains and becomes case A |

### Listing completeness

Vapi list endpoints return at most 100 items per request and offer no page
cursor, only `createdAt` comparison filters. `fetchAllResources` pages backwards
through `createdAt` until a short page proves it reached the end, deduping by id
(the cursor is inclusive, so an item sharing the boundary timestamp is re-read
rather than skipped).

If the walk cannot be completed — the endpoint ignores the cursor params, the
payload carries no `createdAt`, or the page-count backstop trips — the engine
warns and the listing must be treated as a partial view. Anything that reads
"absent from the listing" as "deleted on the dashboard" is wrong on a partial
view; that includes push's `missing_remote` detection and `delete`'s orphan
sweep, which consume the resources today without consuming the verdict.

### E. Fresh clone / new developer (L + S committed, but B is per-dev and missing)

| Command | Behavior |
Expand Down
70 changes: 69 additions & 1 deletion improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ you which stack PR closes the row.**
| 24 | Bare `push` is too easy to use as the deploy path | Raw push skips apply's validate+pull safety | None | Open — mitigated by per-resource drift gate |
| 25 | Interactive flows lack automated coverage | Picker/conflict-prompt regressions ship silently | None | Open — scheduled for the test-update iteration |
| 26 | Rollback is snapshot replay, not transaction rollback | Creates/deletes/state drift are not fully undone | #3 | Open — document/plan transactional rollback |
| 27 | List endpoints read one unpaginated page at a time | >100-resource fleets got truncated orphan detection | None | RESOLVED 2026-08-01 (consumers gap open) |

**Active backlog after cleanup:** `#2`, `#6`, `#8`, `#12`, `#20`, and `#24–#26`. Resolved entries stay in this file as historical incident notes per the maintenance directive; stale superseded backlog rows are not duplicated.
**Active backlog after cleanup:** `#2`, `#6`, `#8`, `#12`, `#20`, `#24–#26`, and the open remainder of `#27` (wiring the listing-completeness verdict into push/delete/audit, and moving `cleanup.ts` onto the shared pager). Resolved entries stay in this file as historical incident notes per the maintenance directive; stale superseded backlog rows are not duplicated.

---

Expand Down Expand Up @@ -1298,6 +1299,73 @@ as a transactional deploy rollback until create/delete/state coverage exists.

---

## 27. List endpoints were read one unpaginated page at a time

**[RESOLVED 2026-08-01]**

**Discovered:** while reviewing force-pull reconciliation. Any feature that reads
"absent from the listing" as "deleted" would have been wrong for every resource
past the first hundred.

### Problem

`fetchAllResources` issued exactly one GET per resource type with no `limit` and
no cursor. Vapi list endpoints cap a response at 100 items, so for any org with
more than 100 resources of a type the engine silently operated on a truncated
inventory.

### Current behavior (Verified)

Every consumer that decides *what exists* from a listing was affected:
`push.ts:452-506` flags a tracked resource absent from the listing as
`missing_remote` and drops its state mapping, `delete.ts` treats listing absence
as an orphan, `audit` reports on the same data, and `pullCredentials` builds the
credential reverse-map from it — a truncated credential list leaves raw UUIDs in
YAML the reverse-map was supposed to resolve.

Confirmed against the live API (2026-08-01): all nine list endpoints accept
`?limit=` and `?createdAtLe=`, and `createdAtLe` genuinely filters.

### Risk

Silent and size-dependent: correct for small orgs, wrong for large ones, with no
output distinguishing the two.

### Current mitigation

None before the fix. Operators on large fleets had to know not to trust
orphan-detection output.

### Possible fix

Implemented as `fetchPagedList` in `src/pull.ts`: page backwards through
`createdAt` (the only cursor these endpoints offer) until a short page proves the
end, dedupe by id, and return a `complete` verdict alongside the resources.
`fetchAllResources` and `fetchCredentials` are wrappers, so pull, push, delete,
and audit all get the completed pages.

Two details are load-bearing, both covered by `tests/list-pagination.test.ts`:

- The **first** request carries `?limit` too. Without it, completeness was decided
by comparing a response capped at the API's *default* page size against
`LIST_PAGE_SIZE` — correct only while those two numbers happen to be equal. Had
the API default ever dropped below `LIST_PAGE_SIZE`, a capped response would
have read as complete.
- The cursor is `createdAtLe`, not `createdAtLt`. An exclusive cursor drops every
item sharing the boundary timestamp with the previous page's oldest, and
bulk-created fleets do collide; inclusive re-reads the boundary item, which the
id map dedupes.

### Status

**RESOLVED 2026-08-01** for the listings themselves. Open remainder: `push`,
`delete`, and `audit` consume the paged resources but not the `complete` verdict,
so they still report confidently on a partial view. `cleanup.ts` has its own local
`vapiGet` and stays unpaginated (`src/cleanup.ts:193`); it fails safe, since a
truncated listing finds *fewer* dashboard orphans to delete.

---

## Out of scope (intentionally not improvements)

- **State file is identity-only and not git-ignored.** It's intentionally
Expand Down
139 changes: 129 additions & 10 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,23 +153,138 @@ export function preserveExplicitOursPaths(
// API Functions
// ─────────────────────────────────────────────────────────────────────────────

export async function fetchAllResources(
resourceType: ResourceType,
): Promise<VapiResource[]> {
const endpoint = ENDPOINT_MAP[resourceType];
const data = await vapiGet<unknown>(endpoint);

// Handle paginated response format (e.g., structured-output returns { results: [], metadata: {} })
// Vapi list endpoints cap a response at 100 items (`limit` defaults to 100) and
// expose no page cursor — only `createdAt` comparison filters. One GET per type
// therefore truncates any fleet bigger than a page, silently, and everything
// that decides *what exists* from a listing inherits the truncation: pull's
// materialization, push's invalid-mapping detection, `delete`'s orphan sweep,
// and `audit`.
const LIST_PAGE_SIZE = 100;
// 50 pages = 5k resources of one type. A runaway-loop backstop, not a real cap.
const LIST_MAX_PAGES = 50;

// Anything a list endpoint can return. Both `VapiResource` and `VapiCredential`
// satisfy it, so the pager below serves resource types and credentials alike.
type Listable = { id: string; createdAt?: unknown };

function unwrapListResponse<T extends Listable>(data: unknown): T[] {
// Some endpoints wrap (e.g. structured-output returns { results, metadata }).
if (
data &&
typeof data === "object" &&
"results" in data &&
Array.isArray((data as Record<string, unknown>).results)
) {
return (data as { results: VapiResource[] }).results;
return (data as { results: T[] }).results;
}
return data as T[];
}

// Compared as strings: Vapi emits `createdAt` as UTC ISO 8601 (`...Z`), which
// sorts lexicographically. Anything that is not a string is skipped, and a page
// with no usable timestamp stops paging rather than guessing a cursor.
function oldestCreatedAt(resources: Listable[]): string | undefined {
let oldest: string | undefined;
for (const resource of resources) {
const createdAt = resource.createdAt;
if (typeof createdAt !== "string") continue;
if (!oldest || createdAt < oldest) oldest = createdAt;
}
return oldest;
}

interface PagedListing<T> {
resources: T[];
/** True only when the engine can prove it saw every item of this type. */
complete: boolean;
}

async function fetchPagedList<T extends Listable>(
endpoint: string,
label: string,
): Promise<PagedListing<T>> {
// Send `limit` on the FIRST request too, so the page size is ours rather than
// whatever the endpoint defaults to. Without it, completeness is decided by
// comparing a response capped at the API's default against LIST_PAGE_SIZE —
// correct only while those two numbers happen to be equal.
let first: T[];
try {
first = unwrapListResponse<T>(
await vapiGet<unknown>(`${endpoint}?limit=${LIST_PAGE_SIZE}`),
);
} catch (error) {
// An endpoint that rejects `limit` still has to work. Fall back to the bare
// request, but do not claim completeness: without a known page size there is
// nothing to compare the length against.
console.warn(
` ⚠️ ${label}: list endpoint rejected ?limit (${error instanceof Error ? error.message : String(error)}); listing cannot be proven complete`,
);
const bare = unwrapListResponse<T>(await vapiGet<unknown>(endpoint));
return { resources: bare, complete: false };
}
if (!Array.isArray(first)) return { resources: first, complete: false };

// A short first page proves we saw everything in one request — the common case
// pays nothing for paging.
if (first.length < LIST_PAGE_SIZE) return { resources: first, complete: true };

// A full page means the response was capped. Walk backwards through
// `createdAt`, the only cursor these endpoints offer.
const byId = new Map(first.map((resource) => [resource.id, resource]));
let cursor = oldestCreatedAt(first);
let complete = false;
let pages = 1;

for (; cursor && pages <= LIST_MAX_PAGES; pages++) {
let batch: T[];
try {
// `createdAtLe`, not `Lt`: an exclusive cursor silently drops every item
// sharing the boundary timestamp with the previous page's oldest (bulk
// created fleets do collide). Inclusive re-reads the boundary item, which
// the id map dedupes for free.
batch = unwrapListResponse<T>(
await vapiGet<unknown>(
`${endpoint}?limit=${LIST_PAGE_SIZE}&createdAtLe=${encodeURIComponent(cursor)}`,
),
);
} catch (error) {
console.warn(
` ⚠️ ${label}: could not page past ${byId.size} (${error instanceof Error ? error.message : String(error)})`,
);
break;
}
if (!Array.isArray(batch)) break;

const sizeBefore = byId.size;
for (const resource of batch) byId.set(resource.id, resource);
if (batch.length < LIST_PAGE_SIZE) {
complete = true;
break;
}
// No new ids, or a cursor that will not move: paging is not working on this
// endpoint (params ignored, or every item shares one timestamp).
const nextCursor = oldestCreatedAt(batch);
if (byId.size === sizeBefore || !nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}

return data as VapiResource[];
const resources = [...byId.values()];
if (!complete) {
console.warn(
` ⚠️ ${label}: listing may be incomplete (${resources.length} fetched, ${pages} request(s)). Anything that infers "deleted" from absence should not trust it.`,
);
}
return { resources, complete };
}

export async function fetchAllResources(
resourceType: ResourceType,
): Promise<VapiResource[]> {
const { resources } = await fetchPagedList<VapiResource>(
ENDPOINT_MAP[resourceType],
resourceType,
);
return resources;
}

export async function fetchResourceById(
Expand Down Expand Up @@ -197,7 +312,11 @@ interface VapiCredential {
}

async function fetchCredentials(): Promise<VapiCredential[]> {
return vapiGet<VapiCredential[]>("/credential");
// Paged for the same reason resource types are: a truncated credential list
// leaves raw UUIDs in the YAML that the credential reverse-map exists to
// resolve.
return (await fetchPagedList<VapiCredential>("/credential", "credentials"))
.resources;
}

function credentialSlug(cred: VapiCredential): string {
Expand Down
Loading