Files
Justin d0c4a7b2a1 feat(runpod-migrate): GraphQL / REST v1 → REST v2 migration skill (#46)
* feat(runpod-migrate): add GraphQL / REST v1 -> REST v2 migration skill

Users migrating off REST v1 / GraphQL hand the job to a coding agent rather
than reading docs, so the skill is the surface that reaches them. It inventories
a codebase by API version, rewrites the call sites, and flags what breaks.

- SKILL.md: scope -> inventory -> brief breaking changes -> plan -> migrate ->
  verify -> summarize. Summary separates "required" from "cleanup".
- scripts/rp_api_inventory.py: stdlib-only scanner. Buckets call sites by
  generation (GraphQL / v1 / v1 field names / already-v2 / serverless job API /
  SDK wrappers), suppresses hits that carry v2 context, and keeps prose and
  rp-migrate:keep-v1 rollback paths out of the plan so --fail-on-legacy works
  in CI.
- reference/: v1->v2 and GraphQL->v2 mapping tables, breaking changes split
  into loud renames vs same-name-different-behavior vs removed capability,
  v2 unlocks keyed to what the code already does, the RUNPOD_API_V1 rollback
  flag, and a worked before/after migration.
- Catalog reads always request include=AVAILABILITY.

Mappings and error shapes were checked against the live v2 API; resources
created for verification were deleted. Two spec-vs-behavior gaps are documented
as observed: `timeout` comes back 0 rather than the documented 300000 default,
and a bad image is 500 {"error"} on v1 but 422 {title,status,detail} on v2.

* fix(runpod-migrate): unblock the inventory step and correct audit findings

Review pass over the skill: an independent accuracy audit against freshly
fetched specs plus read-only live calls, and execution of the commands the
docs tell users to run.

Blocking:
- The documented scanner invocation could not work. `python3
  scripts/rp_api_inventory.py .` assumes the script is in the user's repo,
  but it ships in the installed skill directory while the agent's working
  directory is the customer's project. Step 1 failed with "No such file",
  and every later step depends on it. Now resolved via CLAUDE_PLUGIN_ROOT,
  the skill's own directory, or a search, with a self-check. Verified from a
  simulated plugin install into a simulated customer repo, and asserted in
  the inventory eval.
- The GPU pool lookup one-liner was a Python SyntaxError (escaped quotes in
  an f-string expression), on the command that resolves a GPU type to its
  serverless pool.

Corrections, each re-verified against the specs before applying:
- Template `category` was already an enum with the same values and default
  in v1; it is not a v2 tightening.
- `minCudaVersion` and `volumeEncrypted` were listed as dropped from v1 pod
  create; neither was ever in PodCreateInput.
- The 422 table omitted `gpu` from the endpoint-create required list — the
  field most likely to be lost in a gpuTypeIds -> gpu.pools rewrite.
- globalNetworking was framed as a v2 unlock; the flag existed in v1, the
  response object is what is new.
- Unknown-field rejection is `unevaluatedProperties: false` on the composed
  Create/Update schemas, not `additionalProperties: false` everywhere.
- Rate-limit headers are omitted for rate-limit-exempt callers.
- The 400-vs-422 rule conflated schema and resource-level errors.

Added, both confirmed:
- Class 2: GraphQL `stockStatus` is High/Medium/Low/None but v2
  `availability` is HIGH/MEDIUM/LOW/NONE. A rank table carried across stops
  matching silently.
- Class 2: v2 `mounts` is far more restricted on PATCH than v1's
  volumeInGb/volumeMountPath — kind fixed at create, volumeId immutable,
  clearing rejected, partial entries rejected.
- `mounts.persistent` is deprecated and host-pinned, so a literal
  volumeInGb translation inherits data-loss-on-host-failure.

Rejected one audit finding: the GraphQL network-volume mutations are real —
rphttp2, the v2 REST server, issues them — so the mapping row and scanner
signal stay. The public schema page simply omits them, as it omits
saveEndpoint and saveTemplate; graphql-to-v2.md now warns readers not to
treat that page as complete.

* fix(runpod-migrate): repair the verify gate and the gaps a cold run exposed

A fresh agent was given only the skill and the terse prompt "migrate my
codebase from runpod v1 to v2", ran the whole workflow on a fixture, and
reported back. Findings, verified and fixed.

--fail-on-legacy reported "4 files need migration" on a correctly migrated
tree. A gate that fails green builds gets deleted, and it teaches the agent
to stop trusting step 5. Two causes, both fixed:
- Comment detection only matched whole-line comments, so the skill's own
  house style -- `"image": image,  # was imageName` -- scored as a live v1
  field. Comment detection is now quote-aware and handles trailing comments
  (and does not mistake the `//` in `https://` for one).
- v2 context was matched per line, so `f"{BASE}/pods"` with `BASE` defined
  eleven lines up read as a leftover v1 path. Base-URL constants are now
  derived per file. The hardcoded name guesses (V2_BASE, v2_base, V2_URL)
  are gone -- the migrated fixture had been passing only because it happened
  to use one of them, which meant the corpus was written to match the
  scanner rather than the other way round.

Shipped-code fixes:
- The GPU fallback snippet treated any non-201 as scarce capacity, so a 422
  walked the entire preference list and raised "no GPU available" -- maximally
  misleading during a migration, when 422 is the expected failure. Now fast-
  fails on 422 and keeps the response body in the error.

Missing mappings, both silent failures:
- Billing `amount` -> `totalAmount`. The naive port is a KeyError, and it sits
  one line from the far quieter /billing/endpoints trap.
- `dockerStartCmd` -> `args` joins an argv array into one string. Joining
  ["bash","-lc","python x.py"] naively runs python as the -lc script text.
  No 422, no crash -- just the wrong command. Added as Class 2.
- `volumeInGb: 0` has no `mounts` translation; omit `mounts` entirely.

Workflow:
- Step 3 gains a third bucket, "decisions the user must make", so Class 3
  capability loss has somewhere to land. Without it the cold run dropped
  minRAMPerGPU and minVcpuCount with a code comment -- the exact silent drop
  breaking-changes.md forbids.
- The scope instruction told the agent to ask when unclear, then supplied a
  table resolving every phrasing, so it never fired. Now: infer, state, move
  on; the real question is the step 3 fork.
- File order is dependency-blind (fewest call sites first), which put a
  consumer ahead of the transport helper it imports. Shared helpers first.
- Commit hygiene forbade mixing behavior and version changes while the
  opening pitch advertised one of those behavior changes as the payoff.
  Reconciled: improvements land in the cleanup bucket.
- `rp-migrate: ignore` existed in the scanner but appeared nowhere in
  SKILL.md, so the only documented escape hatch was `keep-v1` -- which
  records a lie when used on a false positive. Both markers now documented
  with the distinction, plus the residual cases the gate can still misread.

Placement: the 25-line benefits pitch opened the file but only pays off at
step 6; trimmed to four lines pointing at unlocks.md. The GPU fallback
rebuild -- the largest piece of new code a migration needs -- is now
signposted from step 4 where it is used, not only from step 2 where it lives.

* test(runpod-migrate): add scanner corpora and wire them into CI

The inventory scanner is the only executable in this repo, and its signal
table is 60+ interacting regexes with `unless` clauses — a one-line edit
breaks a distant case silently. Until now the only thing catching that was
me running fixtures by hand on one laptop, and those fixtures were not in
the repo.

Adds testdata/runpod-migrate/ (4 corpora, 68 KB) plus
hooks/check_migrate_scanner.py, run by CI alongside the other four hooks.
15 assertions, every one corresponding to a defect that actually shipped
during review:

- provision.js contains no "runpod" string, only GraphQL operation names.
  A hand-written prefilter skipped it entirely.
- report.py has no URL, no import, no operation name — only a renamed
  *response* field. Same prefilter bug, and the class of file a v2 rename
  breaks silently in the callers of a wrapper.
- submit_job.py calls api.runpod.ai/v2 — the job API, one letter from the
  control plane. Flagging it makes an agent rewrite working job submission.
- correct-v2/ is the load-bearing corpus: the scanner once reported "4 files
  need migration" on a correctly migrated tree. A verify gate that fails on
  correct code gets deleted, and teaches agents to distrust the verify step.

Deliberately at repo root, not under skills/runpod-migrate/: marketplace.json
installs that directory, so anything inside it ships to every user who
installs the plugin.

Mutation-tested rather than assumed — reverting the trailing-comment fix, the
per-file base-URL derivation, or the job-API bucket each turns the hook red.
live_verify.py stays out of the repo: it rents a real GPU and must never run
in CI.

* docs(runpod-migrate): teach the agent to check the spec itself

The skill handed the agent mapping tables and no way to question them. Both
OpenAPI documents are public and need no auth, but SKILL.md never said so —
the URLs appeared once, in a reference file, with no instruction to use them.

That matters more than a missing link. v2 is actively developed, so the
tables are a snapshot with a verification date; and the reference docs
already record two places where the running API contradicts its own spec.
An agent that hits a 422 naming a field it cannot place had no documented
recourse except guessing.

Adds a "Ground truth" section with commands verified by running them
verbatim:
- fetch either spec
- print a request body's fields with required ones starred, resolving allOf
  composition (a naive read of the raw JSON misses it, which is how `gpu`
  went missing from the documented endpoint-create required list)
- find which schemas mention a field name, for decoding a 422

And a precedence rule: observed live behavior > spec > these tables. Where
the API contradicts the spec, trust the API and say so in the summary, so
the user learns a documented default cannot be relied on. Drift in the
tables should be reported, not silently worked around.

Notes that GraphQL cannot be checked this way — introspection is disabled
and the public reference page is incomplete.

* docs(runpod-migrate): cover every resource with an inline before/after

Example coverage was uneven, and concentrated in the wrong file. The cold run
reported never opening worked-example.md at all -- "never opened unlocks.md or
worked-example.md and didn't miss them" -- so 12 of the skill's 30 code blocks
did no work in the one real trial. What did fire were examples sitting inline
in the mapping tables at the moment of need.

So these go where the agent already is: next to the table row they explain,
in rest-v1-to-v2.md and graphql-to-v2.md rather than in the narrative.

Newly covered, each a real gap:
- Network volumes had zero worked code despite being the flagship rename --
  /networkvolumes -> /network-volumes is the example the ticket itself leads
  with, and it carries three simultaneous changes (hyphenated path,
  dataCenterId -> dataCenter, and the list envelope). Plus attaching one to a
  pod, where `path` is now mandatory.
- CPU pods were one table mention. computeType + cpuFlavorIds + vcpuCount ->
  a single `cpu` object, with the two CPU-only traps: mounts.persistent is
  rejected outright, and vcpuCount must be a power of two inside the flavor's
  advertised range.
- Pod lifecycle actions: four v1 paths collapsing into one body verb, with
  reset having no equivalent and `actions` telling you what is legal now.
- Registries, standalone templates, and list-with-filters.
- A curl pair, so codebases that are not Python have an anchor. Examples were
  13 Python to 1 JS.
- GraphQL: a full podFindAndDeployOnDemand conversion showing the four shape
  changes a field-by-field rename misses -- comma-string ports to array, env
  pair-list to map, dockerArgs to args, and string interpolation disappearing.

Every v2 body in the docs is now machine-checked against the live spec: 22
assertions covering required fields, unknown fields, the six enums, and the
nested config objects. All pass. The CPU example was checked against the live
catalog too -- cpu3c exists and vcpuCount 2 is inside its 2..32 range.

* docs(runpod-migrate): name the GraphQL volume mutations in the mapping table

A coverage audit over every v1 REST operation (37) and every customer-facing
GraphQL operation (23) found one real hole: createNetworkVolume,
updateNetworkVolume and deleteNetworkVolume existed only as a scanner signal
and as the prose "network volume mutations". An agent grepping the reference
for the operation name it found in the user's code got nothing back. Each is
now its own row with its v2 verb and path.

Also notes the /networkvolumes/{id}/update alias removal, which the pods,
endpoints and templates rows already called out.

The other 8 operations the audit flagged were a false alarm from the checker
normalizing {podId} while the docs use {id}; all are mapped.

* fix(runpod-migrate): report `ignore` and `keep-v1` as the distinct things they are

The scanner recorded only *whether* a line carried a suppression marker, not
which one, then rendered every marked hit as `keep-v1` / _(kept on purpose)_.
A repo whose remaining hits were all false positives was therefore described
as deliberately retaining v1 — the exact claim SKILL.md's marker table warns
against. Migrating a real codebase produced a report asserting "19 legacy call
sites kept on purpose" when it had none.

`ignore` was also documented as accepting `file` and `start`/`end` scope but
only ever matched at line scope: MARK_FILE/START/END were hardcoded to
`keep-v1`, and MARK_LINE's lookahead rejected `ignore file`, so it silently
did nothing.

- intentional_lines returns which marker applies, per line and per file.
- Hits carry `marker` instead of a boolean `intentional`.
- The verdict counts the two separately; rows label them differently.
- JSON `intentional_legacy` splits into `kept_on_purpose` and
  `marked_false_positive`.
- keep-v1 wins a tie, since leaving a hit visible as legacy is the safer error.

Both still drop out of the plan and out of --fail-on-legacy, which was never
the broken part.

Adds a testdata/runpod-migrate/markers corpus covering both markers at line,
region and file scope, plus seven assertions including one that reads the
rendered report and fails if an ignore-marked row is labelled kept on purpose.

* docs: show how to point an agent at the worked examples

Plain-English prompts route fine, but golden paths carry no SKILL.md and are
never auto-loaded, and each skill's reference/*.md is progressive-disclosure —
so an agent planning a build has no reason to open either unless asked.

Adds an 'Ask for it by name' section to the repo README: six copyable prompts
covering planning a new service, migrating to REST v2, deploying existing code,
GPU choice, debugging, and cold starts, each paired with the specific paths and
reference docs it pulls in. Reduces the pattern to
[skill] + [read the examples first] + [task].

The plugin README's Usage list gains the same treatment, split into one-off
actions versus work you are designing.

* docs: simplify the example call-out to 'look at the examples'

The prompt table named a specific golden path per row, which made the reader
responsible for knowing which of 24 paths applied. Pointing at the examples in
general is enough — the agent picks. Keeps the one thing worth explaining: golden
paths have no SKILL.md and reference/*.md is progressive-disclosure, so neither
loads unless asked for.

* docs(runpod-migrate): correct what v2 /billing/endpoints actually returns

The reference said an unchanged /billing/endpoints call returns 'the wrong
numbers'. It does not — v2's /billing/endpoints is the Runpod public endpoints
billing route and reports that product's spend correctly.

The trap is narrower and worth stating precisely: the path survives the
migration but now answers a different question, so a caller that carries it
across gets an accurate total for a product it did not ask about — and 0 if it
runs no public endpoints, which reads as 'no serverless spend'.

Links the two reference tables to golden path 11 so the product is identifiable.

* feat: add /runpod:migrate slash command for the migration flow

* feat: bare /runpod:migrate defaults to full scope on current directory

* fix(runpod-migrate): correct four v2 claims the live spec contradicts

The reference tables were verified on 2026-08-10 and four claims no longer
hold. Three of them were Class-3 rows — the class SKILL.md tells the agent to
stop and ask the user about — so being wrong there does not surface as a 422.
It buys an interruption over a decision that does not exist, followed by a
deliberate workaround for a capability the user still has.

- `templateId` is accepted on pod and endpoint create *and* update. It is not a
  422. What actually changed is the semantics: v2 resolves the template once at
  request time and retains no link, so editing a template no longer rolls
  changes out to existing resources. Moved to Class 2 as §13, since a quiet
  behavior change is exactly that class.
- `allowedCudaVersions` / `minCudaVersion` moved under `gpu` rather than being
  removed, nested there so they are unrepresentable on a CPU workload. Dropped
  from Class 3, recorded as a rename in both mapping tables.
- CPU endpoints are writable via `cpu: [{id, vcpuCount}]`. Row removed.
- `/v2/billing/networkvolumes` is a 404; the path is `/v2/billing/network-volumes`.
  The table warned about this exact hyphenation two rows earlier for the
  resource path.

Two rows narrowed rather than removed, both of which overstated a removal:

- GPU fallback applies to pods only. An endpoint's `gpu.pools` is a list and v2
  places workers on whichever listed pool has capacity, so the client-side loop
  this skill teaches is rebuilding something v2 kept.
- `countryCodes` survives as a `/v2/catalog/gpus` read filter. It still cannot
  constrain a create, so the row stands, but it can choose a target.

Also corrects the 422 table's required-field list: `image` is not
schema-required, because `templateId` can supply it.

The scanner's signal table carried the same three wrong claims as remediation
advice, so those rules move with the docs. All 22 scanner assertions still pass.

* ci: gate runpod-migrate's v2 claims against the spec

The four corrections in the previous commit were all found by diffing the docs
against the live spec, mechanically. Nothing in CI did that, which is why a
2026-08-10 verification pass could go stale unnoticed — catching it required
re-reading ~1,200 lines of tables by hand.

Two hooks, both stdlib-only:

- `check_migrate_tables.py` — every `METHOD /v2/...` claim in the skill's
  markdown must exist in the spec with that method. Also checks method-less path
  mentions, which the tables abbreviate as `…/v2/templates`. Paths the docs name
  as counter-examples (`/v2/networkvolumes`, cited as the 404) need an entry in
  EXPECTED_ABSENT with a reason, and the check fails if one of those starts
  existing — an allowlist that silently rots is worse than no allowlist.
- `check_migrate_class3.py` — every identifier in the Class-3 "capability
  removed" table is an assertion of absence, so existence alone falsifies the
  row and no parsing of the prose column is needed. Request-body fields and
  routes fail. Two softer outcomes are reported without failing: survives-as-a-
  read-filter (the row is true, just incomplete) and GraphQL-only identifiers,
  whose absence from a REST spec proves nothing and which are listed as
  unchecked rather than counted as verified.

Both default to a vendored snapshot at `testdata/runpod-migrate/v2-openapi.json`
so the PR gate only fails on what a PR changed. `--live` runs the same checks
against api.runpod.io, wired to a weekly non-blocking `spec-drift` workflow:
upstream moving is real news, but not a reason to red-X an unrelated PR.

Current state: 45/49 path claims verified, 9/11 Class-3 rows verified absent,
1 read-filter note, 2 GraphQL-only rows left for a human.

* docs(runpod-migrate): give countryCodes a migration instead of a dead end

The Class-3 row and the scanner both stopped at "no v2 equivalent." That is
true about the *field* and misleading about the *task*: a country restriction
is rebuildable in v2, as a catalog lookup that resolves countries to data
centers, those IDs passed as `dataCenterIds` on create, and a check that the
resource landed where it was supposed to.

Presenting it as a removal is not a neutral omission. SKILL.md routes Class 3
to stop-and-ask, so a dead-end description hands the user a choice between
accepting a behavior change and keeping the call on v1 — when the actual answer
is a dozen lines of code. Keeping a v1 call they did not need to keep is a worse
outcome than the one the bucket exists to prevent.

- breaking-changes.md gains a worked recipe next to the GPU-fallback one.
- The recipe includes the post-create placement assert, because v2 documents
  `dataCenterIds` as *preferred* and the spec does not say whether the scheduler
  may override it. Unresolved, and called out as unresolved: if the requirement
  is data residency, that distinction is the whole point, so the code verifies
  `pod["dataCenterId"]` rather than trusting the request.
- SKILL.md reframes the stop-and-ask. The question is no longer "what should I
  do here" but "was this a preference or a compliance requirement," which is the
  part only the user can answer. Where a rebuild exists, show it while asking.
- The scanner message carries the recipe, not just the diagnosis, and
  `countryCodes` moves out of the lumped "no equivalent" rule that hid it.
- Fixture and assertion added: the corpus never contained `countryCodes`, so
  nothing would have caught this rule regressing to a dead end. Verified the
  assertion fails when the advice is reverted.

* docs(runpod-migrate): dataCenterIds is enforced — verified against the live API

The previous commit hedged: v2 documents `dataCenterIds` as *preferred* data
centers, the spec does not say whether that is binding, and the recipe worked
around not knowing by asserting placement after create. Tested it instead.

Method: request an RTX 4090 — offered only in five EU data centers — while
naming `["US-KS-2","US-IL-1"]`, where it has no presence at all. That forces the
scheduler to choose between obeying the list and placing the pod, which a
happy-path test cannot do: a pod that lands where you asked proves nothing when
capacity was never scarce there.

Result: `400`, no pod. The control — the same body naming `EU-RO-1` — created
successfully and was terminated immediately. So the list is a hard restriction,
the scheduler will not place outside it to satisfy capacity, and it is a sound
basis for a data residency requirement. "Preferred" is misleading wording.

The post-create assert is dropped; it was insurance against an answer we now
have. Two things replace it:

- The 400 is documented, because the refusal presents as a capacity error and
  never mentions data centers. Over-narrow the list and the message sends people
  hunting for stock when the fix is widening the country list. That is the
  second-likeliest failure in this migration after a malformed body.
- The stop-and-ask is sharpened. Since the restriction holds either way, the
  preference-vs-compliance answer no longer changes the create — it changes the
  *fallback*. A preference widens the list when capacity runs out; a compliance
  requirement fails closed, which is the opposite of the GPU-fallback reflex the
  same document teaches two sections earlier.

Also points compliance cases at `GET /v2/catalog/datacenters`, whose per-DC
`compliance` array (GDPR, ISO_IEC_27001, SOC_2_TYPE_2, HIPAA) is a sounder basis
for an allowed list than a country code. v1 gave users no way to say
"certified", so some of them said "in this country" and meant this.

* fix(runpod-migrate): address review findings at 089816f

Four of five findings from @runpod-Henrik's review, each reproduced before
fixing. The fifth is answered in the PR thread rather than changed.

1. `--fail-on-legacy` failed on correct v2 code. The `networkvolumes` and
   `containerregistryauth` rules matched the bare token, unlike every other
   path rule, so Go packages, Python modules and import paths all read as v1
   call sites — 61 of 61 hits on one real repo. Both now require a leading `/`
   and defer to V2_CONTEXT. Import and module-path lines are suppressed
   separately, because `github.com/acme/internal/networkvolumes` carries the
   slash that otherwise marks a real path.

2. We taught a call that 400s, in 17 places. `include=AVAILABILITY` requires
   `product` and is invalid without it — 400 either way, no default, because
   the same GPU can be scarce for POD and plentiful for SERVERLESS. Every call
   site now names a product chosen for its context. This was the same failure
   this PR set out to fix, one layer down: a claim the spec contradicts, in the
   half of the URL nothing checked.

3. `.claude` was missing from SKIP_DIRS. Agent worktrees are full repo copies,
   so findings were multiplied by however many exist — 10,763 reported against
   1,821 real on runpod-flash. This ships as a Claude Code plugin, so its users
   are exactly the people with that directory. Added, with `.worktrees`,
   `.cache`, `.ruff_cache`, `.pnpm-store` and `bower_components`.

4. Nothing tested traversal, which is why 3 existed. Emptying SKIP_DIRS and
   removing the filter outright both survived a mutation audit, because no
   corpus contained a single skipped directory. New `skipped-dirs` corpus: one
   real v2 file beside seven directories that each hold obvious v1 code,
   asserting zero findings and a clean --fail-on-legacy. Both mutations now
   die; the audit is 8/8.

5. The availability eval asserted the 400 form, so it would have re-certified
   2 after the fix. Now asserts `product=` separately from the include.

Root cause of 2, fixed rather than papered over: check_migrate_tables.py only
verified paths. It now also verifies query parameters — that each one exists on
the path it is used with, and that required-together pairs appear together. The
pairs are derived from the spec's own prose ("Required with include=AVAILABILITY")
rather than hardcoded, so a new co-requirement is enforced as soon as v2
documents one. Verified by reverting a fixed line and watching the check fail.
2026-08-19 12:31:18 -04:00
..