diff --git a/AGENTS.md b/AGENTS.md index 55ad4e2..687798f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/docs/learnings/sync-behavior.md b/docs/learnings/sync-behavior.md index 34e9dd9..edaf375 100644 --- a/docs/learnings/sync-behavior.md +++ b/docs/learnings/sync-behavior.md @@ -19,6 +19,11 @@ function of which of them exist and whether their contents agree. | **Baseline** (`B`) | `.vapi-state-hash//` | **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. @@ -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 @@ -61,7 +66,7 @@ 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`) @@ -69,7 +74,7 @@ UUIDs → names): | 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`) @@ -77,7 +82,7 @@ UUIDs → names): | 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 @@ -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 @@ -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 | diff --git a/improvements.md b/improvements.md index b8696d5..442ab8e 100644 --- a/improvements.md +++ b/improvements.md @@ -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. --- @@ -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 diff --git a/src/pull.ts b/src/pull.ts index fec6cf4..85a111c 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -153,23 +153,138 @@ export function preserveExplicitOursPaths( // API Functions // ───────────────────────────────────────────────────────────────────────────── -export async function fetchAllResources( - resourceType: ResourceType, -): Promise { - const endpoint = ENDPOINT_MAP[resourceType]; - const data = await vapiGet(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(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).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 { + resources: T[]; + /** True only when the engine can prove it saw every item of this type. */ + complete: boolean; +} + +async function fetchPagedList( + endpoint: string, + label: string, +): Promise> { + // 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( + await vapiGet(`${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(await vapiGet(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( + await vapiGet( + `${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 { + const { resources } = await fetchPagedList( + ENDPOINT_MAP[resourceType], + resourceType, + ); + return resources; } export async function fetchResourceById( @@ -197,7 +312,11 @@ interface VapiCredential { } async function fetchCredentials(): Promise { - return vapiGet("/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("/credential", "credentials")) + .resources; } function credentialSlug(cred: VapiCredential): string { diff --git a/tests/list-pagination.test.ts b/tests/list-pagination.test.ts new file mode 100644 index 0000000..41d95c5 --- /dev/null +++ b/tests/list-pagination.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import test from "node:test"; + +// ───────────────────────────────────────────────────────────────────────────── +// Vapi list endpoints cap a response at 100 items and expose no page cursor, +// only `createdAt` comparison filters. These tests pin the resulting pager. +// +// They assert on the REQUESTS the engine makes, not only on what it does with +// responses. That distinction matters: the original implementation omitted +// `limit` from its first request and decided completeness by comparing the +// response length against its own page size — so it was really comparing against +// the API's default. Every response-only test passed, because the stub happened +// to answer the way the author assumed the API did. +// ───────────────────────────────────────────────────────────────────────────── + +// config.ts exits at module load without a token and a slug-shaped argv[2]. +process.argv = ["node", "test", "test-fixture-org"]; +process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used"; + +interface StubReply { + status?: number; + body: unknown; +} + +const requests: string[] = []; +let reply: (url: string) => StubReply = () => ({ body: [] }); + +const server = createServer((req, res) => { + requests.push(req.url ?? ""); + const answer = reply(req.url ?? ""); + res.statusCode = answer.status ?? 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(answer.body)); +}); +await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), +); +server.unref(); +const { port } = server.address() as AddressInfo; + +// Must be set before importing pull.ts — config.ts freezes the base URL at load. +process.env.VAPI_BASE_URL = `http://127.0.0.1:${port}`; +const { fetchAllResources } = await import("../src/pull.ts"); + +const BASE = Date.parse("2026-06-01T00:00:00.000Z"); + +/** `count` tools, newest first, one minute apart — the shape a real page has. */ +function page(count: number, startIndex = 0, createdAt?: string) { + return Array.from({ length: count }, (_, i) => { + const index = startIndex + i; + return { + id: `tool-${index}`, + name: `tool ${index}`, + createdAt: createdAt ?? new Date(BASE - index * 60_000).toISOString(), + }; + }); +} + +async function capturingWarnings( + run: () => Promise, +): Promise<{ result: T; warnings: string }> { + const original = console.warn; + const lines: string[] = []; + console.warn = (...args: unknown[]) => lines.push(args.join(" ")); + try { + return { result: await run(), warnings: lines.join("\n") }; + } finally { + console.warn = original; + } +} + +function reset(next: (url: string) => StubReply) { + requests.length = 0; + reply = next; +} + +test("the first request carries ?limit, so the page size is ours not the API's", async () => { + // The regression this pins. If `limit` is omitted, the engine compares a + // response capped at the API's default against its own LIST_PAGE_SIZE — a + // comparison that is only correct while those two numbers happen to match. + reset(() => ({ body: page(3) })); + await fetchAllResources("tools"); + + assert.equal(requests.length, 1, "a short listing costs exactly one request"); + assert.match(requests[0] as string, /^\/tool\?limit=100$/); +}); + +test("a short first page is complete: no second request, no warning", async () => { + reset(() => ({ body: page(7) })); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + assert.equal(result.length, 7); + assert.equal(requests.length, 1); + assert.doesNotMatch(warnings, /incomplete/); +}); + +test("a full first page is paged through until a short page arrives", async () => { + reset((url) => + url.includes("createdAtLe") + ? { body: page(4, 100) } + : { body: page(100) }, + ); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + assert.equal(result.length, 104, "both pages are returned"); + assert.equal(requests.length, 2); + assert.match(requests[1] as string, /createdAtLe=/); + assert.doesNotMatch(warnings, /incomplete/); +}); + +test("the cursor is inclusive, so items sharing the boundary timestamp survive", async () => { + // `createdAtLt` would exclude every item stamped exactly at the boundary. + // Resources created in bulk do collide on `createdAt`, and a dropped item then + // looks deleted to anything that infers absence. + const shared = new Date(BASE - 99 * 60_000).toISOString(); + const firstPage = [...page(99), { id: "boundary-a", name: "a", createdAt: shared }]; + const secondPage = [ + { id: "boundary-a", name: "a", createdAt: shared }, // re-read, deduped + { id: "boundary-b", name: "b", createdAt: shared }, // would be lost by `Lt` + ]; + reset((url) => + url.includes("createdAtLe") ? { body: secondPage } : { body: firstPage }, + ); + const result = await fetchAllResources("tools"); + + const ids = result.map((r) => r.id); + assert.equal(new Set(ids).size, ids.length, "the boundary item is not duplicated"); + assert.ok(ids.includes("boundary-b"), "the co-timestamped item is not dropped"); +}); + +test("an endpoint that ignores the cursor is reported, not looped on", async () => { + // Returning the same full page forever must terminate and be flagged, rather + // than spinning to the page cap or silently claiming completeness. + reset(() => ({ body: page(100) })); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + assert.equal(result.length, 100); + assert.equal(requests.length, 2, "one probe past the first page is enough"); + assert.match(warnings, /listing may be incomplete/); +}); + +test("a page with no usable createdAt cannot be paged, and says so", async () => { + reset(() => ({ + body: Array.from({ length: 100 }, (_, i) => ({ id: `x-${i}`, name: `x ${i}` })), + })); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + assert.equal(result.length, 100); + assert.equal(requests.length, 1, "no cursor is guessable, so no second request"); + assert.match(warnings, /listing may be incomplete/); +}); + +test("an endpoint that rejects ?limit falls back to a bare request", async () => { + // Degrade rather than fail the whole pull — but never claim completeness, + // because without a known page size there is nothing to compare against. + reset((url) => + url.includes("limit=") + ? { status: 400, body: { message: "limit is not supported" } } + : { body: page(3) }, + ); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + assert.equal(result.length, 3, "the bare request's items are still returned"); + assert.match(requests[0] as string, /limit=100/); + assert.equal(requests[1], "/tool"); + assert.match(warnings, /rejected \?limit/); +}); + +test("the { results } wrapper shape is unwrapped and paged like a bare array", async () => { + // structured-output wraps its list; the pager must see through it on both the + // first request and the cursor requests. + reset((url) => + url.includes("createdAtLe") + ? { body: { results: page(2, 100), metadata: {} } } + : { body: { results: page(100), metadata: {} } }, + ); + const result = await fetchAllResources("structuredOutputs"); + + assert.equal(result.length, 102); + assert.match(requests[0] as string, /^\/structured-output\?limit=100$/); +}); + +test("the cursor advances across several pages, not just one", async () => { + // Two-page walks can pass by accident: a stub that answers any cursor with the + // same second page looks identical to a working walk. Serving strictly older + // slices per cursor proves the engine is threading the timestamp through. + reset((url) => { + const match = url.match(/createdAtLe=([^&]+)/); + if (!match) return { body: page(100) }; + const cursor = decodeURIComponent(match[1] as string); + const index = Math.round((BASE - Date.parse(cursor)) / 60_000); + // Everything strictly older than the cursor, capped at a page. + const remaining = 250 - index; + return { body: page(Math.min(100, Math.max(0, remaining)), index) }; + }); + const { result, warnings } = await capturingWarnings(() => + fetchAllResources("tools"), + ); + + const ids = new Set(result.map((r) => r.id)); + assert.equal(ids.size, result.length, "no duplicates across pages"); + assert.equal(result.length, 250, "every item across three pages is returned"); + assert.ok(requests.length >= 3, `expected a multi-page walk, made ${requests.length}`); + assert.doesNotMatch(warnings, /incomplete/); +});