diff --git a/.github/workflows/backfill-mcp-agent-sessions.yml b/.github/workflows/backfill-mcp-agent-sessions.yml index 41dfbb89c9..d01110a390 100644 --- a/.github/workflows/backfill-mcp-agent-sessions.yml +++ b/.github/workflows/backfill-mcp-agent-sessions.yml @@ -4,7 +4,9 @@ on: workflow_dispatch: inputs: mode: - description: Dry-run audits only; execute writes index rows and the completion marker. + description: + Dry-run audits only; execute writes index rows and the completion + marker. type: choice options: - dry-run @@ -15,7 +17,9 @@ on: type: string default: https://heykody.dev worker_script: - description: Cloudflare Workers script name that owns the MCP Durable Object namespace + description: + Cloudflare Workers script name that owns the MCP Durable Object + namespace type: string default: kody-production @@ -23,7 +27,8 @@ permissions: contents: read concurrency: - group: backfill-mcp-agent-sessions-${{ inputs.origin }}-${{ inputs.worker_script }} + group: + backfill-mcp-agent-sessions-${{ inputs.origin }}-${{ inputs.worker_script }} cancel-in-progress: false jobs: diff --git a/docs/contributing/adding-capabilities.md b/docs/contributing/adding-capabilities.md index 797c77dbf7..bcc7077bf8 100644 --- a/docs/contributing/adding-capabilities.md +++ b/docs/contributing/adding-capabilities.md @@ -240,6 +240,10 @@ Current admin capabilities: - `admin_platform_feedback_get` - `admin_platform_feedback_update` - `admin_community_activity_list` +- `admin_package_codemod_scan` +- `admin_package_codemod_dry_run` +- `admin_package_codemod_apply` +- `admin_package_codemod_revert` When adding more admin actions, expose service-layer functions by adding new `admin/*` capability files that call those service functions directly, set diff --git a/docs/contributing/architecture/primitives.yaml b/docs/contributing/architecture/primitives.yaml index bcd14e457c..21d6e45f63 100644 --- a/docs/contributing/architecture/primitives.yaml +++ b/docs/contributing/architecture/primitives.yaml @@ -182,6 +182,17 @@ primitives: docs: - docs/contributing/architecture/data-storage.md + - id: package-codemods + group: assistant + name: Package codemods + summary: + Versioned pure transforms migrating published user package source, with + dry-run gates, a run ledger, and revert snapshots. + code: + - packages/worker/src/package-codemods/ + docs: + - docs/contributing/package-codemods.md + - id: community-listings group: assistant name: Community package listings diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 858a8b20df..ed43ae0616 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -33,6 +33,7 @@ style, tests, MCP capabilities, and runtime architecture. ## Packages and MCP - [Packages and manifests](./packages-and-manifests.md) +- [Package codemods](./package-codemods.md) - [Community packages](./community-packages.md) - [External package invocation API](./package-invocation-api.md) - [Adding capabilities](./adding-capabilities.md) diff --git a/docs/contributing/package-codemods.md b/docs/contributing/package-codemods.md new file mode 100644 index 0000000000..6c3425c9d7 --- /dev/null +++ b/docs/contributing/package-codemods.md @@ -0,0 +1,299 @@ +# Package codemods + +Kody users own **saved packages**: source in Artifacts git repos, published +through repo checks into KV bundle artifacts. A **package codemod** is a +versioned, pure, deterministic, idempotent transform over a package's +**published file tree**. Codemods migrate user package source when the +platform's package API changes — the user-package analogue of D1 schema +migrations, applied fleet-wide with audit and revert support. + +User-authored package contracts live in +[`packages-and-manifests.md`](./packages-and-manifests.md). Repo-backed source +and publish paths are covered in +[`architecture/data-storage.md`](./architecture/data-storage.md). + +## What codemods are and are not + +**Codemods are:** + +- Transforms over the **published** source snapshot (the same tree + `runRepoChecks` validates), not live Artifacts working copies. +- **Versioned in-repo** platform code, registered once, run many times across + users and packages. +- **Pure** — `detect` and `transform` receive an in-memory file map and return + findings or a new file map; no I/O, no ambient request context. +- **Deterministic and idempotent** — the same input tree always yields the same + output; running `transform` twice on the result must not change files again. +- **Conservative** — when a pattern match is ambiguous, emit a `needsManual` + finding instead of guessing. + +**Codemods are not:** + +- **D1 migrations.** Platform schema changes use SQL migrations under + `packages/worker/migrations/`. Codemods change user-owned package source in + Artifacts/KV, scoped per saved package and per user. +- **Repo-session edits.** Codemods do not patch arbitrary git working trees; + they operate on the published snapshot the checks pipeline already built. +- **Community listing publishes.** A successful apply republishes the owning + user's saved package only. Pinned community listings keep serving the pinned + commit; listing snapshots are not advanced by codemod apply or revert. + +## Codemod contract + +Each codemod lives at +`packages/worker/src/package-codemods/codemods/NNNN-kebab-name.ts` (for example +`0001-ambient-storage-to-package-storage.ts`) and is registered in +`packages/worker/src/package-codemods/registry.ts`. + +Types in `packages/worker/src/package-codemods/types.ts`: + +```ts +type PackageCodemodFinding = { + path: string | null + message: string +} + +type PackageCodemodTransformResult = { + files: Record + changed: boolean + changedPaths: Array + needsManual: Array +} + +type PackageCodemod = { + id: string + description: string + detect(files: Record): Array + transform(files: Record): PackageCodemodTransformResult +} +``` + +- **`detect(files)`** — read-only scan. Returns `{ path, message }` findings + without mutating the tree. Used for fleet discovery and reporting. +- **`transform(files)`** — returns a new tree plus metadata. When a hunk cannot + be migrated confidently, leave the file unchanged and append a `needsManual` + finding rather than applying a risky rewrite. **Partial transforms are + intentional:** when `changed: true` and `needsManual` are both set, dry-run + and apply still proceed with the partial tree (per-file conservatism with + per-package progress). Findings are recorded on the ledger item and returned + to the operator; the no-new-failures check gate still runs on that partial + tree. A codemod that must be all-or-nothing should return the original tree + with `changed: false` and only findings. + +Implementations must stay **pure**: no `fetch`, D1, KV, secrets, or reads of the +calling user. The engine supplies the published file map; the codemod returns a +transformed map. + +## Authoring guide + +1. **Add the module** under + `packages/worker/src/package-codemods/codemods/NNNN-kebab-name.ts`. Use the + next sequential id; ids are stable compatibility contracts. +2. **Register** the export in `packages/worker/src/package-codemods/registry.ts` + so the engine and operator surfaces can resolve it by id. +3. **Prefer mechanical rewrites** with clear before/after fixtures. Cover edge + cases (already migrated imports, commented code, string literals that look + like patterns but are not) with **fixture tests** beside the codemod + (`*.node.test.ts` or `*.workers.test.ts`), using small in-memory file trees + rather than full publish integration unless the behavior requires it. +4. **Emit `needsManual`** when: + - multiple interpretations exist, + - the pattern spans generated or minified output, + - a required symbol cannot be resolved from static analysis alone, or + - the codemod would delete user logic to satisfy the migration. + +### `0001-ambient-storage-to-package-storage` + +The first shipped codemod migrates deprecated ambient `storage` imports from +`kody:runtime` to `packageStorage()` **at call sites**: + +- Rewrites member uses (`storage.get(...)` → `packageStorage().get(...)`) via + AST range replacement; it does **not** insert a module-scope + `const storage = packageStorage()` binding. +- Adjusts the `kody:runtime` import: rename `storage` → `packageStorage`, or + drop the `storage` specifier when `packageStorage` is already imported. +- Emits `needsManual` for aliased imports, non-member uses (value-passing), + re-exports, multiple runtime imports, binding sites, and post-rewrite + verification failures. +- Emits `needsManual` for **parse failures** on scannable module files that + mention `kody:runtime` and `storage`. +- **Manifest gate:** when `package.json#kody` declares any non-empty `app`, + `services`, `jobs`, `subscriptions`, `webhooks`, or `retrievers` surface, + every ambient-storage candidate file gets `needsManual` — ambient `storage` + and `packageStorage()` use different bucket identities on those execution + surfaces, so automatic rewrite risks silent data repointing. + +## Engine + +The engine entry point is `runPackageCodemodStep` in +`packages/worker/src/package-codemods/engine.ts`. Long runs are **paged**: each +call processes up to `limit` packages (or revert items) and returns `nextCursor` +plus a per-step `summary` count by item status. Repeat with the same `runId` and +`nextCursor` until `nextCursor` is null. + +### Step limits + +| Mode | Default `limit` | Max `limit` | +| ---------------------------- | --------------- | ----------- | +| `scan` | 20 | 50 | +| `dry-run`, `apply`, `revert` | 5 | 10 | + +Fleet scan mode may scan up to five D1 pages of 50 saved packages per step while +applying filters, and can return a progress `nextCursor` even when the current +step matched zero packages. + +### Modes + +| Mode | Behavior | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scan` | Run `detect` only; record findings per package. | +| `dry-run` | Run `transform` in memory, then run the full publish check suite (`runRepoChecks`) on **both** the original and transformed trees. Pass only when transformed checks introduce **no new failures** compared to the original. Also verifies mechanical idempotency by transforming twice and requiring an unchanged second result. | +| `apply` | Same gates as dry-run. On success: snapshot the original published tree to KV for revert, republish via `syncArtifactSourceSnapshot` with commit message `codemod(): ...`, refresh the saved-package projection, and dispatch subscription events (see below). Re-checks drift immediately before publish. | +| `revert` | For each `applied` item on a prior apply run: load the KV revert snapshot, verify published HEAD still matches that item's `afterCommit`, republish the snapshot tree, mark the source apply item `reverted`, and dispatch `package.codemod.reverted`. | + +Per-package failures are **isolated**; one package error does not abort sibling +items in the same run step. + +### Safety rails + +- **`skipped_unpublished`** — packages with no published commit are skipped. +- **`skipped_drift`** — when Artifacts default-branch HEAD does not match + `entity_sources.published_commit`, the engine skips and never overwrites. + Apply re-checks drift after transform gates pass and before KV snapshot / + publish. Revert compares HEAD to the prior apply item's **`afterCommit`** + (post-codemod published commit); drift skips revert for that item. +- **Apply snapshots** — before publish, apply writes the pre-codemod published + tree to `BUNDLE_ARTIFACTS_KV` at `package-codemod-revert:{userId}:{itemId}` + with a **90-day TTL** and stores that key on the ledger item as + `revert_snapshot_key`. +- **Check gate** — apply and dry-run both require the transformed tree to pass + `runRepoChecks` without regressions versus the original tree. + +### Item statuses + +Each per-package row in a run records one of: + +`detected`, `clean`, `dry_run_ok`, `dry_run_new_failures`, `needs_manual`, +`skipped_drift`, `skipped_unpublished`, `applied`, `reverted`, `failed`. + +## Ledger + +Every run and per-package item is stored in D1 (migration +`0111-package-codemod-ledger.sql`). Pagination cursors live on step responses, +not in the ledger tables. + +**`package_codemod_runs`:** `id`, `codemod_id`, `mode`, `scope_user_id` (`NULL` +for fleet runs), `initiated_by_user_id`, `filters_json`, `status` (`running` | +`completed` | `failed`), `revert_of_run_id`, `created_at`, `updated_at`. + +**`package_codemod_run_items`:** `id`, `run_id`, `user_id`, `package_id`, +`kody_id`, `status`, `before_commit`, `after_commit`, `changed_paths_json`, +`findings_json`, `check_summary_json`, `error`, `revert_snapshot_key`, +`created_at`, `updated_at`. + +Ledger writes **bound** large text columns (`error`, `check_summary_json`, +`findings_json`, `changed_paths_json`) to restorable UTF-8 byte limits; findings +cap at 50 entries and changed paths at 200, with truncation notices when +overflowing. + +The ledger makes runs **resumable** (page forward with `nextCursor`), +**auditable**, and **revertible** (revert reads KV snapshots keyed by +`revert_snapshot_key`). Revert is only possible while the KV snapshot remains +(90-day TTL). All rows are scoped by the owning user's saved package identity; +cross-user reads are a bug. + +## Operator surfaces + +### Admin UI + +`/admin/codemods` supports fleet **scan**, **dry-run**, **apply**, and +**revert** for a selected codemod. Filters include `userIds`, `packageIds`, and +`limit` so operators can canary a subset before a full fleet apply. + +### MCP — caller's own packages (`packages` domain) + +Authenticated users can migrate **their own** saved packages: + +- `package_codemod_list` +- `package_codemod_scan` +- `package_codemod_dry_run` +- `package_codemod_apply` +- `package_codemod_revert` + +These capabilities scope to the calling user's `userId` and saved package rows. +`package_codemod_revert` accepts a prior **apply** run id and reverts that +user's applied items — including items from a **fleet** apply run, as long as +the run is not scoped to another user (`scope_user_id` is `NULL` or matches the +caller). + +### MCP — fleet (`admin` domain) + +Admin-gated equivalents for operator fleet runs: + +- `admin_package_codemod_scan` +- `admin_package_codemod_dry_run` +- `admin_package_codemod_apply` +- `admin_package_codemod_revert` + +Admin capabilities require `requiredRole: 'admin'` and follow the RBAC boundary +in [Authorization](./architecture/authorization.md). + +## Rollout doctrine + +Platform package API changes that break existing user source follow this +sequence (formalizing existing practice): + +1. **Land the platform change** with deprecation shims and warnings so old + patterns still publish. +2. **Fleet scan** — run codemod `detect` across packages; review findings and + `needs_manual` volume. +3. **Fleet dry-run** — review diffs and dry-run reports; fix codemod gaps before + apply. +4. **Canary apply** — use admin filters (`userIds` / `packageIds`) for a small + cohort; monitor checks, projections, and subscriber notifiers. +5. **Fleet apply** — page through the full population. +6. **Land enforcement** — add or tighten publish-time lint/checks so **new** + publishes cannot use the deprecated pattern. + +Skipping dry-run or canary apply risks mass check failures; skipping step 6 +allows new packages to reintroduce debt. + +## Revert + +Apply persists the pre-codemod published tree to KV (`revert_snapshot_key`, +90-day TTL) before republishing the transformed tree. **Revert** mode creates a +new run with `revert_of_run_id` pointing at the prior apply run, pages through +source items with status `applied`, loads each KV snapshot, and republishes via +`syncArtifactSourceSnapshot` with commit message `revert codemod()`. On +success it marks the **source apply item** `reverted`, refreshes projections, +and dispatches `package.codemod.reverted`. + +Revert requires published HEAD to still equal the source item's `afterCommit`. +Missing or expired KV snapshots fail the revert item. Revert does not restore +Artifacts working-copy edits made after apply. + +Runs are single-pass in every mode: items that fail or are drift-skipped are +recorded on the run but not retried within it. Because failed or skipped source +items keep their `applied` status, retrying is starting a **new** revert run +against the same apply run — it picks up exactly the items that were not +reverted. + +Steps do not take a per-package lock. Publishes serialize in the repo session +Durable Object and transforms are deterministic, so overlapping steps of the +**same** codemod are benign, but do not fleet-apply two **different** codemods +concurrently — the second may transform stale published source. + +## Subscription events + +After each successful **apply** or **revert**, the host dispatches +`package.codemod.applied` or `package.codemod.reverted` to packages saved by the +**owning user** that declare the topic — the same delivery pattern as +`run.error.recorded`. Payload shape and handler guidance live in +[Package subscriptions](../guides/package-subscriptions.md). + +## Related + +- [Packages and manifests](./packages-and-manifests.md) +- [Adding capabilities](./adding-capabilities.md) — MCP capability registration +- [Package subscriptions](../guides/package-subscriptions.md) — event payloads +- [Data storage](./architecture/data-storage.md) — published source and KV diff --git a/docs/guides/package-subscriptions.md b/docs/guides/package-subscriptions.md index fdc547f08f..82826e11e8 100644 --- a/docs/guides/package-subscriptions.md +++ b/docs/guides/package-subscriptions.md @@ -258,6 +258,89 @@ never emit. Failed `execute` calls do persist and do emit. Use this topic for notifier packages that email, write to Sheets, spawn an agent, or otherwise react when something in the user's account fails. +## `package.codemod.applied` + +After a successful package codemod **apply**, Kody dispatches +`package.codemod.applied` to packages saved by the **owning user** of the +migrated package that declare the topic. Delivery follows the same best-effort +host dispatch path as `run.error.recorded` — there is no Queue / DLQ for this +topic. Failures during subscriber discovery or package-invocation infrastructure +are logged and do not fail the codemod apply. + +Handlers receive a metadata-first payload: + +```ts +type PackageCodemodSubscriptionEnvelope = { + event: 'package.codemod.applied' + codemod: { + id: string + description: string + } + package: { + package_id: string + kody_id: string + } + run: { + run_id: string + item_id: string + } + changed_paths: Array + before_commit: string | null + after_commit: string | null +} +``` + +`changed_paths` lists published-tree paths the codemod transform modified. +`before_commit` and `after_commit` are the package's published commit before and +after apply. The event deliberately omits file contents — fetch the current +published source with repo or package capabilities when a handler needs diffs or +full files. Community listing snapshots are unchanged by apply; only the owning +saved package advances. `run.item_id` is the apply ledger item id. + +Use this topic for notifier packages that record migrations, ping owners, or +trigger follow-up automation when platform codemods rewrite user package source. + +## `package.codemod.reverted` + +After a successful package codemod **revert**, Kody dispatches +`package.codemod.reverted` to packages saved by the **owning user** of the +restored package that declare the topic. Delivery semantics match +`package.codemod.applied` and `run.error.recorded`. + +Handlers receive the same envelope shape with +`event: 'package.codemod.reverted'`: + +```ts +type PackageCodemodSubscriptionEnvelope = { + event: 'package.codemod.reverted' + codemod: { + id: string + description: string + } + package: { + package_id: string + kody_id: string + } + run: { + run_id: string + item_id: string + } + changed_paths: Array + before_commit: string | null + after_commit: string | null +} +``` + +For revert, `before_commit` is the post-codemod published commit (the source +apply item's `afterCommit`) and `after_commit` is the restored pre-codemod +commit. `changed_paths` is copied from the source apply item (paths the codemod +originally changed), not recomputed at revert time. `run.item_id` is the new +revert-run ledger item id. Revert snapshots expire from KV after 90 days, so +revert and this event are unavailable once the snapshot is gone. + +Use this topic when automation must react to an operator or user undoing a prior +codemod apply. + ## `community.activity.recorded` (admins) Successful community fork and rating writes enqueue a durable diff --git a/packages/worker/client/lazy-route.tsx b/packages/worker/client/lazy-route.tsx index 3231b50ab1..c6b020d624 100644 --- a/packages/worker/client/lazy-route.tsx +++ b/packages/worker/client/lazy-route.tsx @@ -223,6 +223,7 @@ registerPreloadPatterns( routePattern(routes.adminUserDetail), routePattern(routes.adminInvites), routePattern(routes.adminFeatureFlags), + routePattern(routes.adminCodemods), routePattern(routes.adminRoles), routePattern(routes.adminCommunityReports), routePattern(routes.adminInsights), diff --git a/packages/worker/client/routes/account-management-components.tsx b/packages/worker/client/routes/account-management-components.tsx index c30841e7db..18c0937f4b 100644 --- a/packages/worker/client/routes/account-management-components.tsx +++ b/packages/worker/client/routes/account-management-components.tsx @@ -107,6 +107,11 @@ const adminNavItems = [ label: 'Feature flags', paths: ['/admin/feature-flags'], }, + { + href: '/admin/codemods', + label: 'Codemods', + paths: ['/admin/codemods'], + }, { href: '/admin/roles', label: 'Roles', paths: ['/admin/roles'] }, { href: '/admin/community-reports', diff --git a/packages/worker/client/routes/admin-area.ts b/packages/worker/client/routes/admin-area.ts index 86e9b2341e..df5ae86c76 100644 --- a/packages/worker/client/routes/admin-area.ts +++ b/packages/worker/client/routes/admin-area.ts @@ -11,6 +11,10 @@ export { AdminFeatureFlagsRoute, adminFeatureFlagsRouteLoader, } from './admin-feature-flags.tsx' +export { + AdminCodemodsRoute, + adminCodemodsRouteLoader, +} from './admin-codemods.tsx' export { AdminPlatformFeedbackRoute, adminPlatformFeedbackRouteLoader, diff --git a/packages/worker/client/routes/admin-codemods.tsx b/packages/worker/client/routes/admin-codemods.tsx new file mode 100644 index 0000000000..c33ba719b8 --- /dev/null +++ b/packages/worker/client/routes/admin-codemods.tsx @@ -0,0 +1,1040 @@ +import { formatNullableTimestamp } from '#client/format-timestamp.ts' +import { type Handle, css } from 'remix/ui' +import { on } from '#client/event-mixin.ts' +import { readCurrentRouterHref } from '#client/client-router.tsx' +import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' +import { consumeStaleNavigationData } from '#client/navigation-data.ts' +import { readJson } from '#client/routes/account-approval-shared.ts' +import { colors, mq, spacing, typography } from '#client/styles/tokens.ts' +import { + descriptionCss, + fieldCss, + fieldLabelCss, + getDangerButtonCss, + getPrimaryButtonCss, + getSecondaryButtonCss, + inputCss, +} from '#client/styles/style-primitives.ts' +import { + AccountManagementMessage, + AccountManagementPanel, + AccountManagementShell, + AdminPageHeader, + accountManagementTableCellCss, + accountManagementTableCss, +} from './account-management-components.tsx' +import { + type AdminCodemodListItem, + type AdminCodemodRunItemListItem, + type AdminCodemodRunItemsLoaderData, + type AdminCodemodRunListItem, + type AdminCodemodsLoaderData, +} from '#app/loader-data.ts' +import { + routeLoaderRedirect, + type RouteLoaderResult, +} from '#client/route-loader.ts' + +type PageStatus = 'loading' | 'ready' | 'error' +type RunMode = 'scan' | 'dry-run' | 'apply' | 'revert' +type RunPhase = 'idle' | 'running' | 'complete' | 'error' + +type LiveRunItem = { + itemId: string + userId: string + packageId: string + kodyId: string + status: string + changedPaths: Array + findings: Array<{ path: string | null; message: string }> + error: string | null +} + +const adminCodemodsApiPath = '/admin/codemods.json' +const adminCodemodsRunApiPath = '/admin/codemods/run.json' +const maxRunSteps = 200 + +const runModes = [ + 'scan', + 'dry-run', + 'apply', + 'revert', +] as const satisfies ReadonlyArray + +function isAdminCodemodsPath(href: string) { + return new URL(href, 'http://localhost').pathname === '/admin/codemods' +} + +function parseCommaSeparatedIds(value: string): Array { + return value + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0) +} + +function mergeSummaryCounts( + left: Record, + right: Partial>, +) { + const next = { ...left } + for (const [status, count] of Object.entries(right)) { + if (typeof count !== 'number') continue + next[status] = (next[status] ?? 0) + count + } + return next +} + +function formatSummaryCounts(summary: Record) { + const entries = Object.entries(summary).sort(([left], [right]) => + left.localeCompare(right), + ) + if (entries.length === 0) return 'No items yet' + return entries.map(([status, count]) => `${status}: ${count}`).join(' · ') +} + +function formatFindings( + findings: Array<{ path: string | null; message: string }>, +) { + if (findings.length === 0) return '—' + return findings + .map((finding) => + finding.path ? `${finding.path}: ${finding.message}` : finding.message, + ) + .join('; ') +} + +export async function adminCodemodsRouteLoader( + _url: URL, + signal: AbortSignal, +): Promise { + const response = await fetch(adminCodemodsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (response.status === 401) { + return routeLoaderRedirect('/login') + } + if (response.status === 403) { + throw new Error('You do not have permission to view package codemods.') + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load package codemods.') + } + return { adminCodemods: payload } +} + +export function AdminCodemodsRoute(handle: Handle) { + let status: PageStatus = 'loading' + let codemods: Array = [] + let runs: Array = [] + let message: string | null = null + let messageTone: 'info' | 'error' = 'info' + let loadRequestId = 0 + let lastLoadedHref = '' + let loadingForHref: string | null = null + let lastFailedHref: string | null = null + + let selectedCodemodId = '' + let selectedMode: RunMode = 'scan' + let userIdsFilter = '' + let packageIdsFilter = '' + let revertOfRunId = '' + + let runPhase: RunPhase = 'idle' + let stopRequested = false + let liveRunId: string | null = null + let liveItems: Array = [] + let liveSummary: Record = {} + let pendingConfirmKey: string | null = null + + let selectedHistoryRunId: string | null = null + let historyItems: Array = [] + let historyRun: AdminCodemodRunListItem | null = null + let historyLoading = false + let historyNextAfterId: string | null = null + let historyRequestId = 0 + + function applyData(payload: AdminCodemodsLoaderData) { + codemods = payload.codemods + runs = payload.runs + if ( + !selectedCodemodId || + !codemods.some((codemod) => codemod.id === selectedCodemodId) + ) { + selectedCodemodId = codemods[0]?.id ?? '' + } + status = 'ready' + message = null + messageTone = 'info' + } + + async function loadCodemods() { + const href = readCurrentRouterHref(handle) + loadingForHref = href + const requestId = ++loadRequestId + try { + const response = await fetch(adminCodemodsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (requestId !== loadRequestId) return + if (response.status === 401) { + window.location.assign('/login') + return + } + if (response.status === 403) { + status = 'error' + message = 'You do not have permission to view package codemods.' + messageTone = 'error' + lastFailedHref = href + handle.update() + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load package codemods.') + } + applyData(payload) + lastLoadedHref = href + lastFailedHref = null + handle.update() + } catch (error) { + if (requestId !== loadRequestId) return + status = 'error' + message = + error instanceof Error + ? error.message + : 'Unable to load package codemods.' + messageTone = 'error' + lastFailedHref = href + handle.update() + } finally { + if (requestId === loadRequestId) loadingForHref = null + } + } + + async function refreshRuns() { + const response = await fetch(adminCodemodsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) return + runs = payload.runs + codemods = payload.codemods + handle.update() + } + + async function loadHistoryRun(runId: string, append = false) { + const requestId = ++historyRequestId + historyLoading = true + if (!append) { + selectedHistoryRunId = runId + historyItems = [] + historyRun = null + historyNextAfterId = null + } + handle.update() + try { + const url = new URL(adminCodemodsApiPath, window.location.origin) + url.searchParams.set('runId', runId) + if (append && historyNextAfterId) { + url.searchParams.set('afterId', historyNextAfterId) + } + const response = await fetch(url.pathname + url.search, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (requestId !== historyRequestId) return + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson< + AdminCodemodRunItemsLoaderData & { error?: string } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error ?? 'Unable to load run items.') + } + historyRun = payload.run + historyItems = append + ? [...historyItems, ...payload.items] + : payload.items + historyNextAfterId = payload.nextAfterId + handle.update() + } catch (error) { + if (requestId !== historyRequestId) return + message = + error instanceof Error ? error.message : 'Unable to load run items.' + messageTone = 'error' + handle.update() + } finally { + if (requestId === historyRequestId) { + historyLoading = false + handle.update() + } + } + } + + function buildRunBody(input: { + runId?: string + cursor?: string | null + revertOfRunId?: string + mode: RunMode + codemodId: string + }) { + const userIds = parseCommaSeparatedIds(userIdsFilter) + const packageIds = parseCommaSeparatedIds(packageIdsFilter) + const body: Record = { + codemodId: input.codemodId, + mode: input.mode, + scope: 'fleet', + } + if (userIds.length > 0 || packageIds.length > 0) { + body.filters = { + ...(userIds.length > 0 ? { userIds } : {}), + ...(packageIds.length > 0 ? { packageIds } : {}), + } + } + if (input.runId) body.runId = input.runId + if (input.cursor !== undefined) body.cursor = input.cursor + if (input.mode === 'revert') { + body.revertOfRunId = input.revertOfRunId + } + return body + } + + async function postRunStep(body: Record) { + const response = await fetch(adminCodemodsRunApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(body), + }) + if (response.status === 401) { + window.location.assign('/login') + return null + } + const payload = await readJson<{ + ok?: boolean + error?: string + runId?: string + codemodId?: string + mode?: string + items?: Array + nextCursor?: string | null + summary?: Partial> + }>(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error ?? 'Unable to run package codemod step.') + } + return payload + } + + async function runPagedCodemod(input: { + mode: RunMode + codemodId: string + revertOfRunId?: string + }) { + runPhase = 'running' + stopRequested = false + liveRunId = null + liveItems = [] + liveSummary = {} + message = null + messageTone = 'info' + pendingConfirmKey = null + handle.update() + + let runId: string | undefined + let cursor: string | null | undefined + let stepCount = 0 + try { + for (;;) { + if (stopRequested) { + runPhase = 'complete' + message = `Stopped ${input.mode} for ${input.codemodId}${liveRunId ? ` (run ${liveRunId})` : ''} after ${stepCount} step(s).` + messageTone = 'info' + await refreshRuns() + handle.update() + return + } + if (stepCount >= maxRunSteps) { + runPhase = 'error' + message = `Stopped after ${maxRunSteps} steps without completing. Resume later with the same run id if needed.` + messageTone = 'error' + await refreshRuns() + handle.update() + return + } + + const previousCursor = cursor + const step = await postRunStep( + buildRunBody({ + runId, + cursor, + mode: input.mode, + codemodId: input.codemodId, + revertOfRunId: input.revertOfRunId, + }), + ) + if (!step) return + stepCount += 1 + runId = step.runId + liveRunId = step.runId ?? null + if (Array.isArray(step.items)) { + liveItems = [ + ...liveItems, + ...step.items.map((item) => ({ + itemId: item.itemId, + userId: item.userId, + packageId: item.packageId, + kodyId: item.kodyId, + status: item.status, + changedPaths: item.changedPaths ?? [], + findings: item.findings ?? [], + error: item.error ?? null, + })), + ] + } + if (step.summary) { + liveSummary = mergeSummaryCounts(liveSummary, step.summary) + } + handle.update() + if (step.nextCursor == null) break + if ( + previousCursor !== undefined && + previousCursor === step.nextCursor + ) { + runPhase = 'error' + message = `Codemod run stopped: cursor did not advance (${step.nextCursor}).` + messageTone = 'error' + await refreshRuns() + handle.update() + return + } + cursor = step.nextCursor + } + runPhase = 'complete' + message = `Finished ${input.mode} for ${input.codemodId}${liveRunId ? ` (run ${liveRunId})` : ''}.` + messageTone = 'info' + await refreshRuns() + handle.update() + } catch (error) { + runPhase = 'error' + message = + error instanceof Error + ? error.message + : 'Unable to complete package codemod run.' + messageTone = 'error' + handle.update() + } finally { + stopRequested = false + } + } + + function getConfirmKey(action: string, id: string) { + return `${action}:${id}` + } + + function getDestructiveButtonMix( + action: string, + id: string, + onConfirm: () => void, + ) { + const key = getConfirmKey(action, id) + return [ + on('blur', () => { + if (pendingConfirmKey === key) { + pendingConfirmKey = null + handle.update() + } + }), + on('click', (event) => { + if (pendingConfirmKey !== key) { + event.preventDefault() + pendingConfirmKey = key + handle.update() + return + } + pendingConfirmKey = null + onConfirm() + }), + ] + } + + function handleRunSubmit(event: SubmitEvent) { + event.preventDefault() + if (!(event.currentTarget instanceof HTMLFormElement)) return + if (!selectedCodemodId) { + message = 'Select a codemod first.' + messageTone = 'error' + handle.update() + return + } + if (selectedMode === 'apply' || selectedMode === 'revert') { + message = `Use the ${selectedMode} button and confirm to start a destructive run.` + messageTone = 'info' + handle.update() + return + } + void runPagedCodemod({ + mode: selectedMode, + codemodId: selectedCodemodId, + }) + } + + const primaryButtonCss = getPrimaryButtonCss() + const secondaryButtonCss = getSecondaryButtonCss() + const dangerButtonCss = getDangerButtonCss() + const tableCss = accountManagementTableCss + const cellCss = accountManagementTableCellCss + + return () => { + const currentHref = readCurrentRouterHref(handle) + const routeData = isAdminCodemodsPath(currentHref) + ? tryConsumeRouteLoaderData(handle, 'adminCodemods', currentHref) + : undefined + if (routeData) { + applyData(routeData) + lastLoadedHref = currentHref + lastFailedHref = null + } + const needsStaleRefresh = + consumeStaleNavigationData(currentHref) && !routeData + const needsLoad = + (status === 'loading' || + currentHref !== lastLoadedHref || + needsStaleRefresh) && + currentHref !== lastFailedHref && + loadingForHref !== currentHref + if (!routeData && needsLoad && typeof document !== 'undefined') { + status = 'loading' + loadingForHref = currentHref + handle.queueTask(loadCodemods) + } + + const isRunning = runPhase === 'running' + const canMutate = !isRunning && status === 'ready' + const applyConfirmActive = + pendingConfirmKey === getConfirmKey('apply', selectedCodemodId) + const revertFormConfirmActive = + pendingConfirmKey === getConfirmKey('revert-form', selectedCodemodId) + + return ( + + + {status === 'loading' ? ( +

+ Loading package codemods… +

+ ) : null} + {message ? ( + + {message} + + ) : null} +
+ + {codemods.length === 0 ? ( +

+ No package codemods are registered. +

+ ) : ( +
    + {codemods.map((codemod) => ( +
  • + + {codemod.id} + +

    + {codemod.description} +

    +
  • + ))} +
+ )} +
+ + +
+
+ + + + + {selectedMode === 'revert' ? ( + + ) : null} +
+
+ {selectedMode === 'apply' ? ( + + ) : selectedMode === 'revert' ? ( + + ) : ( + + )} + {isRunning ? ( + + ) : null} + {liveRunId ? ( +

+ Run {liveRunId} · {formatSummaryCounts(liveSummary)} +

+ ) : null} +
+
+
+ + {liveItems.length > 0 || runPhase !== 'idle' ? ( + +
+ + + + + + + + + + + + + {liveItems.map((item) => ( + + + + + + + + + ))} + +
kodyIduserIdstatuschangedPathsfindingserror
{item.kodyId}{item.userId}{item.status} + {item.changedPaths.join(', ') || '—'} + + {formatFindings(item.findings)} + {item.error ?? '—'}
+
+ {liveItems.length === 0 ? ( +

+ {isRunning ? 'Waiting for the first page…' : 'No items.'} +

+ ) : null} +
+ ) : null} + + + {runs.length === 0 ? ( +

+ No runs recorded yet. +

+ ) : ( +
+
+ + + + + + + + + + + + + {runs.map((run) => { + const revertConfirmActive = + pendingConfirmKey === + getConfirmKey('revert-history', run.id) + const canRevert = + run.mode === 'apply' && run.status === 'completed' + return ( + + + + + + + + + ) + })} + +
CreatedCodemodModeStatusScopeActions
+ {formatNullableTimestamp(run.createdAt)} + + + {run.codemodId} + + {run.mode}{run.status} + {run.scopeUserId ?? 'fleet'} + +
+ + {canRevert ? ( + + ) : null} +
+
+
+ + {selectedHistoryRunId ? ( +
+

+ Details for {selectedHistoryRunId} + {historyRun + ? ` · ${historyRun.mode} · ${historyRun.status}` + : ''} +

+ {historyLoading && historyItems.length === 0 ? ( +

+ Loading items… +

+ ) : null} + {historyItems.length > 0 ? ( +
+ + + + + + + + + + + + + {historyItems.map((item) => ( + + + + + + + + + ))} + +
kodyIduserIdstatuschangedPathsfindingserror
{item.kodyId}{item.userId}{item.status} + {item.changedPaths.join(', ') || '—'} + + {formatFindings(item.findings)} + {item.error ?? '—'}
+
+ ) : !historyLoading ? ( +

+ No items for this run. +

+ ) : null} + {historyNextAfterId ? ( + + ) : null} +
+ ) : null} +
+ )} +
+
+
+ ) + } +} diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index 24abc4559c..6e2747dcbb 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -198,6 +198,10 @@ export const clientRouteLoaders: Record = { adminArea, (m) => m.adminFeatureFlagsRouteLoader, ), + [routePattern(routes.adminCodemods)]: lazyRouteLoader( + adminArea, + (m) => m.adminCodemodsRouteLoader, + ), [routePattern(routes.adminRoles)]: lazyRouteLoader( adminArea, (m) => m.adminRolesRouteLoader, @@ -389,6 +393,9 @@ export const clientRoutes = { [routePattern(routes.adminFeatureFlags)]: ( } /> ), + [routePattern(routes.adminCodemods)]: ( + } /> + ), [routePattern(routes.adminRoles)]: ( } /> ), diff --git a/packages/worker/migrations/0111-package-codemod-ledger.sql b/packages/worker/migrations/0111-package-codemod-ledger.sql new file mode 100644 index 0000000000..90bc7f8451 --- /dev/null +++ b/packages/worker/migrations/0111-package-codemod-ledger.sql @@ -0,0 +1,41 @@ +-- Ledger for package codemod runs (fleet or per-user) and per-package items. +-- Runs track scan / dry-run / apply / revert; items record status, commits, +-- findings, check summaries, and KV revert snapshot keys so operators can +-- page, audit, and revert. + +CREATE TABLE IF NOT EXISTS package_codemod_runs ( + id TEXT PRIMARY KEY NOT NULL, + codemod_id TEXT NOT NULL, + mode TEXT NOT NULL, + scope_user_id TEXT, + initiated_by_user_id TEXT NOT NULL, + filters_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL, + revert_of_run_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS package_codemod_run_items ( + id TEXT PRIMARY KEY NOT NULL, + run_id TEXT NOT NULL, + user_id TEXT NOT NULL, + package_id TEXT NOT NULL, + kody_id TEXT NOT NULL, + status TEXT NOT NULL, + before_commit TEXT, + after_commit TEXT, + changed_paths_json TEXT NOT NULL DEFAULT '[]', + findings_json TEXT NOT NULL DEFAULT '[]', + check_summary_json TEXT, + error TEXT, + revert_snapshot_key TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_package_codemod_run_items_run_id +ON package_codemod_run_items(run_id); + +CREATE INDEX IF NOT EXISTS idx_package_codemod_runs_codemod_created +ON package_codemod_runs(codemod_id, created_at); diff --git a/packages/worker/src/account/data-targets.node.test.ts b/packages/worker/src/account/data-targets.node.test.ts index 7fa29fd6cb..15e642cba3 100644 --- a/packages/worker/src/account/data-targets.node.test.ts +++ b/packages/worker/src/account/data-targets.node.test.ts @@ -41,6 +41,15 @@ test('shared user-scoped target match SQL is identical for deletion and export s setColumn: 'banned_by_user_id', value: 'deleted-user', }, + { + kind: 'replace_user_id_in_json_column', + table: 'package_codemod_runs', + column: 'filters_json', + value: 'deleted-user', + includeInExport: false, + surface: 'package_codemod_runs_filters_json', + reason: 'test', + }, { kind: 'bucket_parent', table: 'value_entries', @@ -110,22 +119,42 @@ test('shared user-scoped target match SQL is identical for deletion and export s params: ['deleted-user', 'user-aaa'], }) - expect(matchFor(samples[6]!).qualifiedWhereSql).toBe( + const replaceJsonMatch = matchFor(samples[6]!) + expect(replaceJsonMatch).toEqual({ + table: 'package_codemod_runs', + whereSql: 'filters_json LIKE ?', + qualifiedWhereSql: 'package_codemod_runs.filters_json LIKE ?', + params: ['%"user-aaa"%'], + mutation: { + kind: 'replace_json_string', + column: 'filters_json', + search: '"user-aaa"', + replacement: '"deleted-user"', + }, + }) + expect(buildUserScopedDeleteOrUpdateSql(replaceJsonMatch)).toEqual({ + sql: `UPDATE package_codemod_runs + SET filters_json = REPLACE(filters_json, ?, ?) + WHERE filters_json LIKE ?`, + params: ['"user-aaa"', '"deleted-user"', '%"user-aaa"%'], + }) + + expect(matchFor(samples[7]!).qualifiedWhereSql).toBe( `value_entries.bucket_id IN ( SELECT id FROM value_buckets WHERE user_id = ? )`, ) - expect(matchFor(samples[7]!).qualifiedWhereSql).toBe( + expect(matchFor(samples[8]!).qualifiedWhereSql).toBe( `email_attachments.message_id IN ( SELECT id FROM email_messages WHERE user_id = ? )`, ) - expect(matchFor(samples[8]!).qualifiedWhereSql).toBe( + expect(matchFor(samples[9]!).qualifiedWhereSql).toBe( `community_stars.listing_id IN ( SELECT id FROM community_listings WHERE owner_user_id = ? )`, ) - expect(matchFor(samples[9]!)).toEqual({ + expect(matchFor(samples[10]!)).toEqual({ table: 'mcp_memory_conversation_suppressions', whereSql: 'user_id = ?', qualifiedWhereSql: 'mcp_memory_conversation_suppressions.user_id = ?', diff --git a/packages/worker/src/account/data-targets.ts b/packages/worker/src/account/data-targets.ts index cba64195d2..fd5ba0e525 100644 --- a/packages/worker/src/account/data-targets.ts +++ b/packages/worker/src/account/data-targets.ts @@ -37,6 +37,20 @@ export type UserScopedDataTarget = setColumn: string value: string } + | { + /** + * Rewrite a JSON text column that may embed the deleted user's id + * (for example filters_json.userIds). Deletion-only: export still + * reaches the table through other match columns when appropriate. + */ + kind: 'replace_user_id_in_json_column' + table: string + column: string + value: string + includeInExport?: boolean + surface?: string + reason?: string + } | { kind: 'bucket_parent'; table: string; parentTable: string } | { kind: 'attachment_parent'; table: string } | { @@ -119,6 +133,36 @@ export const accountUserDataTargets: ReadonlyArray = [ { kind: 'user_id', table: 'user_activation_milestones' }, { kind: 'user_id', table: 'user_package_run_successes' }, { kind: 'user_id', table: 'agent_package_conversation_uses' }, + // Per-package codemod outcomes belong to the package owner. Delete before + // anonymizing run attribution so orphaned items do not outlive the user. + { kind: 'user_id', table: 'package_codemod_run_items' }, + // Codemod runs are operator ledger rows: scope_user_id / initiated_by_user_id + // are attribution only. Keep the run for audit and anonymize both columns + // (matching community_bans.banned_by_user_id / package_scope_grants). + { + kind: 'replace_user_column', + table: 'package_codemod_runs', + matchColumn: 'scope_user_id', + setColumn: 'scope_user_id', + value: 'deleted-user', + }, + { + kind: 'replace_user_column', + table: 'package_codemod_runs', + matchColumn: 'initiated_by_user_id', + setColumn: 'initiated_by_user_id', + value: 'deleted-user', + }, + { + kind: 'replace_user_id_in_json_column', + table: 'package_codemod_runs', + column: 'filters_json', + value: 'deleted-user', + includeInExport: false, + surface: 'package_codemod_runs_filters_json', + reason: + 'Fleet/canary filter payloads may list the deleted user in userIds; deletion rewrites that id to deleted-user. Export reaches package_codemod_runs through scope/initiator columns instead.', + }, { kind: 'mcp_memory_suppression' }, { kind: 'user_id', table: 'mcp_memories' }, { kind: 'user_id', table: 'mcp_user_server_instructions' }, @@ -363,6 +407,7 @@ export function getAccountD1UserColumnCoverage() { covered.add(`${target.table}.${target.matchColumn}`) break } + case 'replace_user_id_in_json_column': case 'bucket_parent': case 'attachment_parent': case 'community_listing_child': @@ -399,6 +444,12 @@ export type UserScopedTargetMatch = { | { kind: 'delete' } | { kind: 'null_columns'; columns: ReadonlyArray } | { kind: 'replace_column'; column: string; value: string } + | { + kind: 'replace_json_string' + column: string + search: string + replacement: string + } } export function resolveUserScopedTargetTable( @@ -417,6 +468,7 @@ export function resolveUserScopedTargetTable( case 'user_columns': case 'null_user_column': case 'replace_user_column': + case 'replace_user_id_in_json_column': case 'bucket_parent': case 'community_listing_child': { return target.table @@ -498,6 +550,22 @@ export function buildUserScopedTargetMatch(input: { }, } } + case 'replace_user_id_in_json_column': { + const quotedUserId = `"${input.mcpUserId}"` + const quotedReplacement = `"${target.value}"` + return { + table, + whereSql: `${target.column} LIKE ?`, + qualifiedWhereSql: `${table}.${target.column} LIKE ?`, + params: [`%${quotedUserId}%`], + mutation: { + kind: 'replace_json_string', + column: target.column, + search: quotedUserId, + replacement: quotedReplacement, + }, + } + } case 'bucket_parent': { const whereSql = `bucket_id IN ( SELECT id FROM ${target.parentTable} WHERE user_id = ? @@ -578,6 +646,18 @@ export function buildUserScopedDeleteOrUpdateSql( params: [match.mutation.value, ...match.params], } } + case 'replace_json_string': { + return { + sql: `UPDATE ${match.table} + SET ${match.mutation.column} = REPLACE(${match.mutation.column}, ?, ?) + WHERE ${match.whereSql}`, + params: [ + match.mutation.search, + match.mutation.replacement, + ...match.params, + ], + } + } default: { const exhaustive: never = match.mutation throw new Error( @@ -615,6 +695,7 @@ export const accountExportForeignUserIdColumnsByTable: Readonly< community_activity_events: ['actor_user_id'], community_reports: ['listing_owner_user_id', 'resolved_by_user_id'], account_write_lease_repairs: ['target_user_id', 'repaired_by_user_id'], + package_codemod_runs: ['scope_user_id', 'initiated_by_user_id'], package_scope_grants: [ 'scope_owner_user_id', 'grantee_user_id', diff --git a/packages/worker/src/app/account-deletion.node.test.ts b/packages/worker/src/app/account-deletion.node.test.ts index 42f2f8eba4..84a47030c3 100644 --- a/packages/worker/src/app/account-deletion.node.test.ts +++ b/packages/worker/src/app/account-deletion.node.test.ts @@ -349,6 +349,26 @@ function createTestDb( } return { meta: { changes: changed } } } + const replaceJsonMatch = lower.match( + /^update (\w+) set (\w+) = replace\(\2, \?, \?\) where \2 like \?$/, + ) + if (replaceJsonMatch) { + const table = replaceJsonMatch[1] as string + const column = replaceJsonMatch[2] as string + const search = String(params[0]) + const replacement = String(params[1]) + const likePattern = String(params[2]) + const needle = likePattern.replace(/^%/, '').replace(/%$/, '') + let changed = 0 + for (const row of rows[table] ?? []) { + const current = row[column] + if (typeof current !== 'string') continue + if (!current.includes(needle)) continue + row[column] = current.split(search).join(replacement) + changed += 1 + } + return { meta: { changes: changed } } + } const replaceColumnMatch = lower.match( /^update (\w+) set (\w+) = \? where (\w+) = \?$/, ) @@ -921,6 +941,53 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async { user_id: userAaa, banned_by_user_id: userBbb }, { user_id: userBbb, banned_by_user_id: userAaa }, ], + package_codemod_run_items: [ + { + id: 'codemod-item-1', + run_id: 'codemod-run-1', + user_id: userAaa, + package_id: 'pkg-1', + kody_id: 'demo', + status: 'applied', + }, + { + id: 'codemod-item-2', + run_id: 'codemod-run-2', + user_id: userBbb, + package_id: 'pkg-2', + kody_id: 'demo-b', + status: 'applied', + }, + ], + package_codemod_runs: [ + { + id: 'codemod-run-1', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scope_user_id: userAaa, + initiated_by_user_id: userAaa, + filters_json: JSON.stringify({ userIds: [userAaa, userBbb] }), + status: 'completed', + }, + { + id: 'codemod-run-fleet', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope_user_id: null, + initiated_by_user_id: userBbb, + filters_json: JSON.stringify({ userIds: [userAaa] }), + status: 'completed', + }, + { + id: 'codemod-run-2', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scope_user_id: userBbb, + initiated_by_user_id: userBbb, + filters_json: JSON.stringify({ userIds: [userBbb] }), + status: 'completed', + }, + ], }) const deletedKvKeys: Array = [] @@ -933,6 +1000,9 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async 'derived-cache:v1:community-icon:v1:listing-1:abc123', 'derived-cache:v1:community-icon:v1:listing-1:historical', 'source-snapshot:v1:src-2:def456', + `package-codemod-revert:${userAaa}:item-1`, + `package-codemod-revert:${userAaa}:item-2`, + `package-codemod-revert:${userBbb}:item-other`, 'package-retriever-manifest:v1:user-aaa:pkg-1:abc123', 'package-retriever-index-entry:v1:user-aaa:search:pkg-1:notes', 'package-retriever-index-entry:v1:user-aaa:context:pkg-1:notes', @@ -1246,6 +1316,48 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async expect(rows.community_bans).toEqual([ { user_id: userBbb, banned_by_user_id: 'deleted-user' }, ]) + expect(rows.package_codemod_run_items).toEqual([ + { + id: 'codemod-item-2', + run_id: 'codemod-run-2', + user_id: userBbb, + package_id: 'pkg-2', + kody_id: 'demo-b', + status: 'applied', + }, + ]) + expect(rows.package_codemod_runs).toEqual([ + { + id: 'codemod-run-1', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scope_user_id: 'deleted-user', + initiated_by_user_id: 'deleted-user', + filters_json: JSON.stringify({ userIds: ['deleted-user', userBbb] }), + status: 'completed', + }, + { + id: 'codemod-run-fleet', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope_user_id: null, + initiated_by_user_id: userBbb, + filters_json: JSON.stringify({ userIds: ['deleted-user'] }), + status: 'completed', + }, + { + id: 'codemod-run-2', + codemod_id: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scope_user_id: userBbb, + initiated_by_user_id: userBbb, + filters_json: JSON.stringify({ userIds: [userBbb] }), + status: 'completed', + }, + ]) + for (const run of rows.package_codemod_runs ?? []) { + expect(String(run['filters_json'])).not.toContain(userAaa) + } expect(rows.users).toEqual([{ id: 2, email: 'b@example.com' }]) expect(result.deletedRowCounts.password_resets).toBe(2) expect(result.deletedRowCounts.user_roles).toBe(1) @@ -1279,6 +1391,8 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async 'derived-cache:v1:community-icon:v1:listing-1:abc123', 'derived-cache:v1:community-icon:v1:listing-1:commit-1', 'derived-cache:v1:community-icon:v1:listing-1:historical', + `package-codemod-revert:${userAaa}:item-1`, + `package-codemod-revert:${userAaa}:item-2`, 'package-retriever-index-entry:v1:user-aaa:context:pkg-1:notes', 'package-retriever-index-entry:v1:user-aaa:search:pkg-1:notes', 'package-retriever-index:v1:user-aaa:context', @@ -1295,6 +1409,9 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async expect(deletedKvKeys).not.toContain( 'package-retriever-index:v1:user-bbb:search', ) + expect(deletedKvKeys).not.toContain( + `package-codemod-revert:${userBbb}:item-other`, + ) // Result accounting captures the per-table counts. expect(result.deletedRowCounts.jobs).toBe(3) @@ -1314,7 +1431,7 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async expect(result.updatedRowCounts.community_bans).toBe(1) expect(result.deletedRowCounts.platform_feedback).toBe(1) expect(result.updatedRowCounts.platform_feedback).toBe(1) - expect(result.deletedKvKeys).toBe(14) + expect(result.deletedKvKeys).toBe(16) expect(result.deletedCommunityAssets).toBe(5) expect(result.deletedEmailBlobs).toBe(2) // Prefix sweeps remove current and historical assets without crossing users. diff --git a/packages/worker/src/app/account-deletion.ts b/packages/worker/src/app/account-deletion.ts index eb56e55ff9..4e62326c5a 100644 --- a/packages/worker/src/app/account-deletion.ts +++ b/packages/worker/src/app/account-deletion.ts @@ -1039,6 +1039,11 @@ export async function deleteUserAccount(input: { (listing) => `${derivedCacheKeyPrefix}community-icon:v1:${listing.id}:`, ), + // Package-codemod apply snapshots are user-namespaced in KV + // (`package-codemod-revert:{userId}:{itemId}`). D1 run items are + // deleted separately; purge the orphaned revert trees here rather + // than waiting on the 90-day TTL. + `package-codemod-revert:${input.mcpUserId}:`, ], warnings, }) diff --git a/packages/worker/src/app/document-head.ts b/packages/worker/src/app/document-head.ts index aeb8773e58..d30aa610fe 100644 --- a/packages/worker/src/app/document-head.ts +++ b/packages/worker/src/app/document-head.ts @@ -153,6 +153,7 @@ const routeDocumentHeads = { [routePattern(routes.adminUserDetail)]: titleOnly('Admin users'), [routePattern(routes.adminInvites)]: titleOnly('Admin invites'), [routePattern(routes.adminFeatureFlags)]: titleOnly('Admin feature flags'), + [routePattern(routes.adminCodemods)]: titleOnly('Admin codemods'), [routePattern(routes.adminRoles)]: titleOnly('Admin roles'), [routePattern(routes.adminCommunityReports)]: titleOnly('Community reports'), [routePattern(routes.adminInsights)]: titleOnly('Admin insights'), diff --git a/packages/worker/src/app/handlers/admin-codemods.node.test.ts b/packages/worker/src/app/handlers/admin-codemods.node.test.ts new file mode 100644 index 0000000000..56b28971df --- /dev/null +++ b/packages/worker/src/app/handlers/admin-codemods.node.test.ts @@ -0,0 +1,483 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { + type PermissionString, + type RoleName, +} from '#worker/identity/permissions.ts' +import { type PackageCodemodRunStepResult } from '#worker/package-codemods/engine.ts' +import { type PackageCodemodRunRecord } from '#worker/package-codemods/ledger.ts' +import { logAuditEventSpy } from '#worker/test-support/audit-log-spy.ts' +import type * as AuditLog from '#worker/audit-log.ts' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(), + listPackageCodemods: vi.fn(), + getPackageCodemodById: vi.fn(), + listPackageCodemodRuns: vi.fn(), + listPackageCodemodRunItems: vi.fn(), + getPackageCodemodRunById: vi.fn(), + runPackageCodemodStep: vi.fn(), +})) + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +vi.mock('#worker/audit-log.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getRequestIp: () => '127.0.0.1', + logAuditEvent: (...args: Parameters) => + logAuditEventSpy(...args), + } +}) + +vi.mock('#worker/package-codemods/registry.ts', () => ({ + listPackageCodemods: (...args: Array) => + mockModule.listPackageCodemods(...args), + getPackageCodemodById: (...args: Array) => + mockModule.getPackageCodemodById(...args), +})) + +vi.mock('#worker/package-codemods/ledger.ts', () => ({ + listPackageCodemodRuns: (...args: Array) => + mockModule.listPackageCodemodRuns(...args), + listPackageCodemodRunItems: (...args: Array) => + mockModule.listPackageCodemodRunItems(...args), + getPackageCodemodRunById: (...args: Array) => + mockModule.getPackageCodemodRunById(...args), +})) + +vi.mock('#worker/package-codemods/engine.ts', () => ({ + runPackageCodemodStep: (...args: Array) => + mockModule.runPackageCodemodStep(...args), +})) + +function createAdminActor(roles: Array) { + const permissions: Array = roles.includes('admin') + ? ['read:user:any', 'update:user:any'] + : ['read:user:own'] + return { + sessionUserId: '1', + userId: 1, + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + roles, + permissions, + artifactOwnerIds: ['1'], + mcpUser: { + userId: 'stable-admin', + email: 'admin@example.com', + username: 'admin-user', + displayName: 'admin-user', + }, + } +} + +function createTestEnv() { + return { + APP_DB: { + prepare() { + throw new Error('APP_DB should not be queried directly in these tests') + }, + }, + } as unknown as Env +} + +const { createAdminCodemodsApiHandler, createAdminCodemodsRunApiHandler } = + await import('./admin-codemods.ts') + +beforeEach(() => { + vi.clearAllMocks() + logAuditEventSpy.mockClear() +}) + +function createGetRequest(search = '') { + const url = new URL(`https://example.com/admin/codemods.json${search}`) + return { + request: new Request(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }), + params: {}, + url, + } as never +} + +function createRunRequest(body: unknown) { + const url = new URL('https://example.com/admin/codemods/run.json') + return { + request: new Request(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }), + params: {}, + url, + } as never +} + +const sampleRun: PackageCodemodRunRecord = { + id: 'run-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scopeUserId: null, + initiatedByUserId: 'stable-admin', + filtersJson: '{}', + status: 'completed', + revertOfRunId: null, + createdAt: '2026-07-30T10:00:00.000Z', + updatedAt: '2026-07-30T10:01:00.000Z', +} + +function stubKnownCodemod() { + mockModule.getPackageCodemodById.mockReturnValue({ + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + detect: () => [], + transform: () => ({ + files: {}, + changed: false, + changedPaths: [], + needsManual: [], + }), + }) +} + +test('admin codemods GET requires admin and returns codemods plus recent runs', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsApiHandler(env) + mockModule.listPackageCodemods.mockReturnValue([ + { + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + }, + ]) + mockModule.listPackageCodemodRuns.mockResolvedValue([sampleRun]) + + mockModule.readAuthenticatedAppUser.mockResolvedValue(null) + const unauthorized = await handler.handler(createGetRequest()) + expect(unauthorized.status).toBe(401) + + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['user']), + ) + const forbidden = await handler.handler(createGetRequest()) + expect(forbidden.status).toBe(403) + + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const response = await handler.handler(createGetRequest()) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + codemods: [ + { + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + }, + ], + runs: [sampleRun], + }) + expect(mockModule.listPackageCodemodRuns).toHaveBeenCalledWith(env.APP_DB, { + limit: 50, + }) +}) + +test('admin codemods GET with runId returns paged run items', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + mockModule.getPackageCodemodRunById.mockResolvedValue(sampleRun) + const items = Array.from({ length: 2 }, (_, index) => ({ + id: `item-${index}`, + runId: 'run-1', + userId: 'user-a', + packageId: `pkg-${index}`, + kodyId: `app-${index}`, + status: 'detected', + beforeCommit: null, + afterCommit: null, + changedPaths: [], + findings: [{ path: 'index.ts', message: 'ambient storage' }], + checkSummaryJson: null, + error: null, + createdAt: '2026-07-30T10:00:00.000Z', + updatedAt: '2026-07-30T10:00:00.000Z', + })) + mockModule.listPackageCodemodRunItems.mockResolvedValue(items) + + const response = await handler.handler( + createGetRequest('?runId=run-1&limit=2&afterId=item-0'), + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + run: sampleRun, + items, + nextAfterId: 'item-1', + }) + expect(mockModule.listPackageCodemodRunItems).toHaveBeenCalledWith( + env.APP_DB, + { + runId: 'run-1', + afterId: 'item-0', + limit: 2, + }, + ) +}) + +test('admin codemods run POST requires admin, audits, and runs one step with fleet scope', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + const stepResult: PackageCodemodRunStepResult = { + runId: 'run-new', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + items: [ + { + itemId: 'item-1', + userId: 'user-a', + packageId: 'pkg-1', + kodyId: 'demo-app', + status: 'detected', + changedPaths: [], + findings: [{ path: 'app.ts', message: 'ambient storage' }], + beforeCommit: 'abc', + afterCommit: null, + checkSummary: null, + error: null, + }, + ], + nextCursor: null, + summary: { detected: 1 }, + } + stubKnownCodemod() + mockModule.runPackageCodemodStep.mockResolvedValue(stepResult) + + mockModule.readAuthenticatedAppUser.mockResolvedValue(null) + const unauthorized = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + }), + ) + expect(unauthorized.status).toBe(401) + + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['user']), + ) + const forbidden = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + }), + ) + expect(forbidden.status).toBe(403) + + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + const response = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + filters: { packageIds: ['pkg-1'] }, + }), + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + ...stepResult, + }) + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'stable-admin', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: { kind: 'fleet' }, + filters: { packageIds: ['pkg-1'] }, + }) + expect(logAuditEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'admin', + action: 'package_codemod_run_step', + result: 'success', + email: 'admin@example.com', + ip: '127.0.0.1', + path: '/admin/codemods/run.json', + reason: + 'codemod_id=0001-ambient-storage-to-package-storage;mode=scan;scope=fleet;run_id=run-new;next_cursor=null;item_count=1', + }), + ) +}) + +test('admin codemods run POST requires explicit scope for apply and revert', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + stubKnownCodemod() + + const missingApplyScope = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + }), + ) + expect(missingApplyScope.status).toBe(400) + await expect(missingApplyScope.json()).resolves.toMatchObject({ + ok: false, + error: 'scope is required for apply and revert modes.', + }) + + const missingRevertScope = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + revertOfRunId: 'run-1', + }), + ) + expect(missingRevertScope.status).toBe(400) + await expect(missingRevertScope.json()).resolves.toMatchObject({ + ok: false, + error: 'scope is required for apply and revert modes.', + }) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() + expect(logAuditEventSpy).not.toHaveBeenCalled() +}) + +test('admin codemods run POST rejects invalid mode and missing revertOfRunId', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + stubKnownCodemod() + + const invalidMode = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'explode', + scope: 'fleet', + }), + ) + expect(invalidMode.status).toBe(400) + await expect(invalidMode.json()).resolves.toMatchObject({ + ok: false, + error: 'mode must be one of scan, dry-run, apply, or revert.', + }) + + const missingRevert = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + scope: 'fleet', + }), + ) + expect(missingRevert.status).toBe(400) + await expect(missingRevert.json()).resolves.toMatchObject({ + ok: false, + error: 'revert mode requires revertOfRunId.', + }) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() +}) + +test('admin codemods run POST rejects revert with bare runId in place of revertOfRunId', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + stubKnownCodemod() + + const response = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + scope: 'fleet', + runId: 'run-1', + }), + ) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: 'revert mode requires revertOfRunId.', + }) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() +}) + +test('admin codemods run POST rejects supplied-but-empty filters and allows omitted filters', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + stubKnownCodemod() + mockModule.runPackageCodemodStep.mockResolvedValue({ + runId: 'run-scan', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + items: [], + nextCursor: null, + summary: {}, + }) + + const blankPackageIds = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + filters: { packageIds: [' '] }, + }), + ) + expect(blankPackageIds.status).toBe(400) + await expect(blankPackageIds.json()).resolves.toMatchObject({ + ok: false, + error: + 'filters.packageIds was supplied but contained no usable ids. Omit the key, or provide at least one non-empty id.', + }) + + const emptyUserIds = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + filters: { userIds: [] }, + }), + ) + expect(emptyUserIds.status).toBe(400) + await expect(emptyUserIds.json()).resolves.toMatchObject({ + ok: false, + error: + 'filters.userIds was supplied but contained no usable ids. Omit the key, or provide at least one non-empty id.', + }) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() + + const omittedFilters = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: 'fleet', + }), + ) + expect(omittedFilters.status).toBe(200) + const omittedCall = mockModule.runPackageCodemodStep.mock.calls.at(-1)?.[0] as + | { filters?: unknown } + | undefined + expect(omittedCall).toBeDefined() + expect(omittedCall).not.toHaveProperty('filters') +}) diff --git a/packages/worker/src/app/handlers/admin-codemods.ts b/packages/worker/src/app/handlers/admin-codemods.ts new file mode 100644 index 0000000000..a7b9357bf7 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-codemods.ts @@ -0,0 +1,419 @@ +import { getErrorMessage } from '@kody-internal/shared/error-message.ts' +import { type Action } from 'remix/router' +import { + type AdminCodemodRunItemsLoaderData, + type AdminCodemodsLoaderData, +} from '#app/loader-data.ts' +import { requirePageUserWithRole } from '#app/page-auth.ts' +import { requireUserWithRole } from '#app/permissions-server.ts' +import { + readNonEmptyTrimmedString, + readNonEmptyTrimmedStringOrNumber, +} from '#app/request-body.ts' +import { type routes } from '#app/routes.ts' +import { renderAppPage } from '#app/ssr-render.tsx' +import { getRequestIp, logAuditEvent } from '#worker/audit-log.ts' +import { jsonResponse } from '#worker/json-response.ts' +import { + runPackageCodemodStep, + type PackageCodemodRunMode, + type PackageCodemodRunScope, +} from '#worker/package-codemods/engine.ts' +import { + getPackageCodemodRunById, + listPackageCodemodRunItems, + listPackageCodemodRuns, +} from '#worker/package-codemods/ledger.ts' +import { + getPackageCodemodById, + listPackageCodemods, +} from '#worker/package-codemods/registry.ts' +import { readPositiveInt } from '#worker/query-params.ts' + +const recentRunsLimit = 50 +const defaultRunItemsLimit = 50 +const maxRunItemsLimit = 200 + +const packageCodemodRunModes = [ + 'scan', + 'dry-run', + 'apply', + 'revert', +] as const satisfies ReadonlyArray + +export async function loadAdminCodemodsData( + env: Env, +): Promise { + const [codemods, runs] = await Promise.all([ + Promise.resolve(listPackageCodemods()), + listPackageCodemodRuns(env.APP_DB, { limit: recentRunsLimit }), + ]) + return { + ok: true, + codemods, + runs, + } +} + +export function createAdminCodemodsHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + const admin = await requirePageUserWithRole(request, env, 'admin') + if (admin instanceof Response) { + return admin + } + + const adminCodemods = await loadAdminCodemodsData(env) + + return renderAppPage({ + request, + env, + title: 'Admin codemods', + loaderData: { adminCodemods }, + }) + }, + } satisfies Action +} + +export function createAdminCodemodsApiHandler(env: Env) { + return { + middleware: [], + async handler({ request, url }) { + try { + if (request.method !== 'GET') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + await requireUserWithRole(request, env, 'admin') + + const runId = url.searchParams.get('runId')?.trim() || null + if (runId) { + const limit = readPositiveInt( + url.searchParams.get('limit'), + defaultRunItemsLimit, + maxRunItemsLimit, + ) + const afterId = url.searchParams.get('afterId')?.trim() || null + const [run, items] = await Promise.all([ + getPackageCodemodRunById(env.APP_DB, runId), + listPackageCodemodRunItems(env.APP_DB, { + runId, + afterId, + limit, + }), + ]) + const nextAfterId = + items.length === limit + ? (items[items.length - 1]?.id ?? null) + : null + const payload: AdminCodemodRunItemsLoaderData = { + ok: true, + run, + items, + nextAfterId, + } + return jsonResponse(payload) + } + + const payload = await loadAdminCodemodsData(env) + return jsonResponse(payload) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, + } satisfies Action +} + +export function createAdminCodemodsRunApiHandler(env: Env) { + return { + middleware: [], + async handler({ request, url }) { + try { + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + const actor = await requireUserWithRole(request, env, 'admin') + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return jsonResponse( + { ok: false, error: 'Invalid request body.' }, + 400, + ) + } + + const parsed = parseRunBody(body) + if (!parsed.ok) { + return jsonResponse({ ok: false, error: parsed.error }, 400) + } + + try { + const result = await runPackageCodemodStep({ + env, + baseUrl: url.origin, + initiatedByUserId: actor.mcpUser.userId, + codemodId: parsed.codemodId, + mode: parsed.mode, + scope: parsed.scope, + ...(parsed.filters ? { filters: parsed.filters } : {}), + ...(parsed.runId ? { runId: parsed.runId } : {}), + ...(parsed.cursor !== undefined ? { cursor: parsed.cursor } : {}), + ...(parsed.limit !== undefined ? { limit: parsed.limit } : {}), + ...(parsed.revertOfRunId + ? { revertOfRunId: parsed.revertOfRunId } + : {}), + }) + + const requestIp = getRequestIp(request) ?? undefined + void logAuditEvent({ + category: 'admin', + action: 'package_codemod_run_step', + result: 'success', + email: actor.email, + ip: requestIp, + path: url.pathname, + reason: [ + `codemod_id=${parsed.codemodId}`, + `mode=${parsed.mode}`, + `scope=${formatScopeForAudit(parsed.scope)}`, + `run_id=${result.runId}`, + `next_cursor=${result.nextCursor ?? 'null'}`, + `item_count=${result.items.length}`, + ].join(';'), + }) + + return jsonResponse({ ok: true, ...result }) + } catch (error) { + return jsonResponse({ ok: false, error: getErrorMessage(error) }, 400) + } + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, + } satisfies Action +} + +type ParseRunBodyResult = + | { + ok: true + codemodId: string + mode: PackageCodemodRunMode + scope: PackageCodemodRunScope + filters?: { userIds?: Array; packageIds?: Array } + runId?: string + cursor?: string | null + limit?: number + revertOfRunId?: string + } + | { ok: false; error: string } + +function parseRunBody(body: object): ParseRunBodyResult { + const codemodId = readNonEmptyTrimmedString(body, 'codemodId') + if (!codemodId) { + return { ok: false, error: 'codemodId is required.' } + } + if (!getPackageCodemodById(codemodId)) { + return { ok: false, error: `Unknown package codemod "${codemodId}".` } + } + + const modeRaw = readNonEmptyTrimmedString(body, 'mode') + if (!modeRaw) { + return { ok: false, error: 'mode is required.' } + } + const mode = parsePackageCodemodRunMode(modeRaw) + if (!mode) { + return { + ok: false, + error: 'mode must be one of scan, dry-run, apply, or revert.', + } + } + + const scope = parseScope(body, mode) + if (!scope.ok) { + return scope + } + + const filters = parseFilters(body) + if (!filters.ok) { + return filters + } + + const runId = readNonEmptyTrimmedString(body, 'runId') ?? undefined + const revertOfRunId = + readNonEmptyTrimmedString(body, 'revertOfRunId') ?? undefined + + const record = body as Record + let cursor: string | null | undefined + if (Object.hasOwn(record, 'cursor')) { + const cursorValue = record.cursor + if (cursorValue === null) { + cursor = null + } else if (typeof cursorValue === 'string') { + cursor = cursorValue.trim() || null + } else { + return { ok: false, error: 'cursor must be a string or null.' } + } + } + + let limit: number | undefined + if ( + Object.hasOwn(record, 'limit') && + record.limit != null && + record.limit !== '' + ) { + const limitRaw = readNonEmptyTrimmedStringOrNumber(body, 'limit') + if (!limitRaw) { + return { ok: false, error: 'limit must be a positive integer.' } + } + const parsedLimit = Number(limitRaw) + if (!Number.isInteger(parsedLimit) || parsedLimit < 1) { + return { ok: false, error: 'limit must be a positive integer.' } + } + limit = parsedLimit + } + + if (mode === 'revert' && !revertOfRunId) { + return { ok: false, error: 'revert mode requires revertOfRunId.' } + } + + return { + ok: true, + codemodId, + mode, + scope: scope.scope, + ...(filters.filters ? { filters: filters.filters } : {}), + ...(runId ? { runId } : {}), + ...(cursor !== undefined ? { cursor } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(revertOfRunId ? { revertOfRunId } : {}), + } +} + +function parsePackageCodemodRunMode( + value: string, +): PackageCodemodRunMode | null { + for (const mode of packageCodemodRunModes) { + if (mode === value) return mode + } + return null +} + +function parseScope( + body: object, + mode: PackageCodemodRunMode, +): { ok: true; scope: PackageCodemodRunScope } | { ok: false; error: string } { + const record = body as Record + const scopeMissing = !Object.hasOwn(record, 'scope') || record.scope == null + if (scopeMissing) { + if (mode === 'apply' || mode === 'revert') { + return { + ok: false, + error: 'scope is required for apply and revert modes.', + } + } + return { ok: true, scope: { kind: 'fleet' } } + } + const scopeValue = record.scope + if (scopeValue === 'fleet') { + return { ok: true, scope: { kind: 'fleet' } } + } + if (typeof scopeValue === 'object' && scopeValue !== null) { + const userId = readNonEmptyTrimmedString(scopeValue, 'userId') + if (!userId) { + return { + ok: false, + error: 'scope.userId is required when scope is a user object.', + } + } + return { ok: true, scope: { kind: 'user', userId } } + } + return { + ok: false, + error: 'scope must be "fleet" or { userId }.', + } +} + +function formatScopeForAudit(scope: PackageCodemodRunScope): string { + switch (scope.kind) { + case 'fleet': + return 'fleet' + case 'user': + return `user:${scope.userId}` + default: { + const exhaustive: never = scope + return String(exhaustive) + } + } +} + +function parseFilters(body: object): + | { + ok: true + filters?: { userIds?: Array; packageIds?: Array } + } + | { ok: false; error: string } { + const record = body as Record + if (!Object.hasOwn(record, 'filters') || record.filters == null) { + return { ok: true } + } + const filtersValue = record.filters + if (typeof filtersValue !== 'object' || filtersValue === null) { + return { ok: false, error: 'filters must be an object.' } + } + const filtersRecord = filtersValue as Record + const userIds = parseOptionalStringArray(filtersRecord, 'userIds') + if (userIds === false) { + return { ok: false, error: 'filters.userIds must be an array of strings.' } + } + if (userIds === 'empty') { + return { + ok: false, + error: + 'filters.userIds was supplied but contained no usable ids. Omit the key, or provide at least one non-empty id.', + } + } + const packageIds = parseOptionalStringArray(filtersRecord, 'packageIds') + if (packageIds === false) { + return { + ok: false, + error: 'filters.packageIds must be an array of strings.', + } + } + if (packageIds === 'empty') { + return { + ok: false, + error: + 'filters.packageIds was supplied but contained no usable ids. Omit the key, or provide at least one non-empty id.', + } + } + if (!userIds && !packageIds) { + return { ok: true } + } + return { + ok: true, + filters: { + ...(userIds ? { userIds } : {}), + ...(packageIds ? { packageIds } : {}), + }, + } +} + +function parseOptionalStringArray( + record: Record, + key: string, +): Array | undefined | false | 'empty' { + if (!Object.hasOwn(record, key) || record[key] == null) { + return undefined + } + const value = record[key] + if (!Array.isArray(value)) return false + const items: Array = [] + for (const entry of value) { + if (typeof entry !== 'string') return false + const trimmed = entry.trim() + if (trimmed) items.push(trimmed) + } + return items.length > 0 ? items : 'empty' +} diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index 914372ef22..cef5cfbbda 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -210,6 +210,54 @@ export type AdminFeatureFlagsLoaderData = { featureFlags: Array } +export type AdminCodemodListItem = { + id: string + description: string +} + +export type AdminCodemodRunListItem = { + id: string + codemodId: string + mode: string + scopeUserId: string | null + initiatedByUserId: string + filtersJson: string + status: 'running' | 'completed' | 'failed' + revertOfRunId: string | null + createdAt: string + updatedAt: string +} + +export type AdminCodemodRunItemListItem = { + id: string + runId: string + userId: string + packageId: string + kodyId: string + status: string + beforeCommit: string | null + afterCommit: string | null + changedPaths: Array + findings: Array<{ path: string | null; message: string }> + checkSummaryJson: string | null + error: string | null + createdAt: string + updatedAt: string +} + +export type AdminCodemodsLoaderData = { + ok: true + codemods: Array + runs: Array +} + +export type AdminCodemodRunItemsLoaderData = { + ok: true + run: AdminCodemodRunListItem | null + items: Array + nextAfterId: string | null +} + export type AdminUsageMetric = | 'execute' | 'package_export' @@ -1162,6 +1210,7 @@ export type AppLoaderData = { adminCommunityReports?: AdminCommunityReportsLoaderData adminInvites?: AdminInvitesLoaderData adminFeatureFlags?: AdminFeatureFlagsLoaderData + adminCodemods?: AdminCodemodsLoaderData adminInsights?: AdminInsightsLoaderData adminPlatformFeedback?: AdminPlatformFeedbackLoaderData adminSystemEmail?: AdminSystemEmailLoaderData diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index f9af0514b7..5461a96768 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -17,6 +17,11 @@ import { createAdminFeatureFlagsApiHandler, createAdminFeatureFlagsHandler, } from '#app/handlers/admin-feature-flags.ts' +import { + createAdminCodemodsApiHandler, + createAdminCodemodsHandler, + createAdminCodemodsRunApiHandler, +} from '#app/handlers/admin-codemods.ts' import { createAdminPackageStorageAuditApiHandler } from '#app/handlers/admin-package-storage-audit.ts' import { createAdminRolesApiHandler, @@ -321,6 +326,9 @@ export function createAppRouter(env: Env) { adminFeatureFlags: createAdminFeatureFlagsHandler(env), adminFeatureFlagsApi: createAdminFeatureFlagsApiHandler(env), adminFeatureFlagsApiPost: createAdminFeatureFlagsApiHandler(env), + adminCodemods: createAdminCodemodsHandler(env), + adminCodemodsApi: createAdminCodemodsApiHandler(env), + adminCodemodsRunApi: createAdminCodemodsRunApiHandler(env), adminPackageStorageAuditApi: createAdminPackageStorageAuditApiHandler(env), adminRoles: createAdminRolesHandler(env), diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index e4de31d58c..f3b161a258 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -94,6 +94,9 @@ export const routes = route({ adminFeatureFlags: '/admin/feature-flags', adminFeatureFlagsApi: '/admin/feature-flags.json', adminFeatureFlagsApiPost: post('/admin/feature-flags.json'), + adminCodemods: '/admin/codemods', + adminCodemodsApi: '/admin/codemods.json', + adminCodemodsRunApi: post('/admin/codemods/run.json'), adminPackageStorageAuditApi: '/admin/package-storage-audit.json', adminCommunityReports: '/admin/community-reports', adminCommunityReportsApi: '/admin/community-reports.json', diff --git a/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-apply.ts b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-apply.ts new file mode 100644 index 0000000000..634f968d28 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-apply.ts @@ -0,0 +1,55 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + adminPackageCodemodStepInputSchema, + packageCodemodPagingHint, + packageCodemodStepResultSchema, + runFleetPackageCodemodStep, +} from '#mcp/capabilities/packages/package-codemod-shared.ts' +import { + adminMutationCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' + +export const adminPackageCodemodApplyCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminMutationCapabilityAccess, + destructive: true, + name: 'admin_package_codemod_apply', + description: `Fleet-apply a registered package codemod: republishes transformed published trees after the same gates as dry-run. Prefer admin_package_codemod_dry_run first; canary with filters. Keep the returned runId to revert with admin_package_codemod_revert. ${packageCodemodPagingHint}`, + keywords: [ + 'admin', + 'package', + 'codemod', + 'apply', + 'fleet', + 'migrate', + 'republish', + 'package codemod', + ], + tags: ['codemod'], + inputSchema: adminPackageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await auditAdminCapabilityInvocation( + ctx, + 'admin_package_codemod_apply', + async () => + await runFleetPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'apply', + packageIds: args.packageIds, + filters: args.filters, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }), + { + successReason: (result) => + `codemod=${result.codemodId};run=${result.runId}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-capabilities.node.test.ts new file mode 100644 index 0000000000..6c4a31a1f0 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-capabilities.node.test.ts @@ -0,0 +1,196 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' +import type * as AuditLog from '#worker/audit-log.ts' + +const mockModule = vi.hoisted(() => ({ + runPackageCodemodStep: vi.fn(), + getPackageCodemodRunById: vi.fn(), + logAuditEvent: vi.fn(), +})) + +vi.mock('#worker/package-codemods/engine.ts', () => ({ + runPackageCodemodStep: (...args: Array) => + mockModule.runPackageCodemodStep(...args), +})) + +vi.mock('#worker/package-codemods/ledger.ts', () => ({ + getPackageCodemodRunById: (...args: Array) => + mockModule.getPackageCodemodRunById(...args), +})) + +vi.mock('#worker/audit-log.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + logAuditEvent: (...args: Array) => + mockModule.logAuditEvent(...args), + } +}) + +const { adminPackageCodemodApplyCapability } = + await import('./admin-package-codemod-apply.ts') +const { adminPackageCodemodRevertCapability } = + await import('./admin-package-codemod-revert.ts') +const { adminPackageCodemodScanCapability } = + await import('./admin-package-codemod-scan.ts') +const { adminDomain } = await import('./domain.ts') + +function createAdminCtx(userId = 'admin-1') { + return { + env: { APP_DB: {} } as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { + userId, + email: 'admin@example.com', + displayName: 'Admin', + roles: ['admin'], + }, + }), + } +} + +function emptyStepResult(input: { + runId: string + codemodId: string + mode: 'scan' | 'dry-run' | 'apply' | 'revert' +}) { + return { + runId: input.runId, + codemodId: input.codemodId, + mode: input.mode, + items: [], + nextCursor: null, + summary: {}, + } +} + +test('admin domain registers fleet package codemod capabilities with admin access', () => { + const byName = new Map( + adminDomain.capabilities.map((capability) => [capability.name, capability]), + ) + for (const name of [ + 'admin_package_codemod_scan', + 'admin_package_codemod_dry_run', + 'admin_package_codemod_apply', + 'admin_package_codemod_revert', + ]) { + expect(byName.get(name)?.requiredRole).toBe('admin') + } + expect(byName.get('admin_package_codemod_scan')?.readOnly).toBe(true) + expect(byName.get('admin_package_codemod_dry_run')?.readOnly).toBe(true) + expect(byName.get('admin_package_codemod_apply')?.destructive).toBe(true) + expect(byName.get('admin_package_codemod_revert')?.destructive).toBe(true) +}) + +test('admin_package_codemod_scan uses fleet scope and merges filters', async () => { + mockModule.runPackageCodemodStep.mockResolvedValue( + emptyStepResult({ + runId: 'fleet-scan-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + }), + ) + mockModule.logAuditEvent.mockResolvedValue(undefined) + + await expect( + adminPackageCodemodScanCapability.handler( + { + codemodId: '0001-ambient-storage-to-package-storage', + filters: { userIds: ['user-a'], packageIds: ['pkg-a'] }, + limit: 5, + }, + createAdminCtx(), + ), + ).resolves.toMatchObject({ runId: 'fleet-scan-1', mode: 'scan' }) + + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith({ + env: { APP_DB: {} }, + baseUrl: 'https://heykody.dev', + initiatedByUserId: 'admin-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scope: { kind: 'fleet' }, + filters: { userIds: ['user-a'], packageIds: ['pkg-a'] }, + runId: undefined, + cursor: undefined, + limit: 5, + revertOfRunId: undefined, + }) + expect(mockModule.logAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'admin_package_codemod_scan', + result: 'success', + }), + ) +}) + +test('admin_package_codemod_apply is fleet-scoped and destructive', async () => { + mockModule.runPackageCodemodStep.mockResolvedValue( + emptyStepResult({ + runId: 'fleet-apply-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + }), + ) + mockModule.logAuditEvent.mockResolvedValue(undefined) + + await expect( + adminPackageCodemodApplyCapability.handler( + { + codemodId: '0001-ambient-storage-to-package-storage', + packageIds: ['pkg-canary'], + }, + createAdminCtx(), + ), + ).resolves.toMatchObject({ mode: 'apply' }) + + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'apply', + scope: { kind: 'fleet' }, + filters: { packageIds: ['pkg-canary'] }, + initiatedByUserId: 'admin-1', + }), + ) + expect(adminPackageCodemodApplyCapability.destructive).toBe(true) +}) + +test('admin_package_codemod_revert resolves codemodId from the prior run without user-scope gating', async () => { + mockModule.getPackageCodemodRunById.mockResolvedValue({ + id: 'fleet-apply-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + filtersJson: '{}', + status: 'completed', + revertOfRunId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + mockModule.runPackageCodemodStep.mockResolvedValue( + emptyStepResult({ + runId: 'fleet-revert-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + }), + ) + mockModule.logAuditEvent.mockResolvedValue(undefined) + + await expect( + adminPackageCodemodRevertCapability.handler( + { revertOfRunId: 'fleet-apply-1' }, + createAdminCtx(), + ), + ).resolves.toMatchObject({ runId: 'fleet-revert-1', mode: 'revert' }) + + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith( + expect.objectContaining({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + scope: { kind: 'fleet' }, + revertOfRunId: 'fleet-apply-1', + }), + ) +}) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-dry-run.ts b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-dry-run.ts new file mode 100644 index 0000000000..40593f60d2 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-dry-run.ts @@ -0,0 +1,54 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + adminPackageCodemodStepInputSchema, + packageCodemodPagingHint, + packageCodemodStepResultSchema, + runFleetPackageCodemodStep, +} from '#mcp/capabilities/packages/package-codemod-shared.ts' +import { + adminCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' + +export const adminPackageCodemodDryRunCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminCapabilityAccess, + name: 'admin_package_codemod_dry_run', + description: `Fleet dry-run a registered package codemod: transform in memory and run publish checks without writing. Optional filters canary by userIds or packageIds. ${packageCodemodPagingHint}`, + keywords: [ + 'admin', + 'package', + 'codemod', + 'dry-run', + 'preview', + 'fleet', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + inputSchema: adminPackageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await auditAdminCapabilityInvocation( + ctx, + 'admin_package_codemod_dry_run', + async () => + await runFleetPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'dry-run', + packageIds: args.packageIds, + filters: args.filters, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }), + { + successReason: (result) => + `codemod=${result.codemodId};run=${result.runId}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-revert.ts b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-revert.ts new file mode 100644 index 0000000000..5d7005a306 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-revert.ts @@ -0,0 +1,67 @@ +import { McpCallerError } from '#mcp/caller-error.ts' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + adminPackageCodemodRevertInputSchema, + packageCodemodPagingHint, + packageCodemodStepResultSchema, + runFleetPackageCodemodStep, +} from '#mcp/capabilities/packages/package-codemod-shared.ts' +import { getPackageCodemodRunById } from '#worker/package-codemods/ledger.ts' +import { + adminMutationCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' + +export const adminPackageCodemodRevertCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminMutationCapabilityAccess, + destructive: true, + name: 'admin_package_codemod_revert', + description: `Fleet-revert a prior admin_package_codemod_apply (or other apply) run by republishing stored pre-codemod snapshots. ${packageCodemodPagingHint}`, + keywords: [ + 'admin', + 'package', + 'codemod', + 'revert', + 'undo', + 'fleet', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + inputSchema: adminPackageCodemodRevertInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await auditAdminCapabilityInvocation( + ctx, + 'admin_package_codemod_revert', + async () => { + const priorRun = await getPackageCodemodRunById( + ctx.env.APP_DB, + args.revertOfRunId, + ) + if (!priorRun) { + throw new McpCallerError( + `Package codemod run "${args.revertOfRunId}" was not found.`, + ) + } + return await runFleetPackageCodemodStep(ctx, { + codemodId: priorRun.codemodId, + mode: 'revert', + filters: args.filters, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + revertOfRunId: args.revertOfRunId, + }) + }, + { + successReason: (result) => + `codemod=${result.codemodId};run=${result.runId};revert_of=${args.revertOfRunId}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-scan.ts b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-scan.ts new file mode 100644 index 0000000000..05325968b1 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-package-codemod-scan.ts @@ -0,0 +1,54 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + adminPackageCodemodStepInputSchema, + packageCodemodPagingHint, + packageCodemodStepResultSchema, + runFleetPackageCodemodStep, +} from '#mcp/capabilities/packages/package-codemod-shared.ts' +import { + adminCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' + +export const adminPackageCodemodScanCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminCapabilityAccess, + name: 'admin_package_codemod_scan', + description: `Fleet-scan saved packages for matches of a registered package codemod (detect only; no writes). Optional filters canary by userIds or packageIds. ${packageCodemodPagingHint}`, + keywords: [ + 'admin', + 'package', + 'codemod', + 'scan', + 'detect', + 'fleet', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + inputSchema: adminPackageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await auditAdminCapabilityInvocation( + ctx, + 'admin_package_codemod_scan', + async () => + await runFleetPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'scan', + packageIds: args.packageIds, + filters: args.filters, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }), + { + successReason: (result) => + `codemod=${result.codemodId};run=${result.runId}`, + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/domain.ts b/packages/worker/src/mcp/capabilities/admin/domain.ts index ebe5a2421c..538ad88be2 100644 --- a/packages/worker/src/mcp/capabilities/admin/domain.ts +++ b/packages/worker/src/mcp/capabilities/admin/domain.ts @@ -5,6 +5,10 @@ import { adminCommunityActivityListCapability } from './admin-community-activity import { adminFeatureFlagListCapability } from './admin-feature-flag-list.ts' import { adminFeatureFlagOverrideCapability } from './admin-feature-flag-override.ts' import { adminFeatureFlagSetCapability } from './admin-feature-flag-set.ts' +import { adminPackageCodemodApplyCapability } from './admin-package-codemod-apply.ts' +import { adminPackageCodemodDryRunCapability } from './admin-package-codemod-dry-run.ts' +import { adminPackageCodemodRevertCapability } from './admin-package-codemod-revert.ts' +import { adminPackageCodemodScanCapability } from './admin-package-codemod-scan.ts' import { adminPackageScopeGrantCreateCapability } from './admin-package-scope-grant-create.ts' import { adminPackageScopeGrantListCapability } from './admin-package-scope-grant-list.ts' import { adminPackageScopeGrantRevokeCapability } from './admin-package-scope-grant-revoke.ts' @@ -28,7 +32,7 @@ import { adminAccountWriteLeaseRepairCapability } from './admin-account-write-le export const adminDomain = defineDomain({ name: capabilityDomainNames.admin, description: - 'Admin-only operator capabilities for account metadata, platform accounts, package scope grants, feature flags, operator-owned system email, attributed platform feedback users explicitly submit for admin review, and metadata about activity on public community listings; never exposes private package source or unrelated user content such as secrets, memories, jobs, or user inbox email.', + 'Admin-only operator capabilities for account metadata, platform accounts, package scope grants, fleet package-codemod scan/dry-run/apply/revert over published package trees, feature flags, operator-owned system email, attributed platform feedback users explicitly submit for admin review, and metadata about activity on public community listings; never exposes secrets, memories, jobs, or user inbox email.', keywords: [ 'admin', 'rbac', @@ -43,6 +47,10 @@ export const adminDomain = defineDomain({ 'community activity', 'platform accounts', 'package scope grants', + 'codemod', + 'package codemod', + 'fleet', + 'migration', ], capabilities: [ adminUserListCapability, @@ -55,6 +63,10 @@ export const adminDomain = defineDomain({ adminPackageScopeGrantCreateCapability, adminPackageScopeGrantRevokeCapability, adminPackageScopeGrantListCapability, + adminPackageCodemodScanCapability, + adminPackageCodemodDryRunCapability, + adminPackageCodemodApplyCapability, + adminPackageCodemodRevertCapability, adminAuditLogQueryCapability, adminUserUsageCapability, adminFeatureFlagListCapability, diff --git a/packages/worker/src/mcp/capabilities/packages/domain.ts b/packages/worker/src/mcp/capabilities/packages/domain.ts index 76fca54223..6208682722 100644 --- a/packages/worker/src/mcp/capabilities/packages/domain.ts +++ b/packages/worker/src/mcp/capabilities/packages/domain.ts @@ -5,6 +5,11 @@ import { getGitRemoteCapability } from './get-git-remote.ts' import { getPackageCapability } from './get-package.ts' import { listPackagesCapability } from './list-packages.ts' import { listPackageSubscriptionsCapability } from './list-package-subscriptions.ts' +import { packageCodemodApplyCapability } from './package-codemod-apply.ts' +import { packageCodemodDryRunCapability } from './package-codemod-dry-run.ts' +import { packageCodemodListCapability } from './package-codemod-list.ts' +import { packageCodemodRevertCapability } from './package-codemod-revert.ts' +import { packageCodemodScanCapability } from './package-codemod-scan.ts' import { packageInvocationTokenGetCapability } from './package-invocation-token-get.ts' import { packageInvocationTokenListCapability } from './package-invocation-token-list.ts' import { packageUpdateCapability } from './package-update.ts' @@ -14,7 +19,7 @@ import { savePackageCapability } from './save-package.ts' export const packagesDomain = defineDomain({ name: capabilityDomainNames.packages, description: - 'Saved packages are the only top-level persisted primitive: repo-backed source rooted at package.json, package-scoped config (secrets/values), durable package storage via packageStorage(), and optional apps, services, jobs, and other package.json#kody surfaces.', + 'Saved packages are the only top-level persisted primitive: repo-backed source rooted at package.json, package-scoped config (secrets/values), durable package storage via packageStorage(), and optional apps, services, jobs, and other package.json#kody surfaces. Includes self-scoped package codemod list/scan/dry-run/apply/revert over the caller’s own published trees.', keywords: [ 'package', 'repo', @@ -25,6 +30,9 @@ export const packagesDomain = defineDomain({ 'services', 'subscriptions', 'event handlers', + 'codemod', + 'package codemod', + 'migration', ], capabilities: [ savePackageCapability, @@ -37,5 +45,10 @@ export const packagesDomain = defineDomain({ packageInvocationTokenGetCapability, publishExternalPushCapability, deletePackageCapability, + packageCodemodListCapability, + packageCodemodScanCapability, + packageCodemodDryRunCapability, + packageCodemodApplyCapability, + packageCodemodRevertCapability, ], }) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-apply.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-apply.ts new file mode 100644 index 0000000000..57c3fc1c28 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-apply.ts @@ -0,0 +1,40 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + packageCodemodPagingHint, + packageCodemodStepInputSchema, + packageCodemodStepResultSchema, + runCallerPackageCodemodStep, +} from './package-codemod-shared.ts' + +export const packageCodemodApplyCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_codemod_apply', + description: `Apply a registered package codemod to the signed-in user’s saved packages: republishes transformed published trees after the same gates as dry-run. Prefer package_codemod_dry_run first. Keep the returned runId to revert with package_codemod_revert. ${packageCodemodPagingHint}`, + keywords: [ + 'package', + 'codemod', + 'apply', + 'migrate', + 'republish', + 'package codemod', + ], + tags: ['codemod'], + readOnly: false, + idempotent: false, + destructive: true, + inputSchema: packageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await runCallerPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'apply', + packageIds: args.packageIds, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-capabilities.node.test.ts new file mode 100644 index 0000000000..1c5d0b462a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-capabilities.node.test.ts @@ -0,0 +1,260 @@ +import { expect, test, vi } from 'vitest' + +const mockModule = vi.hoisted(() => ({ + runPackageCodemodStep: vi.fn(), + getPackageCodemodRunById: vi.fn(), + listPackageCodemods: vi.fn(), +})) + +vi.mock('#worker/package-codemods/engine.ts', () => ({ + runPackageCodemodStep: (...args: Array) => + mockModule.runPackageCodemodStep(...args), +})) + +vi.mock('#worker/package-codemods/ledger.ts', () => ({ + getPackageCodemodRunById: (...args: Array) => + mockModule.getPackageCodemodRunById(...args), +})) + +vi.mock('#worker/package-codemods/registry.ts', () => ({ + listPackageCodemods: (...args: Array) => + mockModule.listPackageCodemods(...args), +})) + +const { packageCodemodApplyCapability } = + await import('./package-codemod-apply.ts') +const { packageCodemodListCapability } = + await import('./package-codemod-list.ts') +const { packageCodemodRevertCapability } = + await import('./package-codemod-revert.ts') +const { packagesDomain } = await import('./domain.ts') + +function createCtx(userId = 'user-1') { + return { + env: { APP_DB: {} } as Env, + callerContext: { + baseUrl: 'https://heykody.dev', + user: { + userId, + email: 'user@example.com', + displayName: 'User', + }, + remoteConnectors: null, + storageContext: null, + repoContext: null, + }, + } +} + +function emptyStepResult(input: { + runId: string + codemodId: string + mode: 'scan' | 'dry-run' | 'apply' | 'revert' +}) { + return { + runId: input.runId, + codemodId: input.codemodId, + mode: input.mode, + items: [], + nextCursor: null, + summary: {}, + } +} + +test('packages domain registers self-scoped package codemod capabilities', () => { + const names = packagesDomain.capabilities.map((capability) => capability.name) + expect(names).toEqual( + expect.arrayContaining([ + 'package_codemod_list', + 'package_codemod_scan', + 'package_codemod_dry_run', + 'package_codemod_apply', + 'package_codemod_revert', + ]), + ) +}) + +test('package_codemod_list returns registered codemods', async () => { + mockModule.listPackageCodemods.mockReturnValue([ + { + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + }, + ]) + await expect( + packageCodemodListCapability.handler({}, createCtx()), + ).resolves.toEqual({ + codemods: [ + { + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + }, + ], + }) +}) + +test('package_codemod_apply always scopes to the caller and never accepts another userId', async () => { + mockModule.runPackageCodemodStep.mockResolvedValue( + emptyStepResult({ + runId: 'run-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + }), + ) + + await expect( + packageCodemodApplyCapability.handler( + { + codemodId: '0001-ambient-storage-to-package-storage', + packageIds: ['pkg-1'], + limit: 10, + }, + createCtx('user-1'), + ), + ).resolves.toMatchObject({ + runId: 'run-1', + mode: 'apply', + }) + + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledTimes(1) + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith({ + env: { APP_DB: {} }, + baseUrl: 'https://heykody.dev', + initiatedByUserId: 'user-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-1'] }, + runId: undefined, + cursor: undefined, + limit: 10, + }) + const engineInput = mockModule.runPackageCodemodStep.mock.calls[0]?.[0] as { + scope: { kind: string; userId?: string } + } + expect(engineInput.scope).toEqual({ kind: 'user', userId: 'user-1' }) + expect(JSON.stringify(packageCodemodApplyCapability.inputSchema)).not.toMatch( + /"userId"/, + ) +}) + +test('package_codemod_revert looks up codemodId and rejects cross-user runs', async () => { + mockModule.getPackageCodemodRunById.mockResolvedValueOnce({ + id: 'apply-run-other', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scopeUserId: 'user-2', + initiatedByUserId: 'user-2', + filtersJson: '{}', + status: 'completed', + revertOfRunId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + + await expect( + packageCodemodRevertCapability.handler( + { revertOfRunId: 'apply-run-other' }, + createCtx('user-1'), + ), + ).rejects.toThrow(/not scoped to the signed-in user/i) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() + + mockModule.getPackageCodemodRunById.mockResolvedValueOnce({ + id: 'apply-run-self', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scopeUserId: 'user-1', + initiatedByUserId: 'user-1', + filtersJson: '{}', + status: 'completed', + revertOfRunId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + mockModule.runPackageCodemodStep.mockResolvedValueOnce( + emptyStepResult({ + runId: 'revert-run-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + }), + ) + + await expect( + packageCodemodRevertCapability.handler( + { revertOfRunId: 'apply-run-self', cursor: 'item-1' }, + createCtx('user-1'), + ), + ).resolves.toMatchObject({ runId: 'revert-run-1', mode: 'revert' }) + + expect(mockModule.runPackageCodemodStep).toHaveBeenCalledWith({ + env: { APP_DB: {} }, + baseUrl: 'https://heykody.dev', + initiatedByUserId: 'user-1', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + runId: undefined, + cursor: 'item-1', + limit: undefined, + revertOfRunId: 'apply-run-self', + }) + + mockModule.getPackageCodemodRunById.mockResolvedValueOnce({ + id: 'apply-run-fleet', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + filtersJson: '{}', + status: 'completed', + revertOfRunId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + mockModule.runPackageCodemodStep.mockResolvedValueOnce( + emptyStepResult({ + runId: 'revert-run-fleet', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'revert', + }), + ) + await expect( + packageCodemodRevertCapability.handler( + { revertOfRunId: 'apply-run-fleet' }, + createCtx('user-1'), + ), + ).resolves.toMatchObject({ runId: 'revert-run-fleet', mode: 'revert' }) + expect(mockModule.runPackageCodemodStep).toHaveBeenLastCalledWith( + expect.objectContaining({ + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: 'apply-run-fleet', + }), + ) +}) + +test('package_codemod_apply and package_codemod_revert require an authenticated user', async () => { + const anonymousCtx = { + env: { APP_DB: {} } as Env, + callerContext: { + baseUrl: 'https://heykody.dev', + user: null, + remoteConnectors: null, + storageContext: null, + repoContext: null, + }, + } + await expect( + packageCodemodApplyCapability.handler( + { codemodId: '0001-ambient-storage-to-package-storage' }, + anonymousCtx, + ), + ).rejects.toThrow(/authenticated/i) + await expect( + packageCodemodRevertCapability.handler( + { revertOfRunId: 'apply-run-1' }, + anonymousCtx, + ), + ).rejects.toThrow(/authenticated/i) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-dry-run.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-dry-run.ts new file mode 100644 index 0000000000..be9d58ec52 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-dry-run.ts @@ -0,0 +1,40 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + packageCodemodPagingHint, + packageCodemodStepInputSchema, + packageCodemodStepResultSchema, + runCallerPackageCodemodStep, +} from './package-codemod-shared.ts' + +export const packageCodemodDryRunCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_codemod_dry_run', + description: `Dry-run a registered package codemod on the signed-in user’s saved packages: transform in memory and run publish checks without writing. ${packageCodemodPagingHint}`, + keywords: [ + 'package', + 'codemod', + 'dry-run', + 'preview', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: packageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await runCallerPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'dry-run', + packageIds: args.packageIds, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-list.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-list.ts new file mode 100644 index 0000000000..75b283c67a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-list.ts @@ -0,0 +1,33 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { emptyCapabilityInputSchema } from '#mcp/capabilities/types.ts' +import { listPackageCodemods } from '#worker/package-codemods/registry.ts' +import { packageCodemodListOutputSchema } from './package-codemod-shared.ts' + +export const packageCodemodListCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_codemod_list', + description: + 'List registered package codemods available to scan, dry-run, apply, or revert against the signed-in user’s saved packages.', + keywords: [ + 'package', + 'codemod', + 'list', + 'migration', + 'transform', + 'package codemod', + ], + tags: ['codemod'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: emptyCapabilityInputSchema, + outputSchema: packageCodemodListOutputSchema, + async handler(args, ctx) { + void args + void ctx + return { codemods: listPackageCodemods() } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-revert.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-revert.ts new file mode 100644 index 0000000000..09df541ae4 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-revert.ts @@ -0,0 +1,65 @@ +import { McpCallerError } from '#mcp/caller-error.ts' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { runPackageCodemodStep } from '#worker/package-codemods/engine.ts' +import { getPackageCodemodRunById } from '#worker/package-codemods/ledger.ts' +import { + packageCodemodPagingHint, + packageCodemodRevertInputSchema, + packageCodemodStepResultSchema, +} from './package-codemod-shared.ts' + +export const packageCodemodRevertCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_codemod_revert', + description: `Revert a prior package_codemod_apply run for the signed-in user’s packages by republishing stored pre-codemod snapshots. ${packageCodemodPagingHint}`, + keywords: [ + 'package', + 'codemod', + 'revert', + 'undo', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + readOnly: false, + idempotent: false, + destructive: true, + inputSchema: packageCodemodRevertInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + const user = requireMcpUser(ctx.callerContext) + const priorRun = await getPackageCodemodRunById( + ctx.env.APP_DB, + args.revertOfRunId, + ) + if (!priorRun) { + throw new McpCallerError( + `Package codemod run "${args.revertOfRunId}" was not found.`, + ) + } + if ( + priorRun.scopeUserId != null && + priorRun.scopeUserId !== user.userId + ) { + throw new McpCallerError( + `Package codemod run "${args.revertOfRunId}" is not scoped to the signed-in user.`, + ) + } + return await runPackageCodemodStep({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + initiatedByUserId: user.userId, + codemodId: priorRun.codemodId, + mode: 'revert', + scope: { kind: 'user', userId: user.userId }, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + revertOfRunId: args.revertOfRunId, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-scan.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-scan.ts new file mode 100644 index 0000000000..0286eb3706 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-scan.ts @@ -0,0 +1,40 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + packageCodemodPagingHint, + packageCodemodStepInputSchema, + packageCodemodStepResultSchema, + runCallerPackageCodemodStep, +} from './package-codemod-shared.ts' + +export const packageCodemodScanCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_codemod_scan', + description: `Scan the signed-in user’s saved packages for matches of a registered package codemod (detect only; no writes). ${packageCodemodPagingHint}`, + keywords: [ + 'package', + 'codemod', + 'scan', + 'detect', + 'migration', + 'package codemod', + ], + tags: ['codemod'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: packageCodemodStepInputSchema, + outputSchema: packageCodemodStepResultSchema, + async handler(args, ctx) { + return await runCallerPackageCodemodStep(ctx, { + codemodId: args.codemodId, + mode: 'scan', + packageIds: args.packageIds, + runId: args.runId, + cursor: args.cursor, + limit: args.limit, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/package-codemod-shared.ts b/packages/worker/src/mcp/capabilities/packages/package-codemod-shared.ts new file mode 100644 index 0000000000..394729c533 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-shared.ts @@ -0,0 +1,231 @@ +import { z } from 'zod' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { + runPackageCodemodStep, + type PackageCodemodRunMode, +} from '#worker/package-codemods/engine.ts' + +export const packageCodemodItemStatusSchema = z.enum([ + 'detected', + 'clean', + 'dry_run_ok', + 'dry_run_new_failures', + 'needs_manual', + 'skipped_drift', + 'skipped_unpublished', + 'applied', + 'reverted', + 'failed', +]) + +export const packageCodemodRunModeSchema = z.enum([ + 'scan', + 'dry-run', + 'apply', + 'revert', +]) + +export const packageCodemodFindingSchema = z.object({ + path: z.string().nullable(), + message: z.string(), +}) + +export const packageCodemodRunItemSchema = z.object({ + itemId: z.string(), + userId: z.string(), + packageId: z.string(), + kodyId: z.string(), + status: packageCodemodItemStatusSchema, + changedPaths: z.array(z.string()), + findings: z.array(packageCodemodFindingSchema), + beforeCommit: z.string().nullable(), + afterCommit: z.string().nullable(), + checkSummary: z + .object({ + ok: z.boolean(), + newFailures: z.array(z.string()), + }) + .nullable(), + error: z.string().nullable(), +}) + +export const packageCodemodStepResultSchema = z.object({ + runId: z.string().describe('Codemod run id; pass back when paging.'), + codemodId: z.string(), + mode: packageCodemodRunModeSchema, + items: z.array(packageCodemodRunItemSchema), + nextCursor: z + .string() + .nullable() + .describe( + 'Opaque cursor for the next page, or null when this is the last page.', + ), + summary: z.partialRecord( + packageCodemodItemStatusSchema, + z.number().int().nonnegative(), + ), +}) + +export const packageCodemodStepInputSchema = z.object({ + codemodId: z + .string() + .min(1) + .describe('Registered package codemod id from package_codemod_list.'), + packageIds: z + .array(z.string().min(1)) + .optional() + .describe('Optional saved package ids to limit this step.'), + runId: z + .string() + .min(1) + .optional() + .describe('Existing run id to continue; required with cursor when paging.'), + cursor: z + .string() + .min(1) + .optional() + .describe('Opaque pagination cursor from a previous nextCursor value.'), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe( + 'Max packages to process in this step. Scan: default 20, max 50. Dry-run, apply, and revert are check-heavy: default 5, max 10 (higher values are clamped).', + ), +}) + +export const packageCodemodRevertInputSchema = z.object({ + revertOfRunId: z + .string() + .min(1) + .describe('Prior apply run id whose applied items should be reverted.'), + runId: z + .string() + .min(1) + .optional() + .describe( + 'Existing revert run id to continue; required with cursor when paging.', + ), + cursor: z + .string() + .min(1) + .optional() + .describe('Opaque pagination cursor from a previous nextCursor value.'), + limit: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe( + 'Max items to revert in this step (default 5, max 10 — reverts republish packages and are check-heavy).', + ), +}) + +export const packageCodemodFiltersSchema = z + .object({ + userIds: z + .array(z.string().min(1)) + .optional() + .describe('Optional user ids to canary or limit a fleet run.'), + packageIds: z + .array(z.string().min(1)) + .optional() + .describe('Optional saved package ids to limit a fleet run.'), + }) + .describe('Optional fleet filters for canary or partial runs.') + +export const adminPackageCodemodStepInputSchema = + packageCodemodStepInputSchema.extend({ + filters: packageCodemodFiltersSchema.optional(), + }) + +export const adminPackageCodemodRevertInputSchema = + packageCodemodRevertInputSchema.extend({ + filters: packageCodemodFiltersSchema.optional(), + }) + +export const packageCodemodListOutputSchema = z.object({ + codemods: z.array( + z.object({ + id: z.string(), + description: z.string(), + }), + ), +}) + +const pagingDescription = + 'Paged: call again with runId and nextCursor until nextCursor is null.' + +export const packageCodemodPagingHint = pagingDescription + +export async function runCallerPackageCodemodStep( + ctx: CapabilityContext, + input: { + codemodId: string + mode: Exclude + packageIds?: Array + runId?: string + cursor?: string + limit?: number + }, +) { + const user = requireMcpUser(ctx.callerContext) + return await runPackageCodemodStep({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + initiatedByUserId: user.userId, + codemodId: input.codemodId, + mode: input.mode, + scope: { kind: 'user', userId: user.userId }, + filters: input.packageIds ? { packageIds: input.packageIds } : undefined, + runId: input.runId, + cursor: input.cursor, + limit: input.limit, + }) +} + +export async function runFleetPackageCodemodStep( + ctx: CapabilityContext, + input: { + codemodId: string + mode: PackageCodemodRunMode + packageIds?: Array + filters?: { + userIds?: Array + packageIds?: Array + } + runId?: string + cursor?: string + limit?: number + revertOfRunId?: string + }, +) { + const user = requireMcpUser(ctx.callerContext) + const packageIds = input.filters?.packageIds ?? input.packageIds + const filters = + input.filters?.userIds != null || packageIds != null + ? { + ...(input.filters?.userIds != null + ? { userIds: input.filters.userIds } + : {}), + ...(packageIds != null ? { packageIds } : {}), + } + : undefined + return await runPackageCodemodStep({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + initiatedByUserId: user.userId, + codemodId: input.codemodId, + mode: input.mode, + scope: { kind: 'fleet' }, + filters, + runId: input.runId, + cursor: input.cursor, + limit: input.limit, + revertOfRunId: input.revertOfRunId, + }) +} diff --git a/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.node.test.ts b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.node.test.ts new file mode 100644 index 0000000000..bd130fcda8 --- /dev/null +++ b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.node.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from 'vitest' +import { ambientStorageToPackageStorageCodemod } from './0001-ambient-storage-to-package-storage.ts' + +function exportOnlyManifest(name = '@user/demo', kodyId = 'demo') { + return `${JSON.stringify( + { + name, + exports: { '.': './index.ts' }, + kody: { + id: kodyId, + description: 'Demo package for ambient storage codemod tests.', + }, + }, + null, + '\t', + )}\n` +} + +function appManifest() { + return `${JSON.stringify( + { + name: '@user/app', + exports: { '.': './index.ts' }, + kody: { + id: 'app', + description: 'App package that must not auto-migrate ambient storage.', + app: { entry: './app.ts' }, + }, + }, + null, + '\t', + )}\n` +} + +test('0001 rewrites member call sites, gates apps, verifies AST, and handles parse failures', () => { + const plain = { + 'package.json': exportOnlyManifest(), + 'index.ts': + "import { storage } from 'kody:runtime'\n\nexport async function run() {\n\treturn storage.get('k')\n}\n", + } + const plainDetect = ambientStorageToPackageStorageCodemod.detect(plain) + expect(plainDetect).toEqual([ + { + path: 'index.ts', + message: expect.stringContaining('ambient `storage`'), + }, + ]) + const plainTransform = ambientStorageToPackageStorageCodemod.transform(plain) + expect(plainTransform.changed).toBe(true) + expect(plainTransform.changedPaths).toEqual(['index.ts']) + expect(plainTransform.needsManual).toEqual([]) + expect(plainTransform.files['index.ts']).toContain( + "import { packageStorage } from 'kody:runtime'", + ) + expect(plainTransform.files['index.ts']).toContain( + "packageStorage().get('k')", + ) + expect(plainTransform.files['index.ts']).not.toContain( + 'const storage = packageStorage()', + ) + expect(plainTransform.files['index.ts']).not.toMatch( + /(? storage.get('k')\n", + } + const appTransform = + ambientStorageToPackageStorageCodemod.transform(appPackage) + expect(appTransform.changed).toBe(false) + expect(appTransform.needsManual).toEqual([ + { + path: 'index.ts', + message: expect.stringContaining('bucket identities'), + }, + ]) + expect(appTransform.files['index.ts']).toBe(appPackage['index.ts']) + + const commentTrap = { + 'package.json': exportOnlyManifest('@user/comment', 'comment'), + 'comment.ts': + "import { storage } from 'kody:runtime'\n// const storage = packageStorage()\nexport const run = () => storage.get('k')\n", + } + const commentTransform = + ambientStorageToPackageStorageCodemod.transform(commentTrap) + expect(commentTransform.changed).toBe(true) + expect(commentTransform.files['comment.ts']).toContain( + "packageStorage().get('k')", + ) + expect(commentTransform.files['comment.ts']).toContain( + '// const storage = packageStorage()', + ) + + const stringTrap = { + 'package.json': exportOnlyManifest('@user/string', 'string'), + 'string.ts': + "import { storage } from 'kody:runtime'\nconst note = 'const storage = packageStorage()'\nexport const run = () => storage.get('k')\n", + } + const stringTransform = + ambientStorageToPackageStorageCodemod.transform(stringTrap) + expect(stringTransform.changed).toBe(true) + expect(stringTransform.files['string.ts']).toContain( + "packageStorage().get('k')", + ) + expect(stringTransform.files['string.ts']).toContain( + "'const storage = packageStorage()'", + ) + + const unparseable = { + 'package.json': exportOnlyManifest('@user/bad', 'bad'), + 'bad.ts': + "import { storage } from 'kody:runtime'\nexport function broken( {\n", + } + const unparseableDetect = + ambientStorageToPackageStorageCodemod.detect(unparseable) + expect(unparseableDetect).toEqual([ + { + path: 'bad.ts', + message: expect.stringContaining('could not be parsed'), + }, + ]) + const unparseableTransform = + ambientStorageToPackageStorageCodemod.transform(unparseable) + expect(unparseableTransform.changed).toBe(false) + expect(unparseableTransform.needsManual).toEqual([ + { + path: 'bad.ts', + message: expect.stringContaining('could not be parsed'), + }, + ]) + + const clean = { + 'package.json': exportOnlyManifest('@user/clean', 'clean'), + 'clean.ts': + "import { packageStorage } from 'kody:runtime'\nexport const run = () => packageStorage().get('k')\n", + } + expect(ambientStorageToPackageStorageCodemod.detect(clean)).toEqual([]) + expect(ambientStorageToPackageStorageCodemod.transform(clean).changed).toBe( + false, + ) +}) diff --git a/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.ts b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.ts new file mode 100644 index 0000000000..39ea62fbe4 --- /dev/null +++ b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.ts @@ -0,0 +1,704 @@ +import { parseModuleSource, type ModuleAstNode } from '#worker/module-source.ts' +import { collectAmbientStorageImportFiles } from '#worker/repo/checks.ts' +import { + type PackageCodemod, + type PackageCodemodFinding, + type PackageCodemodTransformResult, +} from '../types.ts' + +export const ambientStorageToPackageStorageCodemodId = + '0001-ambient-storage-to-package-storage' + +const ambientStorageDetectMessage = + "Imports ambient `storage` from 'kody:runtime'; migrate to `packageStorage()`." + +const manualAliasMessage = + 'Ambient `storage` is imported under an alias; migrate to `packageStorage()` manually.' + +const manualUnusualMessage = + "Ambient `storage` import from 'kody:runtime' uses a pattern this codemod will not rewrite; migrate to `packageStorage()` manually." + +const manualNonMemberMessage = + 'Ambient `storage` is used as a value (not only as a member-expression object); migrate to `packageStorage()` manually.' + +const manualParseFailureMessage = + "File imports or references ambient `storage` from 'kody:runtime' but could not be parsed; migrate to `packageStorage()` manually." + +const manualVerifyMessage = + 'Rewritten file still contains a `storage` identifier; migrate to `packageStorage()` manually.' + +const manualExecutionSurfaceMessage = + 'Package declares an app, service, job, subscription, webhook, or retriever. Ambient `storage` and `packageStorage()` use different bucket identities for those surfaces; migrate manually to avoid silent data repointing.' + +const scannableModuleFilePattern = /\.(?:[cm]?[jt]s|[jt]sx)$/ + +type NamedImportSpecifier = { + type: string + importKind?: unknown + imported?: { name?: unknown; value?: unknown } + local?: { name?: unknown; value?: unknown } + start?: number + end?: number +} + +type ImportDeclarationNode = ModuleAstNode & { + importKind?: unknown + source?: { value?: unknown } + specifiers?: Array + start?: number + end?: number +} + +type AstNode = ModuleAstNode & { + name?: unknown + value?: unknown + start?: number + end?: number + computed?: boolean + object?: AstNode + property?: AstNode + key?: AstNode + id?: AstNode + local?: AstNode + imported?: AstNode + exported?: AstNode + params?: Array + body?: AstNode | Array + declarations?: Array + argument?: AstNode + left?: AstNode + right?: AstNode + callee?: AstNode + arguments?: Array + elements?: Array + properties?: Array + param?: AstNode + program?: { body?: Array } +} + +function getNodeName( + node: AstNode | { name?: unknown; value?: unknown } | null | undefined, +) { + if (!node) return null + if (typeof node.name === 'string') return node.name + if (typeof node.value === 'string') return node.value + return null +} + +function isTypeDeclarationFilePath(path: string) { + return ( + path.endsWith('.d.ts') || path.endsWith('.d.mts') || path.endsWith('.d.cts') + ) +} + +function parseProgram(source: string): AstNode | null { + try { + return parseModuleSource(source) as unknown as AstNode + } catch { + return null + } +} + +function getProgramBody(parsed: AstNode): Array { + const program = parsed.program + const body = program?.body ?? (parsed.body as Array | undefined) + return Array.isArray(body) ? body : [] +} + +function walkAst( + node: AstNode, + parent: AstNode | null, + visit: (node: AstNode, parent: AstNode | null) => void, +) { + visit(node, parent) + for (const [key, value] of Object.entries(node)) { + if ( + key === 'loc' || + key === 'start' || + key === 'end' || + key === 'range' || + key === 'leadingComments' || + key === 'trailingComments' || + key === 'innerComments' || + key === 'comments' + ) { + continue + } + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child === 'object' && 'type' in child) { + walkAst(child as AstNode, node, visit) + } + } + continue + } + if (value && typeof value === 'object' && 'type' in value) { + walkAst(value as AstNode, node, visit) + } + } +} + +function isNonComputedPropertyName(node: AstNode, parent: AstNode | null) { + if (!parent) return false + if ( + (parent.type === 'MemberExpression' || + parent.type === 'OptionalMemberExpression') && + parent.property === node && + parent.computed !== true + ) { + return true + } + if ( + (parent.type === 'ObjectProperty' || + parent.type === 'ObjectMethod' || + parent.type === 'ClassProperty' || + parent.type === 'ClassMethod' || + parent.type === 'ClassPrivateProperty' || + parent.type === 'Property') && + parent.key === node && + parent.computed !== true + ) { + return true + } + if ( + parent.type === 'ExportSpecifier' && + parent.exported === node && + parent.local !== node + ) { + return true + } + return false +} + +function isImportStorageSpecifier(node: AstNode, parent: AstNode | null) { + return ( + parent?.type === 'ImportSpecifier' && + (parent.local === node || parent.imported === node) + ) +} + +function listRuntimeStorageImports(body: Array) { + const imports: Array<{ + declaration: ImportDeclarationNode + storageSpecifiers: Array + hasPackageStorage: boolean + hasUnusualSpecifiers: boolean + }> = [] + for (const node of body) { + if (node.type !== 'ImportDeclaration') continue + const declaration = node as ImportDeclarationNode + if (declaration.importKind === 'type') continue + if (declaration.source?.value !== 'kody:runtime') continue + const specifiers = Array.isArray(declaration.specifiers) + ? declaration.specifiers + : [] + const storageSpecifiers: Array = [] + let hasPackageStorage = false + let hasUnusualSpecifiers = false + for (const specifier of specifiers) { + if ( + specifier.type === 'ImportDefaultSpecifier' || + specifier.type === 'ImportNamespaceSpecifier' + ) { + hasUnusualSpecifiers = true + continue + } + if (specifier.type !== 'ImportSpecifier') continue + if (specifier.importKind === 'type') continue + const importedName = getNodeName(specifier.imported) + const localName = getNodeName(specifier.local) + if (importedName === 'packageStorage') { + hasPackageStorage = true + continue + } + if (importedName !== 'storage') continue + if (localName !== 'storage') { + hasUnusualSpecifiers = true + } + storageSpecifiers.push(specifier) + } + if (storageSpecifiers.length === 0) continue + imports.push({ + declaration, + storageSpecifiers, + hasPackageStorage, + hasUnusualSpecifiers, + }) + } + return imports +} + +function hasExportReexportOfStorage(body: Array) { + for (const node of body) { + if (node.type !== 'ExportNamedDeclaration') continue + const declaration = node as AstNode & { + source?: { value?: unknown } + specifiers?: Array<{ + local?: { name?: unknown; value?: unknown } + exported?: { name?: unknown; value?: unknown } + }> + } + if (declaration.source?.value !== 'kody:runtime') continue + for (const specifier of declaration.specifiers ?? []) { + if ( + getNodeName(specifier.local) === 'storage' || + getNodeName(specifier.exported) === 'storage' + ) { + return true + } + } + } + return false +} + +function hasStorageBindingSite( + parsed: AstNode, + storageImportStarts: Set, +) { + let found = false + walkAst(parsed, null, (node, parent) => { + if (found) return + if (node.type !== 'Identifier' || getNodeName(node) !== 'storage') return + if (isNonComputedPropertyName(node, parent)) return + if (isImportStorageSpecifier(node, parent)) return + if (parent?.type === 'VariableDeclarator' && parent.id === node) { + found = true + return + } + if ( + (parent?.type === 'FunctionDeclaration' || + parent?.type === 'FunctionExpression' || + parent?.type === 'ArrowFunctionExpression' || + parent?.type === 'ClassMethod' || + parent?.type === 'ObjectMethod') && + Array.isArray(parent.params) && + parent.params.includes(node) + ) { + found = true + return + } + if (parent?.type === 'CatchClause' && parent.param === node) { + found = true + return + } + if (parent?.type === 'AssignmentPattern' && parent.left === node) { + found = true + } + }) + void storageImportStarts + return found +} + +function collectStorageMemberUseSites(parsed: AstNode): { + memberObjects: Array<{ start: number; end: number }> + nonMemberUses: boolean +} { + const memberObjects: Array<{ start: number; end: number }> = [] + let nonMemberUses = false + walkAst(parsed, null, (node, parent) => { + if (node.type !== 'Identifier' || getNodeName(node) !== 'storage') return + if (isNonComputedPropertyName(node, parent)) return + if (isImportStorageSpecifier(node, parent)) return + if ( + (parent?.type === 'MemberExpression' || + parent?.type === 'OptionalMemberExpression') && + parent.object === node && + typeof node.start === 'number' && + typeof node.end === 'number' + ) { + memberObjects.push({ start: node.start, end: node.end }) + return + } + nonMemberUses = true + }) + return { memberObjects, nonMemberUses } +} + +function remainingStorageIdentifiers(parsed: AstNode) { + const remaining: Array = [] + walkAst(parsed, null, (node, parent) => { + if (node.type !== 'Identifier' || getNodeName(node) !== 'storage') return + if (isNonComputedPropertyName(node, parent)) return + remaining.push(node) + }) + return remaining +} + +function removeSpecifierFromImportText(input: { + source: string + declaration: ImportDeclarationNode + specifier: NamedImportSpecifier +}) { + const declarationStart = input.declaration.start + const declarationEnd = input.declaration.end + const specifierStart = input.specifier.start + const specifierEnd = input.specifier.end + if ( + declarationStart == null || + declarationEnd == null || + specifierStart == null || + specifierEnd == null + ) { + return null + } + const declarationText = input.source.slice(declarationStart, declarationEnd) + const relativeStart = specifierStart - declarationStart + const relativeEnd = specifierEnd - declarationStart + const before = declarationText.slice(0, relativeStart) + const after = declarationText.slice(relativeEnd) + const beforeTrimmed = before.replace(/\s*,\s*$/, '') + const afterTrimmed = after.replace(/^\s*,\s*/, (match) => + beforeTrimmed.trimEnd().endsWith('{') ? match.replace(',', '') : match, + ) + let nextDeclaration = `${beforeTrimmed}${afterTrimmed}` + nextDeclaration = nextDeclaration.replace(/\{\s*,/, '{') + nextDeclaration = nextDeclaration.replace(/,\s*\}/, ' }') + nextDeclaration = nextDeclaration.replace(/\{\s+\}/, '{}') + return ( + input.source.slice(0, declarationStart) + + nextDeclaration + + input.source.slice(declarationEnd) + ) +} + +function replaceSpecifierNameInImportText(input: { + source: string + specifier: NamedImportSpecifier + nextName: string +}) { + const start = input.specifier.start + const end = input.specifier.end + if (start == null || end == null) return null + return input.source.slice(0, start) + input.nextName + input.source.slice(end) +} + +function applyRangeReplacements( + source: string, + replacements: Array<{ start: number; end: number; text: string }>, +) { + const ordered = [...replacements].sort( + (left, right) => right.start - left.start, + ) + let next = source + for (const replacement of ordered) { + next = + next.slice(0, replacement.start) + + replacement.text + + next.slice(replacement.end) + } + return next +} + +function packageDeclaresNonExportExecutionSurface( + files: Record, +) { + const raw = files['package.json'] + if (typeof raw !== 'string') { + return { + blocked: true as const, + message: + 'package.json is missing; cannot confirm the package is export-only before migrating ambient storage.', + } + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return { + blocked: true as const, + message: + 'package.json could not be parsed; cannot confirm the package is export-only before migrating ambient storage.', + } + } + if (!parsed || typeof parsed !== 'object') { + return { + blocked: true as const, + message: manualExecutionSurfaceMessage, + } + } + const kody = (parsed as { kody?: unknown }).kody + if (!kody || typeof kody !== 'object') { + return { blocked: false as const } + } + const record = kody as Record + const surfaceKeys = [ + 'app', + 'services', + 'jobs', + 'subscriptions', + 'webhooks', + 'retrievers', + ] as const + for (const key of surfaceKeys) { + const value = record[key] + if (value == null) continue + if (Array.isArray(value) && value.length === 0) continue + if ( + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + continue + } + return { blocked: true as const, message: manualExecutionSurfaceMessage } + } + return { blocked: false as const } +} + +function listParseFailureCandidates(files: Record) { + const paths: Array = [] + for (const [path, source] of Object.entries(files)) { + if (!scannableModuleFilePattern.test(path)) continue + if (isTypeDeclarationFilePath(path)) continue + if (!source.includes('kody:runtime') || !source.includes('storage')) + continue + if (parseProgram(source) == null) { + paths.push(path) + } + } + return paths.sort((left, right) => left.localeCompare(right)) +} + +function transformSourceFile(source: string): { + content: string + changed: boolean + needsManual: string | null +} { + const parsed = parseProgram(source) + if (!parsed) { + return { + content: source, + changed: false, + needsManual: manualParseFailureMessage, + } + } + const body = getProgramBody(parsed) + const imports = listRuntimeStorageImports(body) + if (imports.length === 0) { + return { content: source, changed: false, needsManual: null } + } + if (imports.length > 1) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const target = imports[0]! + if (target.hasUnusualSpecifiers) { + return { + content: source, + changed: false, + needsManual: manualAliasMessage, + } + } + if (target.storageSpecifiers.length !== 1) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + if (hasExportReexportOfStorage(body)) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const storageSpecifier = target.storageSpecifiers[0]! + if ( + typeof target.declaration.start !== 'number' || + typeof storageSpecifier.start !== 'number' || + typeof storageSpecifier.end !== 'number' + ) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const importStarts = new Set( + typeof target.declaration.start === 'number' + ? [target.declaration.start] + : [], + ) + if (hasStorageBindingSite(parsed, importStarts)) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const { memberObjects, nonMemberUses } = collectStorageMemberUseSites(parsed) + if (nonMemberUses) { + return { + content: source, + changed: false, + needsManual: manualNonMemberMessage, + } + } + if (memberObjects.length === 0) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + + let nextSource = applyRangeReplacements( + source, + memberObjects.map((range) => ({ + ...range, + text: 'packageStorage()', + })), + ) + + const reparsedAfterUses = parseProgram(nextSource) + if (!reparsedAfterUses) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const nextImports = listRuntimeStorageImports( + getProgramBody(reparsedAfterUses), + ) + const nextTarget = nextImports[0] + if (!nextTarget || nextTarget.storageSpecifiers.length !== 1) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const nextStorageSpecifier = nextTarget.storageSpecifiers[0]! + const alreadyHasPackageStorage = nextTarget.hasPackageStorage + const withImport = alreadyHasPackageStorage + ? removeSpecifierFromImportText({ + source: nextSource, + declaration: nextTarget.declaration, + specifier: nextStorageSpecifier, + }) + : replaceSpecifierNameInImportText({ + source: nextSource, + specifier: nextStorageSpecifier, + nextName: 'packageStorage', + }) + if (withImport == null) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + nextSource = withImport + + const verified = parseProgram(nextSource) + if (!verified) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + if (remainingStorageIdentifiers(verified).length > 0) { + return { + content: source, + changed: false, + needsManual: manualVerifyMessage, + } + } + if (listRuntimeStorageImports(getProgramBody(verified)).length > 0) { + return { + content: source, + changed: false, + needsManual: manualVerifyMessage, + } + } + if (nextSource === source) { + return { content: source, changed: false, needsManual: null } + } + return { content: nextSource, changed: true, needsManual: null } +} + +function detectAmbientStorage( + files: Record, +): Array { + const findings: Array = + collectAmbientStorageImportFiles(files).map((path) => ({ + path, + message: ambientStorageDetectMessage, + })) + for (const path of listParseFailureCandidates(files)) { + if (findings.some((finding) => finding.path === path)) continue + findings.push({ path, message: manualParseFailureMessage }) + } + findings.sort((left, right) => + (left.path ?? '').localeCompare(right.path ?? ''), + ) + return findings +} + +function transformAmbientStorage( + files: Record, +): PackageCodemodTransformResult { + const ambientPaths = collectAmbientStorageImportFiles(files) + const parseFailurePaths = listParseFailureCandidates(files) + const candidatePaths = [ + ...new Set([...ambientPaths, ...parseFailurePaths]), + ].sort((left, right) => left.localeCompare(right)) + + if (candidatePaths.length === 0) { + return { + files: { ...files }, + changed: false, + changedPaths: [], + needsManual: [], + } + } + + const surfaceGate = packageDeclaresNonExportExecutionSurface(files) + if (surfaceGate.blocked) { + return { + files: { ...files }, + changed: false, + changedPaths: [], + needsManual: candidatePaths.map((path) => ({ + path, + message: surfaceGate.message, + })), + } + } + + const nextFiles: Record = { ...files } + const changedPaths: Array = [] + const needsManual: Array = [] + for (const path of candidatePaths) { + const source = files[path] + if (typeof source !== 'string') continue + const result = transformSourceFile(source) + if (result.needsManual) { + needsManual.push({ path, message: result.needsManual }) + continue + } + if (!result.changed) continue + nextFiles[path] = result.content + changedPaths.push(path) + } + return { + files: nextFiles, + changed: changedPaths.length > 0, + changedPaths, + needsManual, + } +} + +export const ambientStorageToPackageStorageCodemod = { + id: ambientStorageToPackageStorageCodemodId, + description: + "Replace ambient `storage` member uses from 'kody:runtime' with `packageStorage()` call sites.", + detect: detectAmbientStorage, + transform: transformAmbientStorage, +} satisfies PackageCodemod diff --git a/packages/worker/src/package-codemods/engine.node.test.ts b/packages/worker/src/package-codemods/engine.node.test.ts new file mode 100644 index 0000000000..d4f94c2781 --- /dev/null +++ b/packages/worker/src/package-codemods/engine.node.test.ts @@ -0,0 +1,1206 @@ +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test, vi } from 'vitest' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import type * as RepoChecks from '#worker/repo/checks.ts' +import { + createPackageCodemodRun, + getPackageCodemodRunById, + getPackageCodemodRunItemById, + insertPackageCodemodRunItem, + listPackageCodemodRunItems, + packageCodemodLedgerTextBounds, +} from './ledger.ts' + +const mocks = vi.hoisted(() => ({ + listSavedPackagesByUserId: vi.fn(), + listSavedPackagesPage: vi.fn(), + loadPackageSourceBySourceId: vi.fn(), + syncArtifactSourceSnapshot: vi.fn(), + refreshSavedPackageProjection: vi.fn(), + resolveArtifactSourceHead: vi.fn(), + runRepoChecks: vi.fn(), + dispatchPackageCodemodSubscriptionEvent: vi.fn(), +})) + +vi.mock('#worker/package-registry/repo.ts', () => ({ + listSavedPackagesByUserId: (...args: Array) => + mocks.listSavedPackagesByUserId(...args), + listSavedPackagesPage: (...args: Array) => + mocks.listSavedPackagesPage(...args), +})) + +vi.mock('#worker/package-registry/source.ts', () => ({ + loadPackageSourceBySourceId: (...args: Array) => + mocks.loadPackageSourceBySourceId(...args), +})) + +vi.mock('#worker/repo/source-sync.ts', () => ({ + syncArtifactSourceSnapshot: (...args: Array) => + mocks.syncArtifactSourceSnapshot(...args), +})) + +vi.mock('#worker/package-registry/service.ts', () => ({ + refreshSavedPackageProjection: (...args: Array) => + mocks.refreshSavedPackageProjection(...args), +})) + +vi.mock('#worker/repo/artifacts.ts', () => ({ + resolveArtifactSourceHead: (...args: Array) => + mocks.resolveArtifactSourceHead(...args), +})) + +vi.mock('#worker/repo/checks.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runRepoChecks: (...args: Array) => mocks.runRepoChecks(...args), + } +}) + +vi.mock('./subscription-events.ts', () => ({ + packageCodemodAppliedTopic: 'package.codemod.applied', + packageCodemodRevertedTopic: 'package.codemod.reverted', + createPackageCodemodSubscriptionCache: () => ({ + load: async () => ({ subscriptions: [], discoveryErrors: [] }), + }), + dispatchPackageCodemodSubscriptionEvent: (...args: Array) => + mocks.dispatchPackageCodemodSubscriptionEvent(...args), +})) + +const { buildPackageCodemodRevertSnapshotKvKey, runPackageCodemodStep } = + await import('./engine.ts') + +const codemodId = '0001-ambient-storage-to-package-storage' + +function createKv() { + const store = new Map() + return { + store, + namespace: { + async get(key: string) { + return store.get(key)?.value ?? null + }, + async put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ) { + store.set(key, { value, expirationTtl: options?.expirationTtl }) + }, + async delete(key: string) { + store.delete(key) + }, + } as unknown as KVNamespace, + } +} + +function createEngineDb() { + const sqlite = new DatabaseSync(':memory:') + sqlite.exec( + readFileSync( + new URL( + '../../migrations/0111-package-codemod-ledger.sql', + import.meta.url, + ), + 'utf8', + ), + ) + return createD1FromSqlite(sqlite) +} + +function createEnv() { + const kv = createKv() + return { + env: { + APP_DB: createEngineDb(), + BUNDLE_ARTIFACTS_KV: kv.namespace, + APP_BASE_URL: 'https://example.com', + } as Env, + kv, + } +} + +function savedPackage(input: { + id: string + userId: string + kodyId: string + sourceId: string +}) { + return { + id: input.id, + userId: input.userId, + name: `@${input.userId}/${input.kodyId}`, + kodyId: input.kodyId, + description: input.kodyId, + tags: [], + searchText: null, + sourceId: input.sourceId, + hasApp: false, + hidden: false, + isPrivate: true, + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + } +} + +function ambientFiles() { + return { + 'package.json': `${JSON.stringify( + { + name: '@user/demo', + exports: { '.': './index.ts' }, + kody: { id: 'demo', description: 'Demo package for codemod tests.' }, + }, + null, + '\t', + )}\n`, + 'index.ts': + "import { storage } from 'kody:runtime'\nexport async function run() {\n\treturn storage.get('k')\n}\n", + } +} + +function cleanFiles() { + return { + 'package.json': `${JSON.stringify( + { + name: '@user/clean', + exports: { '.': './index.ts' }, + kody: { id: 'clean', description: 'Clean package for codemod tests.' }, + }, + null, + '\t', + )}\n`, + 'index.ts': + "import { packageStorage } from 'kody:runtime'\nexport async function run() {\n\treturn packageStorage().get('k')\n}\n", + } +} + +function loadedSource(input: { + files: Record + publishedCommit: string | null + repoId?: string + sourceId?: string + userId?: string +}) { + return { + source: { + id: input.sourceId ?? 'source-1', + user_id: input.userId ?? 'user-1', + entity_kind: 'package', + repo_id: input.repoId ?? 'repo-1', + published_commit: input.publishedCommit, + indexed_commit: input.publishedCommit, + manifest_path: 'package.json', + source_root: '/', + created_at: '2026-07-30T00:00:00.000Z', + updated_at: '2026-07-30T00:00:00.000Z', + }, + files: input.files, + manifest: {}, + } +} + +function resetMocks() { + mocks.listSavedPackagesByUserId.mockReset() + mocks.listSavedPackagesPage.mockReset() + mocks.loadPackageSourceBySourceId.mockReset() + mocks.syncArtifactSourceSnapshot.mockReset() + mocks.refreshSavedPackageProjection.mockReset() + mocks.resolveArtifactSourceHead.mockReset() + mocks.runRepoChecks.mockReset() + mocks.dispatchPackageCodemodSubscriptionEvent.mockReset() + mocks.refreshSavedPackageProjection.mockResolvedValue(undefined) + mocks.dispatchPackageCodemodSubscriptionEvent.mockResolvedValue([]) + mocks.resolveArtifactSourceHead.mockImplementation( + async (_env: Env, repoId: string) => ({ + branch: 'main', + commit: `commit-${repoId}`, + }), + ) + mocks.runRepoChecks.mockResolvedValue({ + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + manifest: {}, + sourceFiles: {}, + }) + mocks.syncArtifactSourceSnapshot.mockImplementation( + async (input: { files: Record }) => { + const marker = input.files['index.ts']?.includes('packageStorage()') + ? 'after' + : 'reverted' + return `commit-${marker}` + }, + ) +} + +test('package codemod engine covers lifecycle, drift, isolation, snapshot keys, and gates', async () => { + resetMocks() + const { env, kv } = createEnv() + + const pkgAmbient = savedPackage({ + id: 'pkg-ambient', + userId: 'user-1', + kodyId: 'ambient', + sourceId: 'source-ambient', + }) + const pkgClean = savedPackage({ + id: 'pkg-clean', + userId: 'user-1', + kodyId: 'clean', + sourceId: 'source-clean', + }) + const pkgDrift = savedPackage({ + id: 'pkg-drift', + userId: 'user-1', + kodyId: 'drift', + sourceId: 'source-drift', + }) + const pkgUnpublished = savedPackage({ + id: 'pkg-unpublished', + userId: 'user-1', + kodyId: 'unpublished', + sourceId: 'source-unpublished', + }) + const pkgFail = savedPackage({ + id: 'pkg-fail', + userId: 'user-1', + kodyId: 'fail', + sourceId: 'source-fail', + }) + const pkgOtherUser = savedPackage({ + id: 'pkg-other', + userId: 'user-2', + kodyId: 'other', + sourceId: 'source-other', + }) + + mocks.listSavedPackagesByUserId.mockImplementation( + async (_db: D1Database, input: { userId: string }) => { + if (input.userId === 'user-1') { + return [pkgAmbient, pkgClean, pkgDrift, pkgUnpublished, pkgFail] + } + if (input.userId === 'user-2') { + return [pkgOtherUser] + } + return [] + }, + ) + mocks.listSavedPackagesPage.mockResolvedValue([]) + + mocks.loadPackageSourceBySourceId.mockImplementation( + async (input: { sourceId: string; userId: string }) => { + if (input.sourceId === 'source-fail') { + throw new Error('source boom') + } + if (input.sourceId === 'source-unpublished') { + return loadedSource({ + files: ambientFiles(), + publishedCommit: null, + repoId: 'repo-unpublished', + sourceId: input.sourceId, + userId: input.userId, + }) + } + if (input.sourceId === 'source-drift') { + return loadedSource({ + files: ambientFiles(), + publishedCommit: 'commit-published-old', + repoId: 'repo-drift', + sourceId: input.sourceId, + userId: input.userId, + }) + } + if (input.sourceId === 'source-clean') { + return loadedSource({ + files: cleanFiles(), + publishedCommit: 'commit-repo-clean', + repoId: 'repo-clean', + sourceId: input.sourceId, + userId: input.userId, + }) + } + if (input.sourceId === 'source-other') { + return loadedSource({ + files: ambientFiles(), + publishedCommit: 'commit-repo-other', + repoId: 'repo-other', + sourceId: input.sourceId, + userId: input.userId, + }) + } + return loadedSource({ + files: ambientFiles(), + publishedCommit: 'commit-repo-ambient', + repoId: 'repo-ambient', + sourceId: input.sourceId, + userId: input.userId, + }) + }, + ) + + mocks.resolveArtifactSourceHead.mockImplementation( + async (_env: Env, repoId: string) => { + if (repoId === 'repo-drift') { + return { branch: 'main', commit: 'commit-head-moved' } + } + return { branch: 'main', commit: `commit-${repoId}` } + }, + ) + + const scan = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + limit: 50, + }) + expect(scan.nextCursor).toBeNull() + expect(scan.summary).toMatchObject({ + detected: 1, + clean: 1, + skipped_drift: 1, + skipped_unpublished: 1, + failed: 1, + }) + + mocks.runRepoChecks.mockImplementation( + async (input: { + workspace: { readFile(path: string): Promise } + }) => { + const index = await input.workspace.readFile('index.ts') + const hasAmbient = + typeof index === 'string' && + /import\s*\{[^}]*\bstorage\b/.test(index) && + index.includes("from 'kody:runtime'") + if (hasAmbient) { + return { + ok: false, + results: [ + { kind: 'lint', ok: false, message: 'ambient storage line 12' }, + ], + manifest: {}, + sourceFiles: {}, + } + } + return { + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + manifest: {}, + sourceFiles: {}, + } + }, + ) + + const dryRun = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'dry-run', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-ambient', 'pkg-clean'] }, + limit: 10, + }) + expect(dryRun.summary).toMatchObject({ + dry_run_ok: 1, + clean: 1, + }) + expect(mocks.syncArtifactSourceSnapshot).not.toHaveBeenCalled() + + mocks.runRepoChecks.mockImplementation( + async (input: { + workspace: { readFile(path: string): Promise } + }) => { + const index = await input.workspace.readFile('index.ts') + const transformed = + typeof index === 'string' && index.includes('packageStorage().get') + if (transformed) { + return { + ok: false, + results: [ + { + kind: 'lint', + ok: false, + message: 'new failure only after transform', + }, + ], + manifest: {}, + sourceFiles: {}, + } + } + return { + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + manifest: {}, + sourceFiles: {}, + } + }, + ) + const gated = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-ambient'] }, + limit: 10, + }) + expect(gated.items[0]?.status).toBe('dry_run_new_failures') + expect(mocks.syncArtifactSourceSnapshot).not.toHaveBeenCalled() + + mocks.runRepoChecks.mockImplementation( + async (input: { + workspace: { readFile(path: string): Promise } + }) => { + const index = await input.workspace.readFile('index.ts') + const hasAmbient = + typeof index === 'string' && + /import\s*\{[^}]*\bstorage\b/.test(index) && + !index.includes('packageStorage') + if (hasAmbient) { + return { + ok: false, + results: [ + { kind: 'lint', ok: false, message: 'ambient storage line 12' }, + ], + manifest: {}, + sourceFiles: {}, + } + } + return { + ok: false, + results: [ + { kind: 'lint', ok: false, message: 'ambient storage line 40' }, + ], + manifest: {}, + sourceFiles: {}, + } + }, + ) + + const apply = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-ambient'] }, + limit: 10, + }) + expect(apply.items[0]).toMatchObject({ + status: 'applied', + packageId: 'pkg-ambient', + beforeCommit: 'commit-repo-ambient', + afterCommit: 'commit-after', + }) + const applyItemId = apply.items[0]!.itemId + const revertKey = buildPackageCodemodRevertSnapshotKvKey({ + userId: 'user-1', + itemId: applyItemId, + }) + expect(revertKey).toBe(`package-codemod-revert:user-1:${applyItemId}`) + const stored = kv.store.get(revertKey) + expect(stored?.expirationTtl).toBe(90 * 24 * 60 * 60) + const ledgerItem = await getPackageCodemodRunItemById(env.APP_DB, applyItemId) + expect(ledgerItem?.revertSnapshotKey).toBe(revertKey) + expect(mocks.dispatchPackageCodemodSubscriptionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'package.codemod.applied', + subscriptionCache: expect.anything(), + }), + ) + + mocks.resolveArtifactSourceHead.mockImplementation( + async (_env: Env, repoId: string) => { + if (repoId === 'repo-ambient') { + return { branch: 'main', commit: 'commit-after' } + } + if (repoId === 'repo-drift') { + return { branch: 'main', commit: 'commit-head-moved' } + } + return { branch: 'main', commit: `commit-${repoId}` } + }, + ) + + const revert = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: apply.runId, + limit: 10, + }) + expect(revert.items[0]?.status).toBe('reverted') + const sourceAfterRevert = await getPackageCodemodRunItemById( + env.APP_DB, + applyItemId, + ) + expect(sourceAfterRevert?.status).toBe('reverted') + + const secondRevert = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: apply.runId, + limit: 10, + }) + expect(secondRevert.items).toEqual([]) + + const user2CannotRevertUser1 = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-2', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-2' }, + revertOfRunId: apply.runId, + limit: 10, + }) + expect(user2CannotRevertUser1.items).toEqual([]) +}) + +test('package codemod engine enforces resume scope, binary paging, fleet progress, and publish failure id reuse', async () => { + resetMocks() + const { env, kv } = createEnv() + + const pkgA = savedPackage({ + id: 'a', + userId: 'user-1', + kodyId: 'a', + sourceId: 'source-a', + }) + const pkgB = savedPackage({ + id: 'B', + userId: 'user-1', + kodyId: 'b', + sourceId: 'source-b', + }) + mocks.listSavedPackagesByUserId.mockResolvedValue([pkgA, pkgB]) + mocks.loadPackageSourceBySourceId.mockImplementation( + async (input: { sourceId: string; userId: string }) => + loadedSource({ + files: cleanFiles(), + publishedCommit: `commit-${input.sourceId}`, + repoId: input.sourceId, + sourceId: input.sourceId, + userId: input.userId, + }), + ) + mocks.resolveArtifactSourceHead.mockImplementation( + async (_env: Env, repoId: string) => ({ + branch: 'main', + commit: `commit-${repoId}`, + }), + ) + + const firstPage = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + limit: 1, + }) + expect(firstPage.items.map((item) => item.packageId)).toEqual(['B']) + expect(firstPage.nextCursor).toBe('B') + const secondPage = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + runId: firstPage.runId, + cursor: firstPage.nextCursor, + limit: 1, + }) + expect(secondPage.items.map((item) => item.packageId)).toEqual(['a']) + expect(secondPage.nextCursor).toBeNull() + + await expect( + runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'fleet' }, + runId: firstPage.runId, + limit: 1, + }), + ).rejects.toThrow(/scope does not match/i) + + mocks.listSavedPackagesPage.mockImplementation( + async ( + _db: D1Database, + input: { afterId: string | null; limit: number }, + ) => { + const start = input.afterId + ? Number(input.afterId.split('-').at(-1)) + 1 + : 1 + if (start > 250) return [] + return Array.from({ length: input.limit }, (_, index) => { + const n = start + index + return savedPackage({ + id: `fleet-${String(n).padStart(4, '0')}`, + userId: 'user-9', + kodyId: `nope-${n}`, + sourceId: `source-fleet-${n}`, + }) + }) + }, + ) + const fleetFiltered = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'admin-1', + codemodId, + mode: 'scan', + scope: { kind: 'fleet' }, + filters: { packageIds: ['never-match'] }, + limit: 5, + }) + expect(fleetFiltered.items).toEqual([]) + expect(fleetFiltered.nextCursor).toBe('fleet-0250') + expect(mocks.listSavedPackagesPage.mock.calls.length).toBe(5) + + const pkgPublish = savedPackage({ + id: 'pkg-publish', + userId: 'user-1', + kodyId: 'publish', + sourceId: 'source-publish', + }) + mocks.listSavedPackagesByUserId.mockResolvedValue([pkgPublish]) + mocks.loadPackageSourceBySourceId.mockResolvedValue( + loadedSource({ + files: ambientFiles(), + publishedCommit: 'commit-repo-publish', + repoId: 'repo-publish', + sourceId: 'source-publish', + userId: 'user-1', + }), + ) + mocks.resolveArtifactSourceHead.mockResolvedValue({ + branch: 'main', + commit: 'commit-repo-publish', + }) + mocks.runRepoChecks.mockResolvedValue({ + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + manifest: {}, + sourceFiles: {}, + }) + mocks.syncArtifactSourceSnapshot.mockRejectedValueOnce( + new Error('publish exploded'), + ) + const failedPublish = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-publish'] }, + limit: 10, + }) + expect(failedPublish.items).toHaveLength(1) + expect(failedPublish.items[0]?.status).toBe('failed') + expect(failedPublish.items[0]?.error).toMatch(/repo HEAD may be ahead/i) + const failedItem = await getPackageCodemodRunItemById( + env.APP_DB, + failedPublish.items[0]!.itemId, + ) + expect(failedItem?.revertSnapshotKey).toBe( + buildPackageCodemodRevertSnapshotKvKey({ + userId: 'user-1', + itemId: failedPublish.items[0]!.itemId, + }), + ) + expect(kv.store.has(failedItem!.revertSnapshotKey!)).toBe(true) + + let headCalls = 0 + mocks.syncArtifactSourceSnapshot.mockReset() + mocks.syncArtifactSourceSnapshot.mockResolvedValue('commit-after') + mocks.resolveArtifactSourceHead.mockImplementation(async () => { + headCalls += 1 + if (headCalls >= 2) { + return { branch: 'main', commit: 'commit-moved-before-publish' } + } + return { branch: 'main', commit: 'commit-repo-publish' } + }) + const driftBeforePublish = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-publish'] }, + limit: 10, + }) + expect(driftBeforePublish.items[0]?.status).toBe('skipped_drift') + expect(mocks.syncArtifactSourceSnapshot).not.toHaveBeenCalled() +}) + +test('package codemod revert skips when HEAD no longer matches applied afterCommit', async () => { + resetMocks() + const { env } = createEnv() + const pkg = savedPackage({ + id: 'pkg-revert-drift', + userId: 'user-1', + kodyId: 'revert-drift', + sourceId: 'source-revert-drift', + }) + mocks.listSavedPackagesByUserId.mockResolvedValue([pkg]) + mocks.loadPackageSourceBySourceId.mockResolvedValue( + loadedSource({ + files: ambientFiles(), + publishedCommit: 'commit-repo-revert-drift', + repoId: 'repo-revert-drift', + sourceId: 'source-revert-drift', + userId: 'user-1', + }), + ) + mocks.resolveArtifactSourceHead.mockResolvedValue({ + branch: 'main', + commit: 'commit-repo-revert-drift', + }) + mocks.runRepoChecks.mockResolvedValue({ + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + manifest: {}, + sourceFiles: {}, + }) + mocks.syncArtifactSourceSnapshot.mockResolvedValue('commit-after-apply') + + const apply = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-1' }, + limit: 10, + }) + expect(apply.items[0]?.status).toBe('applied') + + mocks.resolveArtifactSourceHead.mockResolvedValue({ + branch: 'main', + commit: 'commit-user-moved-head', + }) + const revert = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: apply.runId, + limit: 10, + }) + expect(revert.items[0]?.status).toBe('skipped_drift') + expect(mocks.syncArtifactSourceSnapshot).toHaveBeenCalledTimes(1) +}) + +test('package codemod ledger bounds stored JSON/text columns', async () => { + const sqlite = new DatabaseSync(':memory:') + sqlite.exec( + readFileSync( + new URL( + '../../migrations/0111-package-codemod-ledger.sql', + import.meta.url, + ), + 'utf8', + ), + ) + const db = createD1FromSqlite(sqlite) + const { createPackageCodemodRun, insertPackageCodemodRunItem } = + await import('./ledger.ts') + await createPackageCodemodRun(db, { + id: 'run-bound', + codemodId, + mode: 'scan', + scopeUserId: 'user-1', + initiatedByUserId: 'admin', + }) + const hugePaths = Array.from( + { length: 500 }, + (_, index) => `path-${index}-${'x'.repeat(200)}`, + ) + const hugeFindings = Array.from({ length: 200 }, (_, index) => ({ + path: `file-${index}.ts`, + message: 'm'.repeat(2_000), + })) + const item = await insertPackageCodemodRunItem(db, { + id: 'item-bound', + runId: 'run-bound', + userId: 'user-1', + packageId: 'pkg-1', + kodyId: 'one', + status: 'detected', + changedPaths: hugePaths, + findings: hugeFindings, + checkSummaryJson: JSON.stringify({ + ok: false, + newFailures: ['x'.repeat(100_000)], + }), + error: 'e'.repeat(100_000), + revertSnapshotKey: 'package-codemod-revert:user-1:item-bound', + }) + expect( + new TextEncoder().encode(JSON.stringify(item.changedPaths)).byteLength, + ).toBeLessThanOrEqual( + packageCodemodLedgerTextBounds.maxRestorableTextColumnBytes, + ) + expect( + new TextEncoder().encode(JSON.stringify(item.findings)).byteLength, + ).toBeLessThanOrEqual( + packageCodemodLedgerTextBounds.maxRestorableTextColumnBytes, + ) + expect(item.error?.includes('[truncated]')).toBe(true) + expect(item.revertSnapshotKey).toBe( + 'package-codemod-revert:user-1:item-bound', + ) + const listed = await listPackageCodemodRunItems(db, { + runId: 'run-bound', + limit: 10, + }) + expect(listed[0]?.revertSnapshotKey).toBe( + 'package-codemod-revert:user-1:item-bound', + ) + expect(await getPackageCodemodRunById(db, 'run-bound')).toMatchObject({ + id: 'run-bound', + }) +}) + +test('package codemod engine rejects resume steps with mismatched filters', async () => { + resetMocks() + const { env } = createEnv() + mocks.listSavedPackagesByUserId.mockResolvedValue([ + savedPackage({ + id: 'pkg-a', + userId: 'user-1', + kodyId: 'a', + sourceId: 'source-a', + }), + ]) + mocks.loadPackageSourceBySourceId.mockResolvedValue( + loadedSource({ + files: cleanFiles(), + publishedCommit: 'commit-a', + repoId: 'repo-a', + sourceId: 'source-a', + userId: 'user-1', + }), + ) + + const first = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'admin-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-a', 'pkg-b'] }, + limit: 10, + }) + expect(first.runId).toBeTruthy() + + await expect( + runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'admin-2', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + runId: first.runId, + filters: { packageIds: ['pkg-other'] }, + limit: 10, + }), + ).rejects.toThrow(/filters do not match/i) + + const resumed = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'admin-2', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + runId: first.runId, + filters: { packageIds: ['pkg-b', 'pkg-a'] }, + limit: 10, + }) + expect(resumed.runId).toBe(first.runId) +}) + +test('package codemod revert page ceiling and user-scoped SQL filter for sparse ownership', async () => { + resetMocks() + const { env } = createEnv() + + await createPackageCodemodRun(env.APP_DB, { + id: 'prior-fleet', + codemodId, + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + status: 'completed', + }) + + for (let index = 0; index < 40; index += 1) { + await insertPackageCodemodRunItem(env.APP_DB, { + id: `other-${String(index).padStart(4, '0')}`, + runId: 'prior-fleet', + userId: 'user-other', + packageId: `pkg-other-${index}`, + kodyId: `other-${index}`, + status: 'applied', + beforeCommit: 'before', + afterCommit: 'after', + }) + } + await insertPackageCodemodRunItem(env.APP_DB, { + id: 'mine-0001', + runId: 'prior-fleet', + userId: 'user-1', + packageId: 'pkg-mine-1', + kodyId: 'mine-1', + status: 'applied', + beforeCommit: 'before', + afterCommit: 'after', + }) + await insertPackageCodemodRunItem(env.APP_DB, { + id: 'mine-0002', + runId: 'prior-fleet', + userId: 'user-1', + packageId: 'pkg-mine-2', + kodyId: 'mine-2', + status: 'applied', + beforeCommit: 'before', + afterCommit: 'after', + }) + + const sparse = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: 'prior-fleet', + limit: 10, + }) + expect(sparse.items).toHaveLength(2) + expect(sparse.items.every((item) => item.userId === 'user-1')).toBe(true) + expect(sparse.nextCursor).toBeNull() + expect( + await getPackageCodemodRunById(env.APP_DB, sparse.runId), + ).toMatchObject({ + status: 'completed', + }) + + await createPackageCodemodRun(env.APP_DB, { + id: 'prior-dense', + codemodId, + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + status: 'completed', + }) + // More applied rows than one heavy step can finish (step limit caps at 10) + // so the revert must return a cursor instead of completing the run. + for (let index = 0; index < 30; index += 1) { + await insertPackageCodemodRunItem(env.APP_DB, { + id: `dense-${String(index).padStart(4, '0')}`, + runId: 'prior-dense', + userId: 'user-1', + packageId: `pkg-dense-${index}`, + kodyId: `dense-${index}`, + status: 'applied', + beforeCommit: 'before', + afterCommit: 'after', + }) + } + + const paged = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'revert', + scope: { kind: 'user', userId: 'user-1' }, + revertOfRunId: 'prior-dense', + limit: 10, + }) + expect(paged.items).toHaveLength(10) + expect(paged.nextCursor).toBe('dense-0009') + expect(await getPackageCodemodRunById(env.APP_DB, paged.runId)).toMatchObject( + { + status: 'running', + }, + ) +}) + +test('package codemod fleet revert applies packageIds filters and leaves others applied', async () => { + resetMocks() + const { env, kv } = createEnv() + + await createPackageCodemodRun(env.APP_DB, { + id: 'prior-canary-apply', + codemodId, + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + status: 'completed', + }) + + const priorItems = [ + { + id: 'item-pkg-keep-a', + userId: 'user-1', + packageId: 'pkg-keep-a', + kodyId: 'keep-a', + sourceId: 'source-keep-a', + repoId: 'repo-keep-a', + }, + { + id: 'item-pkg-revert', + userId: 'user-1', + packageId: 'pkg-revert-me', + kodyId: 'revert-me', + sourceId: 'source-revert', + repoId: 'repo-revert', + }, + { + id: 'item-pkg-keep-b', + userId: 'user-2', + packageId: 'pkg-keep-b', + kodyId: 'keep-b', + sourceId: 'source-keep-b', + repoId: 'repo-keep-b', + }, + ] as const + + for (const prior of priorItems) { + const revertSnapshotKey = buildPackageCodemodRevertSnapshotKvKey({ + userId: prior.userId, + itemId: prior.id, + }) + await insertPackageCodemodRunItem(env.APP_DB, { + id: prior.id, + runId: 'prior-canary-apply', + userId: prior.userId, + packageId: prior.packageId, + kodyId: prior.kodyId, + status: 'applied', + beforeCommit: `commit-${prior.repoId}-before`, + afterCommit: `commit-${prior.repoId}-after`, + changedPaths: ['index.ts'], + revertSnapshotKey, + }) + await kv.namespace.put( + revertSnapshotKey, + JSON.stringify({ + codemodId, + userId: prior.userId, + packageId: prior.packageId, + beforeCommit: `commit-${prior.repoId}-before`, + files: cleanFiles(), + }), + ) + } + + mocks.listSavedPackagesByUserId.mockImplementation( + async (_db: D1Database, input: { userId: string }) => + priorItems + .filter((prior) => prior.userId === input.userId) + .map((prior) => + savedPackage({ + id: prior.packageId, + userId: prior.userId, + kodyId: prior.kodyId, + sourceId: prior.sourceId, + }), + ), + ) + mocks.loadPackageSourceBySourceId.mockImplementation( + async (input: { sourceId: string; userId: string }) => { + const prior = priorItems.find( + (candidate) => candidate.sourceId === input.sourceId, + ) + if (!prior) throw new Error(`unexpected source ${input.sourceId}`) + return loadedSource({ + files: cleanFiles(), + publishedCommit: `commit-${prior.repoId}-after`, + repoId: prior.repoId, + sourceId: prior.sourceId, + userId: prior.userId, + }) + }, + ) + mocks.resolveArtifactSourceHead.mockImplementation( + async (_env: Env, repoId: string) => ({ + branch: 'main', + commit: `commit-${repoId}-after`, + }), + ) + mocks.syncArtifactSourceSnapshot.mockResolvedValue('commit-reverted') + + const revert = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'admin-1', + codemodId, + mode: 'revert', + scope: { kind: 'fleet' }, + filters: { packageIds: ['pkg-revert-me'] }, + revertOfRunId: 'prior-canary-apply', + limit: 10, + }) + expect(revert.items).toHaveLength(1) + expect(revert.items[0]).toMatchObject({ + packageId: 'pkg-revert-me', + status: 'reverted', + }) + expect(mocks.syncArtifactSourceSnapshot).toHaveBeenCalledTimes(1) + + expect( + await getPackageCodemodRunItemById(env.APP_DB, 'item-pkg-revert'), + ).toMatchObject({ status: 'reverted' }) + expect( + await getPackageCodemodRunItemById(env.APP_DB, 'item-pkg-keep-a'), + ).toMatchObject({ status: 'applied' }) + expect( + await getPackageCodemodRunItemById(env.APP_DB, 'item-pkg-keep-b'), + ).toMatchObject({ status: 'applied' }) +}) + +test('package codemod step rejects a cursor without a runId', async () => { + resetMocks() + const { env } = createEnv() + mocks.listSavedPackagesByUserId.mockResolvedValue([]) + + await expect( + runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + cursor: 'pkg-somewhere', + limit: 50, + }), + ).rejects.toThrow('cursor requires runId') +}) diff --git a/packages/worker/src/package-codemods/engine.ts b/packages/worker/src/package-codemods/engine.ts new file mode 100644 index 0000000000..d23f8dcec6 --- /dev/null +++ b/packages/worker/src/package-codemods/engine.ts @@ -0,0 +1,1245 @@ +import { getErrorMessage } from '@kody-internal/shared/error-message.ts' +import { + listSavedPackagesByUserId, + listSavedPackagesPage, +} from '#worker/package-registry/repo.ts' +import { refreshSavedPackageProjection } from '#worker/package-registry/service.ts' +import { loadPackageSourceBySourceId } from '#worker/package-registry/source.ts' +import { type SavedPackageRecord } from '#worker/package-registry/types.ts' +import { resolveArtifactSourceHead } from '#worker/repo/artifacts.ts' +import { runRepoChecks, type RepoCheckRunResult } from '#worker/repo/checks.ts' +import { normalizeRepoWorkspacePath } from '#worker/repo/manifest.ts' +import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' +import { + createPackageCodemodRun, + getPackageCodemodRunById, + insertPackageCodemodRunItem, + listPackageCodemodRunItems, + updatePackageCodemodRunItem, + updatePackageCodemodRunStatus, + type PackageCodemodRunRecord, +} from './ledger.ts' +import { getPackageCodemodById } from './registry.ts' +import { + createPackageCodemodSubscriptionCache, + dispatchPackageCodemodSubscriptionEvent, + packageCodemodAppliedTopic, + packageCodemodRevertedTopic, +} from './subscription-events.ts' +import { type PackageCodemod, type PackageCodemodFinding } from './types.ts' + +export type PackageCodemodRunMode = 'scan' | 'dry-run' | 'apply' | 'revert' + +export type PackageCodemodRunScope = + | { kind: 'user'; userId: string } + | { kind: 'fleet' } + +export type PackageCodemodRunFilters = { + userIds?: Array + packageIds?: Array +} + +export type PackageCodemodItemStatus = + | 'detected' + | 'clean' + | 'dry_run_ok' + | 'dry_run_new_failures' + | 'needs_manual' + | 'skipped_drift' + | 'skipped_unpublished' + | 'applied' + | 'reverted' + | 'failed' + +export type PackageCodemodRunItemResult = { + itemId: string + userId: string + packageId: string + kodyId: string + status: PackageCodemodItemStatus + changedPaths: Array + findings: Array + beforeCommit: string | null + afterCommit: string | null + checkSummary: { ok: boolean; newFailures: Array } | null + error: string | null +} + +export type PackageCodemodRunStepResult = { + runId: string + codemodId: string + mode: PackageCodemodRunMode + items: Array + nextCursor: string | null + summary: Partial> +} + +type PersistedItemResult = PackageCodemodRunItemResult & { + revertSnapshotKey?: string | null +} + +const scanDefaultStepLimit = 20 +const scanMaxStepLimit = 50 +const heavyDefaultStepLimit = 5 +const heavyMaxStepLimit = 10 +const fleetScanPageSize = 50 +const maxFleetPagesPerStep = 5 +const revertSnapshotTtlSeconds = 90 * 24 * 60 * 60 + +type CodemodRevertSnapshot = { + codemodId: string + userId: string + packageId: string + beforeCommit: string | null + files: Record +} + +export function buildPackageCodemodRevertSnapshotKvKey(input: { + userId: string + itemId: string +}) { + return `package-codemod-revert:${input.userId}:${input.itemId}` +} + +function compareBinaryIds(left: string, right: string) { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function resolveStepLimit(mode: PackageCodemodRunMode, limit?: number) { + if (mode === 'scan') { + return Math.min( + Math.max(limit ?? scanDefaultStepLimit, 1), + scanMaxStepLimit, + ) + } + return Math.min( + Math.max(limit ?? heavyDefaultStepLimit, 1), + heavyMaxStepLimit, + ) +} + +function createSnapshotFilesWorkspace(files: Record) { + return { + async readFile(path: string) { + return files[normalizeRepoWorkspacePath(path)] ?? null + }, + async glob(_pattern: string) { + return Object.keys(files).map((path) => ({ + path, + type: 'file' as const, + })) + }, + } +} + +function normalizeFailureMessage(message: string) { + return message.replace(/\d+/g, '#') +} + +function failureKeys(result: RepoCheckRunResult): Set { + const keys = new Set() + for (const check of result.results) { + if (check.ok) continue + keys.add(`${check.kind}:${normalizeFailureMessage(check.message)}`) + } + return keys +} + +function computeCheckSummary(input: { + before: RepoCheckRunResult + after: RepoCheckRunResult +}) { + const beforeFailures = failureKeys(input.before) + const newFailures: Array = [] + for (const key of failureKeys(input.after)) { + if (!beforeFailures.has(key)) { + newFailures.push(key) + } + } + newFailures.sort((left, right) => left.localeCompare(right)) + return { + ok: newFailures.length === 0, + newFailures, + } +} + +function matchesCodemodTargetFilters( + target: { userId: string; packageId: string }, + filters: PackageCodemodRunFilters | undefined, +) { + if (!filters) return true + if ( + filters.userIds != null && + filters.userIds.length > 0 && + !filters.userIds.includes(target.userId) + ) { + return false + } + if ( + filters.packageIds != null && + filters.packageIds.length > 0 && + !filters.packageIds.includes(target.packageId) + ) { + return false + } + return true +} + +function matchesFilters( + savedPackage: SavedPackageRecord, + filters: PackageCodemodRunFilters | undefined, +) { + return matchesCodemodTargetFilters( + { userId: savedPackage.userId, packageId: savedPackage.id }, + filters, + ) +} + +function emptyItemResult(input: { + itemId: string + userId: string + packageId: string + kodyId: string + status: PackageCodemodItemStatus + error?: string | null + findings?: Array + changedPaths?: Array + beforeCommit?: string | null + afterCommit?: string | null + checkSummary?: PackageCodemodRunItemResult['checkSummary'] + revertSnapshotKey?: string | null +}): PersistedItemResult { + return { + itemId: input.itemId, + userId: input.userId, + packageId: input.packageId, + kodyId: input.kodyId, + status: input.status, + changedPaths: input.changedPaths ?? [], + findings: input.findings ?? [], + beforeCommit: input.beforeCommit ?? null, + afterCommit: input.afterCommit ?? null, + checkSummary: input.checkSummary ?? null, + error: input.error ?? null, + revertSnapshotKey: input.revertSnapshotKey ?? null, + } +} + +function toPublicItem(item: PersistedItemResult): PackageCodemodRunItemResult { + return { + itemId: item.itemId, + userId: item.userId, + packageId: item.packageId, + kodyId: item.kodyId, + status: item.status, + changedPaths: item.changedPaths, + findings: item.findings, + beforeCommit: item.beforeCommit, + afterCommit: item.afterCommit, + checkSummary: item.checkSummary, + error: item.error, + } +} + +function incrementSummary( + summary: Partial>, + status: PackageCodemodItemStatus, +) { + summary[status] = (summary[status] ?? 0) + 1 +} + +function requestedScopeUserId(scope: PackageCodemodRunScope) { + switch (scope.kind) { + case 'user': + return scope.userId + case 'fleet': + return null + default: { + const exhaustive: never = scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + } +} + +function normalizeFiltersJson(filters?: PackageCodemodRunFilters | null) { + const userIds = [...(filters?.userIds ?? [])].sort((left, right) => + compareBinaryIds(left, right), + ) + const packageIds = [...(filters?.packageIds ?? [])].sort((left, right) => + compareBinaryIds(left, right), + ) + const normalized: PackageCodemodRunFilters = {} + if (userIds.length > 0) normalized.userIds = userIds + if (packageIds.length > 0) normalized.packageIds = packageIds + return JSON.stringify(normalized) +} + +function parseStoredFiltersJson(filtersJson: string): PackageCodemodRunFilters { + try { + const parsed: unknown = JSON.parse(filtersJson) + if (!parsed || typeof parsed !== 'object') return {} + const record = parsed as Record + const filters: PackageCodemodRunFilters = {} + if (Array.isArray(record.userIds)) { + filters.userIds = record.userIds.filter( + (value): value is string => typeof value === 'string', + ) + } + if (Array.isArray(record.packageIds)) { + filters.packageIds = record.packageIds.filter( + (value): value is string => typeof value === 'string', + ) + } + return filters + } catch { + return {} + } +} + +async function ensureRun(input: { + env: Env + runId?: string + codemodId: string + mode: PackageCodemodRunMode + scope: PackageCodemodRunScope + initiatedByUserId: string + filters?: PackageCodemodRunFilters + revertOfRunId?: string +}): Promise { + const scopeUserId = requestedScopeUserId(input.scope) + const filtersJson = normalizeFiltersJson(input.filters) + if (input.runId) { + const existing = await getPackageCodemodRunById( + input.env.APP_DB, + input.runId, + ) + if (!existing) { + throw new Error(`Package codemod run "${input.runId}" was not found.`) + } + if (existing.codemodId !== input.codemodId) { + throw new Error( + `Package codemod run "${input.runId}" belongs to codemod "${existing.codemodId}", not "${input.codemodId}".`, + ) + } + if (existing.mode !== input.mode) { + throw new Error( + `Package codemod run "${input.runId}" is mode "${existing.mode}", not "${input.mode}".`, + ) + } + if (existing.scopeUserId !== scopeUserId) { + throw new Error( + `Package codemod run "${input.runId}" scope does not match the requested scope.`, + ) + } + // Continuation steps may omit revertOfRunId; the stored run is the + // source of truth. Only an explicit mismatched value is rejected. + if ( + input.revertOfRunId != null && + (existing.revertOfRunId ?? null) !== input.revertOfRunId + ) { + throw new Error( + `Package codemod run "${input.runId}" revertOfRunId does not match the requested value.`, + ) + } + const existingFiltersJson = normalizeFiltersJson( + parseStoredFiltersJson(existing.filtersJson), + ) + if (existingFiltersJson !== filtersJson) { + throw new Error( + `Package codemod run "${input.runId}" filters do not match the requested filters.`, + ) + } + return existing + } + return await createPackageCodemodRun(input.env.APP_DB, { + id: crypto.randomUUID(), + codemodId: input.codemodId, + mode: input.mode, + scopeUserId, + initiatedByUserId: input.initiatedByUserId, + filtersJson, + status: 'running', + revertOfRunId: input.revertOfRunId ?? null, + }) +} + +async function listCandidatePackages(input: { + env: Env + scope: PackageCodemodRunScope + filters?: PackageCodemodRunFilters + cursor: string | null + limit: number +}): Promise<{ + packages: Array + nextCursor: string | null +}> { + const packages: Array = [] + let cursor = input.cursor + switch (input.scope.kind) { + case 'user': { + const all = await listSavedPackagesByUserId(input.env.APP_DB, { + userId: input.scope.userId, + }) + const ordered = [...all] + .filter((savedPackage) => matchesFilters(savedPackage, input.filters)) + .sort((left, right) => compareBinaryIds(left.id, right.id)) + const startIndex = + cursor == null + ? 0 + : ordered.findIndex( + (savedPackage) => compareBinaryIds(savedPackage.id, cursor!) > 0, + ) + const sliceStart = startIndex < 0 ? ordered.length : startIndex + const page = ordered.slice(sliceStart, sliceStart + input.limit) + packages.push(...page) + const last = page.at(-1) + const exhausted = + page.length < input.limit || + last == null || + ordered.every( + (savedPackage) => compareBinaryIds(savedPackage.id, last.id) <= 0, + ) + return { + packages, + nextCursor: exhausted ? null : (last?.id ?? null), + } + } + case 'fleet': { + let pagesScanned = 0 + for (;;) { + pagesScanned += 1 + const page = await listSavedPackagesPage(input.env.APP_DB, { + afterId: cursor, + limit: fleetScanPageSize, + }) + if (page.length === 0) { + return { packages, nextCursor: null } + } + for (const savedPackage of page) { + cursor = savedPackage.id + if (!matchesFilters(savedPackage, input.filters)) continue + packages.push(savedPackage) + if (packages.length >= input.limit) { + return { + packages, + nextCursor: savedPackage.id, + } + } + } + if (page.length < fleetScanPageSize) { + return { packages, nextCursor: null } + } + if (pagesScanned >= maxFleetPagesPerStep) { + return { + packages, + nextCursor: cursor, + } + } + } + } + default: { + const exhaustive: never = input.scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + } +} + +async function detectPublishedDrift(input: { + env: Env + repoId: string + expectedCommit: string +}): Promise { + try { + const head = await resolveArtifactSourceHead(input.env, input.repoId) + if (!head.commit) return true + return head.commit !== input.expectedCommit + } catch { + return true + } +} + +async function persistItem( + env: Env, + result: PersistedItemResult, + runId: string, +) { + await insertPackageCodemodRunItem(env.APP_DB, { + id: result.itemId, + runId, + userId: result.userId, + packageId: result.packageId, + kodyId: result.kodyId, + status: result.status, + beforeCommit: result.beforeCommit, + afterCommit: result.afterCommit, + changedPaths: result.changedPaths, + findings: result.findings, + checkSummaryJson: + result.checkSummary == null ? null : JSON.stringify(result.checkSummary), + error: result.error, + revertSnapshotKey: result.revertSnapshotKey ?? null, + }) +} + +async function runChecksOnFiles(input: { + env: Env + baseUrl: string + userId: string + files: Record + manifestPath: string + sourceRoot: string +}) { + return await runRepoChecks({ + workspace: createSnapshotFilesWorkspace(input.files), + manifestPath: input.manifestPath, + sourceRoot: input.sourceRoot, + env: input.env, + baseUrl: input.baseUrl, + userId: input.userId, + }) +} + +async function processScanItem(input: { + codemod: PackageCodemod + savedPackage: SavedPackageRecord + files: Record + beforeCommit: string | null +}): Promise { + const findings = input.codemod.detect(input.files) + const status: PackageCodemodItemStatus = + findings.length > 0 ? 'detected' : 'clean' + return emptyItemResult({ + itemId: crypto.randomUUID(), + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status, + findings, + beforeCommit: input.beforeCommit, + }) +} + +async function processTransformGates(input: { + env: Env + baseUrl: string + codemod: PackageCodemod + savedPackage: SavedPackageRecord + files: Record + beforeCommit: string | null + manifestPath: string + sourceRoot: string +}): Promise< + | { + kind: 'terminal' + result: PersistedItemResult + } + | { + kind: 'ready' + itemId: string + transformedFiles: Record + changedPaths: Array + findings: Array + checkSummary: { ok: boolean; newFailures: Array } + } +> { + const itemId = crypto.randomUUID() + const transformed = input.codemod.transform(input.files) + if (!transformed.changed && transformed.needsManual.length > 0) { + return { + kind: 'terminal', + result: emptyItemResult({ + itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'needs_manual', + findings: transformed.needsManual, + beforeCommit: input.beforeCommit, + }), + } + } + if (!transformed.changed) { + return { + kind: 'terminal', + result: emptyItemResult({ + itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'clean', + findings: transformed.needsManual, + beforeCommit: input.beforeCommit, + }), + } + } + const secondPass = input.codemod.transform(transformed.files) + if (secondPass.changed) { + return { + kind: 'terminal', + result: emptyItemResult({ + itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'failed', + findings: transformed.needsManual, + changedPaths: transformed.changedPaths, + beforeCommit: input.beforeCommit, + error: + 'Codemod transform is not idempotent: a second transform pass still reported changes.', + }), + } + } + const beforeChecks = await runChecksOnFiles({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.savedPackage.userId, + files: input.files, + manifestPath: input.manifestPath, + sourceRoot: input.sourceRoot, + }) + const afterChecks = await runChecksOnFiles({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.savedPackage.userId, + files: transformed.files, + manifestPath: input.manifestPath, + sourceRoot: input.sourceRoot, + }) + const checkSummary = computeCheckSummary({ + before: beforeChecks, + after: afterChecks, + }) + if (!checkSummary.ok) { + return { + kind: 'terminal', + result: emptyItemResult({ + itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'dry_run_new_failures', + findings: transformed.needsManual, + changedPaths: transformed.changedPaths, + beforeCommit: input.beforeCommit, + checkSummary, + }), + } + } + return { + kind: 'ready', + itemId, + transformedFiles: transformed.files, + changedPaths: transformed.changedPaths, + findings: transformed.needsManual, + checkSummary, + } +} + +async function processPackageForMode(input: { + env: Env + baseUrl: string + mode: PackageCodemodRunMode + codemod: PackageCodemod + savedPackage: SavedPackageRecord + runId: string + waitUntil?: (promise: Promise) => void + subscriptionCache: ReturnType +}): Promise { + const loaded = await loadPackageSourceBySourceId({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.savedPackage.userId, + sourceId: input.savedPackage.sourceId, + }) + const publishedCommit = loaded.source.published_commit + if (publishedCommit == null) { + return emptyItemResult({ + itemId: crypto.randomUUID(), + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'skipped_unpublished', + }) + } + const drifted = await detectPublishedDrift({ + env: input.env, + repoId: loaded.source.repo_id, + expectedCommit: publishedCommit, + }) + if (drifted) { + return emptyItemResult({ + itemId: crypto.randomUUID(), + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'skipped_drift', + beforeCommit: publishedCommit, + }) + } + + switch (input.mode) { + case 'scan': + return await processScanItem({ + codemod: input.codemod, + savedPackage: input.savedPackage, + files: loaded.files, + beforeCommit: publishedCommit, + }) + case 'dry-run': { + const gated = await processTransformGates({ + env: input.env, + baseUrl: input.baseUrl, + codemod: input.codemod, + savedPackage: input.savedPackage, + files: loaded.files, + beforeCommit: publishedCommit, + manifestPath: loaded.source.manifest_path, + sourceRoot: loaded.source.source_root, + }) + if (gated.kind === 'terminal') return gated.result + return emptyItemResult({ + itemId: gated.itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'dry_run_ok', + changedPaths: gated.changedPaths, + findings: gated.findings, + beforeCommit: publishedCommit, + checkSummary: gated.checkSummary, + }) + } + case 'apply': { + const gated = await processTransformGates({ + env: input.env, + baseUrl: input.baseUrl, + codemod: input.codemod, + savedPackage: input.savedPackage, + files: loaded.files, + beforeCommit: publishedCommit, + manifestPath: loaded.source.manifest_path, + sourceRoot: loaded.source.source_root, + }) + if (gated.kind === 'terminal') return gated.result + + const prePublishDrift = await detectPublishedDrift({ + env: input.env, + repoId: loaded.source.repo_id, + expectedCommit: publishedCommit, + }) + if (prePublishDrift) { + return emptyItemResult({ + itemId: gated.itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'skipped_drift', + changedPaths: gated.changedPaths, + findings: gated.findings, + beforeCommit: publishedCommit, + checkSummary: gated.checkSummary, + }) + } + + const revertSnapshotKey = buildPackageCodemodRevertSnapshotKvKey({ + userId: input.savedPackage.userId, + itemId: gated.itemId, + }) + const snapshot: CodemodRevertSnapshot = { + codemodId: input.codemod.id, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + beforeCommit: publishedCommit, + files: loaded.files, + } + await input.env.BUNDLE_ARTIFACTS_KV.put( + revertSnapshotKey, + JSON.stringify(snapshot), + { expirationTtl: revertSnapshotTtlSeconds }, + ) + + try { + const afterCommit = await syncArtifactSourceSnapshot({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.savedPackage.userId, + sourceId: input.savedPackage.sourceId, + files: gated.transformedFiles, + destructiveOverwriteConfirmed: true, + commitMessage: `codemod(${input.codemod.id}): ${input.codemod.description}`, + }) + if (afterCommit == null) { + return emptyItemResult({ + itemId: gated.itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'failed', + changedPaths: gated.changedPaths, + findings: gated.findings, + beforeCommit: publishedCommit, + checkSummary: gated.checkSummary, + revertSnapshotKey, + error: + 'syncArtifactSourceSnapshot returned no published commit. A revert snapshot was retained; repo HEAD may be ahead of published_commit.', + }) + } + try { + await refreshSavedPackageProjection({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + sourceId: input.savedPackage.sourceId, + }) + } catch (error) { + console.error( + JSON.stringify({ + message: 'package-codemod projection refresh failed', + packageId: input.savedPackage.id, + error: getErrorMessage(error), + }), + ) + } + await dispatchPackageCodemodSubscriptionEvent({ + env: input.env, + userId: input.savedPackage.userId, + topic: packageCodemodAppliedTopic, + codemodId: input.codemod.id, + codemodDescription: input.codemod.description, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + runId: input.runId, + itemId: gated.itemId, + changedPaths: gated.changedPaths, + beforeCommit: publishedCommit, + afterCommit, + waitUntil: input.waitUntil, + subscriptionCache: input.subscriptionCache, + }) + return emptyItemResult({ + itemId: gated.itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'applied', + changedPaths: gated.changedPaths, + findings: gated.findings, + beforeCommit: publishedCommit, + afterCommit, + checkSummary: gated.checkSummary, + revertSnapshotKey, + }) + } catch (error) { + return emptyItemResult({ + itemId: gated.itemId, + userId: input.savedPackage.userId, + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + status: 'failed', + changedPaths: gated.changedPaths, + findings: gated.findings, + beforeCommit: publishedCommit, + checkSummary: gated.checkSummary, + revertSnapshotKey, + error: `${getErrorMessage(error)} A revert snapshot was retained; repo HEAD may be ahead of published_commit.`, + }) + } + } + case 'revert': + throw new Error('processPackageForMode does not handle revert mode.') + default: { + const exhaustive: never = input.mode + throw new Error(`Unknown package codemod mode: ${String(exhaustive)}`) + } + } +} + +async function processRevertStep(input: { + env: Env + baseUrl: string + codemod: PackageCodemod + run: PackageCodemodRunRecord + scope: PackageCodemodRunScope + cursor: string | null + limit: number + waitUntil?: (promise: Promise) => void + subscriptionCache: ReturnType +}): Promise { + if (!input.run.revertOfRunId) { + throw new Error('revert mode requires revertOfRunId on the run.') + } + const items: Array = [] + const summary: Partial> = {} + let cursor = input.cursor + let pagesFetched = 0 + const scopeUserIdFilter = + input.scope.kind === 'user' ? input.scope.userId : undefined + const filters = parseStoredFiltersJson(input.run.filtersJson) + for (;;) { + pagesFetched += 1 + const page = await listPackageCodemodRunItems(input.env.APP_DB, { + runId: input.run.revertOfRunId, + afterId: cursor, + limit: fleetScanPageSize, + status: 'applied', + ...(scopeUserIdFilter != null ? { userId: scopeUserIdFilter } : {}), + }) + if (page.length === 0) { + await updatePackageCodemodRunStatus(input.env.APP_DB, { + id: input.run.id, + status: 'completed', + }) + return { + runId: input.run.id, + codemodId: input.codemod.id, + mode: 'revert', + items, + nextCursor: null, + summary, + } + } + for (const priorItem of page) { + cursor = priorItem.id + if (input.scope.kind !== 'user' && input.scope.kind !== 'fleet') { + const exhaustive: never = input.scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + if ( + !matchesCodemodTargetFilters( + { + userId: priorItem.userId, + packageId: priorItem.packageId, + }, + filters, + ) + ) { + // Filter skips advance the cursor but do not count toward limit; + // the fleet page ceiling still bounds work per step. + continue + } + const itemId = crypto.randomUUID() + let result: PersistedItemResult + try { + const snapshotKey = + priorItem.revertSnapshotKey ?? + buildPackageCodemodRevertSnapshotKvKey({ + userId: priorItem.userId, + itemId: priorItem.id, + }) + const raw = await input.env.BUNDLE_ARTIFACTS_KV.get(snapshotKey) + if (raw == null) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + beforeCommit: priorItem.afterCommit, + error: `Missing revert snapshot for item "${priorItem.id}".`, + }) + } else { + const snapshot = JSON.parse(raw) as CodemodRevertSnapshot + if ( + snapshot.userId !== priorItem.userId || + snapshot.packageId !== priorItem.packageId + ) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + beforeCommit: priorItem.afterCommit, + error: `Revert snapshot ownership mismatch for item "${priorItem.id}".`, + }) + } else { + const savedPackage = ( + await listSavedPackagesByUserId(input.env.APP_DB, { + userId: priorItem.userId, + }) + ).find((candidate) => candidate.id === priorItem.packageId) + if (!savedPackage) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + error: `Saved package "${priorItem.packageId}" was not found for revert.`, + }) + } else { + const loaded = await loadPackageSourceBySourceId({ + env: input.env, + baseUrl: input.baseUrl, + userId: priorItem.userId, + sourceId: savedPackage.sourceId, + }) + const expectedHead = priorItem.afterCommit + if (expectedHead == null) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + error: `Applied item "${priorItem.id}" is missing afterCommit for revert drift check.`, + }) + } else { + const drifted = await detectPublishedDrift({ + env: input.env, + repoId: loaded.source.repo_id, + expectedCommit: expectedHead, + }) + if (drifted) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'skipped_drift', + beforeCommit: priorItem.afterCommit, + }) + } else { + const afterCommit = await syncArtifactSourceSnapshot({ + env: input.env, + baseUrl: input.baseUrl, + userId: priorItem.userId, + sourceId: savedPackage.sourceId, + files: snapshot.files, + destructiveOverwriteConfirmed: true, + commitMessage: `revert codemod(${input.codemod.id})`, + }) + if (afterCommit == null) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + error: + 'syncArtifactSourceSnapshot returned no published commit.', + }) + } else { + try { + await refreshSavedPackageProjection({ + env: input.env, + baseUrl: input.baseUrl, + userId: priorItem.userId, + packageId: priorItem.packageId, + sourceId: savedPackage.sourceId, + }) + } catch (error) { + console.error( + JSON.stringify({ + message: + 'package-codemod revert projection refresh failed', + packageId: priorItem.packageId, + error: getErrorMessage(error), + }), + ) + } + await dispatchPackageCodemodSubscriptionEvent({ + env: input.env, + userId: priorItem.userId, + topic: packageCodemodRevertedTopic, + codemodId: input.codemod.id, + codemodDescription: input.codemod.description, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + runId: input.run.id, + itemId, + changedPaths: priorItem.changedPaths, + beforeCommit: priorItem.afterCommit, + afterCommit, + waitUntil: input.waitUntil, + subscriptionCache: input.subscriptionCache, + }) + await updatePackageCodemodRunItem(input.env.APP_DB, { + id: priorItem.id, + status: 'reverted', + }) + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'reverted', + changedPaths: priorItem.changedPaths, + beforeCommit: priorItem.afterCommit, + afterCommit, + }) + } + } + } + } + } + } + } catch (error) { + result = emptyItemResult({ + itemId, + userId: priorItem.userId, + packageId: priorItem.packageId, + kodyId: priorItem.kodyId, + status: 'failed', + error: getErrorMessage(error), + }) + } + await persistItem(input.env, result, input.run.id) + items.push(toPublicItem(result)) + incrementSummary(summary, result.status) + if (items.length >= input.limit) { + return { + runId: input.run.id, + codemodId: input.codemod.id, + mode: 'revert', + items, + nextCursor: cursor, + summary, + } + } + } + if (page.length < fleetScanPageSize) { + await updatePackageCodemodRunStatus(input.env.APP_DB, { + id: input.run.id, + status: 'completed', + }) + return { + runId: input.run.id, + codemodId: input.codemod.id, + mode: 'revert', + items, + nextCursor: null, + summary, + } + } + if (pagesFetched >= maxFleetPagesPerStep) { + return { + runId: input.run.id, + codemodId: input.codemod.id, + mode: 'revert', + items, + nextCursor: cursor, + summary, + } + } + } +} + +export async function runPackageCodemodStep(input: { + env: Env + baseUrl: string + initiatedByUserId: string + codemodId: string + mode: PackageCodemodRunMode + scope: PackageCodemodRunScope + filters?: PackageCodemodRunFilters + runId?: string + cursor?: string | null + limit?: number + revertOfRunId?: string + waitUntil?: (promise: Promise) => void +}): Promise { + const codemod = getPackageCodemodById(input.codemodId) + if (!codemod) { + throw new Error(`Unknown package codemod "${input.codemodId}".`) + } + switch (input.mode) { + case 'scan': + case 'dry-run': + case 'apply': + case 'revert': + break + default: { + const exhaustive: never = input.mode + throw new Error(`Unknown package codemod mode: ${String(exhaustive)}`) + } + } + const limit = resolveStepLimit(input.mode, input.limit) + if (input.mode === 'revert' && !input.revertOfRunId && !input.runId) { + throw new Error('revert mode requires revertOfRunId.') + } + // A cursor is only meaningful when continuing an existing run; accepting + // one on a fresh run would silently skip everything before the cursor. + if (input.cursor != null && !input.runId) { + throw new Error('cursor requires runId to continue an existing run.') + } + const run = await ensureRun({ + env: input.env, + runId: input.runId, + codemodId: input.codemodId, + mode: input.mode, + scope: input.scope, + initiatedByUserId: input.initiatedByUserId, + filters: input.filters, + revertOfRunId: input.revertOfRunId, + }) + const subscriptionCache = createPackageCodemodSubscriptionCache() + if (input.mode === 'revert') { + return await processRevertStep({ + env: input.env, + baseUrl: input.baseUrl, + codemod, + run, + scope: input.scope, + cursor: input.cursor ?? null, + limit, + waitUntil: input.waitUntil, + subscriptionCache, + }) + } + + const { packages, nextCursor } = await listCandidatePackages({ + env: input.env, + scope: input.scope, + filters: input.filters, + cursor: input.cursor ?? null, + limit, + }) + const items: Array = [] + const summary: Partial> = {} + for (const savedPackage of packages) { + let result: PersistedItemResult + try { + result = await processPackageForMode({ + env: input.env, + baseUrl: input.baseUrl, + mode: input.mode, + codemod, + savedPackage, + runId: run.id, + waitUntil: input.waitUntil, + subscriptionCache, + }) + } catch (error) { + result = emptyItemResult({ + itemId: crypto.randomUUID(), + userId: savedPackage.userId, + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + status: 'failed', + error: getErrorMessage(error), + }) + } + await persistItem(input.env, result, run.id) + items.push(toPublicItem(result)) + incrementSummary(summary, result.status) + } + if (nextCursor == null) { + await updatePackageCodemodRunStatus(input.env.APP_DB, { + id: run.id, + status: 'completed', + }) + } + return { + runId: run.id, + codemodId: input.codemodId, + mode: input.mode, + items, + nextCursor, + summary, + } +} diff --git a/packages/worker/src/package-codemods/ledger.node.test.ts b/packages/worker/src/package-codemods/ledger.node.test.ts new file mode 100644 index 0000000000..4d1e69bc15 --- /dev/null +++ b/packages/worker/src/package-codemods/ledger.node.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { + createPackageCodemodRun, + getPackageCodemodRunById, + getPackageCodemodRunItemById, + insertPackageCodemodRunItem, + listPackageCodemodRunItems, + listPackageCodemodRuns, + updatePackageCodemodRunStatus, +} from './ledger.ts' + +function createLedgerDb() { + const sqlite = new DatabaseSync(':memory:') + sqlite.exec( + readFileSync( + new URL( + '../../migrations/0111-package-codemod-ledger.sql', + import.meta.url, + ), + 'utf8', + ), + ) + return { sqlite, db: createD1FromSqlite(sqlite) } +} + +test('package codemod ledger pages runs and items with filters', async () => { + const { db } = createLedgerDb() + + await createPackageCodemodRun(db, { + id: 'run-a', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + scopeUserId: 'user-1', + initiatedByUserId: 'admin-1', + createdAt: '2026-07-30T10:00:00.000Z', + updatedAt: '2026-07-30T10:00:00.000Z', + }) + await createPackageCodemodRun(db, { + id: 'run-b', + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'apply', + scopeUserId: null, + initiatedByUserId: 'admin-1', + createdAt: '2026-07-30T11:00:00.000Z', + updatedAt: '2026-07-30T11:00:00.000Z', + }) + await createPackageCodemodRun(db, { + id: 'run-c', + codemodId: '0002-other', + mode: 'scan', + scopeUserId: 'user-2', + initiatedByUserId: 'admin-1', + createdAt: '2026-07-30T12:00:00.000Z', + updatedAt: '2026-07-30T12:00:00.000Z', + }) + + const byCodemod = await listPackageCodemodRuns(db, { + codemodId: '0001-ambient-storage-to-package-storage', + limit: 10, + }) + expect(byCodemod.map((run) => run.id)).toEqual(['run-b', 'run-a']) + + const fleetOnly = await listPackageCodemodRuns(db, { + scopeUserId: null, + limit: 10, + }) + expect(fleetOnly.map((run) => run.id)).toEqual(['run-b']) + + const userScoped = await listPackageCodemodRuns(db, { + scopeUserId: 'user-1', + limit: 10, + }) + expect(userScoped.map((run) => run.id)).toEqual(['run-a']) + + await updatePackageCodemodRunStatus(db, { + id: 'run-a', + status: 'completed', + }) + expect(await getPackageCodemodRunById(db, 'run-a')).toMatchObject({ + id: 'run-a', + status: 'completed', + }) + + await insertPackageCodemodRunItem(db, { + id: 'item-1', + runId: 'run-b', + userId: 'user-1', + packageId: 'pkg-1', + kodyId: 'one', + status: 'applied', + beforeCommit: 'c1', + afterCommit: 'c2', + changedPaths: ['index.ts'], + findings: [{ path: 'index.ts', message: 'note' }], + revertSnapshotKey: 'package-codemod-revert:user-1:item-1', + }) + await insertPackageCodemodRunItem(db, { + id: 'item-2', + runId: 'run-b', + userId: 'user-2', + packageId: 'pkg-2', + kodyId: 'two', + status: 'clean', + }) + await insertPackageCodemodRunItem(db, { + id: 'item-3', + runId: 'run-b', + userId: 'user-3', + packageId: 'pkg-3', + kodyId: 'three', + status: 'applied', + }) + + const firstPage = await listPackageCodemodRunItems(db, { + runId: 'run-b', + limit: 2, + }) + expect(firstPage.map((item) => item.id)).toEqual(['item-1', 'item-2']) + expect(firstPage[0]).toMatchObject({ + changedPaths: ['index.ts'], + findings: [{ path: 'index.ts', message: 'note' }], + beforeCommit: 'c1', + afterCommit: 'c2', + revertSnapshotKey: 'package-codemod-revert:user-1:item-1', + }) + + const secondPage = await listPackageCodemodRunItems(db, { + runId: 'run-b', + afterId: 'item-2', + limit: 2, + }) + expect(secondPage.map((item) => item.id)).toEqual(['item-3']) + + const appliedOnly = await listPackageCodemodRunItems(db, { + runId: 'run-b', + status: 'applied', + limit: 10, + }) + expect(appliedOnly.map((item) => item.id)).toEqual(['item-1', 'item-3']) + + const user1Items = await listPackageCodemodRunItems(db, { + runId: 'run-b', + userId: 'user-1', + limit: 10, + }) + expect(user1Items.map((item) => item.id)).toEqual(['item-1']) + + const user1AppliedPage = await listPackageCodemodRunItems(db, { + runId: 'run-b', + userId: 'user-1', + status: 'applied', + limit: 10, + }) + expect(user1AppliedPage.map((item) => item.id)).toEqual(['item-1']) + + expect( + await getPackageCodemodRunById(db, 'run-a', { userId: 'user-1' }), + ).toMatchObject({ id: 'run-a' }) + expect( + await getPackageCodemodRunById(db, 'run-a', { userId: 'user-2' }), + ).toBeNull() + expect( + await getPackageCodemodRunById(db, 'run-b', { userId: 'user-1' }), + ).toBeNull() + + expect( + await getPackageCodemodRunItemById(db, 'item-1', { userId: 'user-1' }), + ).toMatchObject({ id: 'item-1' }) + expect( + await getPackageCodemodRunItemById(db, 'item-1', { userId: 'user-2' }), + ).toBeNull() +}) diff --git a/packages/worker/src/package-codemods/ledger.ts b/packages/worker/src/package-codemods/ledger.ts new file mode 100644 index 0000000000..d8635f4dd1 --- /dev/null +++ b/packages/worker/src/package-codemods/ledger.ts @@ -0,0 +1,516 @@ +import { + maxRestorableTextColumnBytes, + truncateToUtf8Bytes, + utf8ByteLength, +} from '@kody-internal/shared/backup-restore-safety.ts' +import { type PackageCodemodFinding } from './types.ts' + +export type PackageCodemodRunStatus = 'running' | 'completed' | 'failed' + +export type PackageCodemodRunRecord = { + id: string + codemodId: string + mode: string + scopeUserId: string | null + initiatedByUserId: string + filtersJson: string + status: PackageCodemodRunStatus + revertOfRunId: string | null + createdAt: string + updatedAt: string +} + +export type PackageCodemodRunItemRecord = { + id: string + runId: string + userId: string + packageId: string + kodyId: string + status: string + beforeCommit: string | null + afterCommit: string | null + changedPaths: Array + findings: Array + checkSummaryJson: string | null + error: string | null + revertSnapshotKey: string | null + createdAt: string + updatedAt: string +} + +const runSelectColumns = `id, codemod_id, mode, scope_user_id, initiated_by_user_id, + filters_json, status, revert_of_run_id, created_at, updated_at` + +const itemSelectColumns = `id, run_id, user_id, package_id, kody_id, status, + before_commit, after_commit, changed_paths_json, findings_json, + check_summary_json, error, revert_snapshot_key, created_at, updated_at` + +const maxFindingsStored = 50 +const maxChangedPathsStored = 200 +const truncationNotice = '\n…[truncated]' + +function parseJsonArray(value: string | null | undefined): Array { + if (value == null || value === '') return [] + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? (parsed as Array) : [] + } catch { + return [] + } +} + +function boundTextColumn(value: string | null | undefined): string | null { + if (value == null) return null + if (utf8ByteLength(value) <= maxRestorableTextColumnBytes) return value + return ( + truncateToUtf8Bytes( + value, + maxRestorableTextColumnBytes - utf8ByteLength(truncationNotice), + ) + truncationNotice + ) +} + +function boundJsonStringArray(values: Array): string { + const capped = values.slice(0, maxChangedPathsStored) + for (let size = capped.length; size >= 0; size -= 1) { + const slice = capped.slice(0, size) + const payload = + size < values.length + ? [...slice, `…truncated ${values.length - size} more`] + : slice + const json = JSON.stringify(payload) + if (utf8ByteLength(json) <= maxRestorableTextColumnBytes) { + return json + } + } + return JSON.stringify(['…truncated']) +} + +function boundFindingsJson(findings: Array): string { + const capped = findings.slice(0, maxFindingsStored) + for (let size = capped.length; size >= 0; size -= 1) { + const slice = capped.slice(0, size) + const payload = + size < findings.length + ? [ + ...slice, + { + path: null, + message: `…truncated ${findings.length - size} more findings`, + }, + ] + : slice + const json = JSON.stringify(payload) + if (utf8ByteLength(json) <= maxRestorableTextColumnBytes) { + return json + } + } + return JSON.stringify([ + { + path: null, + message: 'findings truncated', + }, + ]) +} + +function mapRunRow(row: Record): PackageCodemodRunRecord { + const status = String(row['status']) + return { + id: String(row['id']), + codemodId: String(row['codemod_id']), + mode: String(row['mode']), + scopeUserId: + row['scope_user_id'] == null ? null : String(row['scope_user_id']), + initiatedByUserId: String(row['initiated_by_user_id']), + filtersJson: String(row['filters_json'] ?? '{}'), + status: + status === 'completed' || status === 'failed' || status === 'running' + ? status + : 'failed', + revertOfRunId: + row['revert_of_run_id'] == null ? null : String(row['revert_of_run_id']), + createdAt: String(row['created_at']), + updatedAt: String(row['updated_at']), + } +} + +function mapItemRow(row: Record): PackageCodemodRunItemRecord { + return { + id: String(row['id']), + runId: String(row['run_id']), + userId: String(row['user_id']), + packageId: String(row['package_id']), + kodyId: String(row['kody_id']), + status: String(row['status']), + beforeCommit: + row['before_commit'] == null ? null : String(row['before_commit']), + afterCommit: + row['after_commit'] == null ? null : String(row['after_commit']), + changedPaths: parseJsonArray( + row['changed_paths_json'] == null + ? '[]' + : String(row['changed_paths_json']), + ), + findings: parseJsonArray( + row['findings_json'] == null ? '[]' : String(row['findings_json']), + ), + checkSummaryJson: + row['check_summary_json'] == null + ? null + : String(row['check_summary_json']), + error: row['error'] == null ? null : String(row['error']), + revertSnapshotKey: + row['revert_snapshot_key'] == null + ? null + : String(row['revert_snapshot_key']), + createdAt: String(row['created_at']), + updatedAt: String(row['updated_at']), + } +} + +export async function createPackageCodemodRun( + db: D1Database, + input: { + id: string + codemodId: string + mode: string + scopeUserId: string | null + initiatedByUserId: string + filtersJson?: string + status?: PackageCodemodRunStatus + revertOfRunId?: string | null + createdAt?: string + updatedAt?: string + }, +): Promise { + const now = new Date().toISOString() + const createdAt = input.createdAt ?? now + const updatedAt = input.updatedAt ?? now + const status = input.status ?? 'running' + await db + .prepare( + `INSERT INTO package_codemod_runs ( + id, codemod_id, mode, scope_user_id, initiated_by_user_id, + filters_json, status, revert_of_run_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.codemodId, + input.mode, + input.scopeUserId, + input.initiatedByUserId, + input.filtersJson ?? '{}', + status, + input.revertOfRunId ?? null, + createdAt, + updatedAt, + ) + .run() + return { + id: input.id, + codemodId: input.codemodId, + mode: input.mode, + scopeUserId: input.scopeUserId, + initiatedByUserId: input.initiatedByUserId, + filtersJson: input.filtersJson ?? '{}', + status, + revertOfRunId: input.revertOfRunId ?? null, + createdAt, + updatedAt, + } +} + +export async function updatePackageCodemodRunStatus( + db: D1Database, + input: { + id: string + status: PackageCodemodRunStatus + updatedAt?: string + }, +) { + const updatedAt = input.updatedAt ?? new Date().toISOString() + await db + .prepare( + `UPDATE package_codemod_runs + SET status = ?, updated_at = ? + WHERE id = ?`, + ) + .bind(input.status, updatedAt, input.id) + .run() +} + +export async function getPackageCodemodRunById( + db: D1Database, + runId: string, + options?: { userId?: string }, +): Promise { + if (options?.userId != null) { + const row = await db + .prepare( + `SELECT ${runSelectColumns} + FROM package_codemod_runs + WHERE id = ? AND scope_user_id = ?`, + ) + .bind(runId, options.userId) + .first>() + return row ? mapRunRow(row) : null + } + const row = await db + .prepare( + `SELECT ${runSelectColumns} + FROM package_codemod_runs + WHERE id = ?`, + ) + .bind(runId) + .first>() + return row ? mapRunRow(row) : null +} + +export async function listPackageCodemodRuns( + db: D1Database, + input: { + codemodId?: string + scopeUserId?: string | null + limit?: number + } = {}, +): Promise> { + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200) + const conditions: Array = [] + const params: Array = [] + if (input.codemodId != null) { + conditions.push('codemod_id = ?') + params.push(input.codemodId) + } + if (input.scopeUserId !== undefined) { + if (input.scopeUserId == null) { + conditions.push('scope_user_id IS NULL') + } else { + conditions.push('scope_user_id = ?') + params.push(input.scopeUserId) + } + } + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const rows = await db + .prepare( + `SELECT ${runSelectColumns} + FROM package_codemod_runs + ${whereClause} + ORDER BY created_at DESC, id DESC + LIMIT ?`, + ) + .bind(...params, limit) + .all>() + return (rows.results ?? []).map(mapRunRow) +} + +export async function insertPackageCodemodRunItem( + db: D1Database, + input: { + id: string + runId: string + userId: string + packageId: string + kodyId: string + status: string + beforeCommit?: string | null + afterCommit?: string | null + changedPaths?: Array + findings?: Array + checkSummaryJson?: string | null + error?: string | null + revertSnapshotKey?: string | null + createdAt?: string + updatedAt?: string + }, +): Promise { + const now = new Date().toISOString() + const createdAt = input.createdAt ?? now + const updatedAt = input.updatedAt ?? now + const changedPaths = input.changedPaths ?? [] + const findings = input.findings ?? [] + const changedPathsJson = boundJsonStringArray(changedPaths) + const findingsJson = boundFindingsJson(findings) + const checkSummaryJson = boundTextColumn(input.checkSummaryJson ?? null) + const error = boundTextColumn(input.error ?? null) + const revertSnapshotKey = input.revertSnapshotKey ?? null + await db + .prepare( + `INSERT INTO package_codemod_run_items ( + id, run_id, user_id, package_id, kody_id, status, + before_commit, after_commit, changed_paths_json, findings_json, + check_summary_json, error, revert_snapshot_key, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.runId, + input.userId, + input.packageId, + input.kodyId, + input.status, + input.beforeCommit ?? null, + input.afterCommit ?? null, + changedPathsJson, + findingsJson, + checkSummaryJson, + error, + revertSnapshotKey, + createdAt, + updatedAt, + ) + .run() + return { + id: input.id, + runId: input.runId, + userId: input.userId, + packageId: input.packageId, + kodyId: input.kodyId, + status: input.status, + beforeCommit: input.beforeCommit ?? null, + afterCommit: input.afterCommit ?? null, + changedPaths: parseJsonArray(changedPathsJson), + findings: parseJsonArray(findingsJson), + checkSummaryJson, + error, + revertSnapshotKey, + createdAt, + updatedAt, + } +} + +export async function updatePackageCodemodRunItem( + db: D1Database, + input: { + id: string + status?: string + beforeCommit?: string | null + afterCommit?: string | null + changedPaths?: Array + findings?: Array + checkSummaryJson?: string | null + error?: string | null + revertSnapshotKey?: string | null + updatedAt?: string + }, +) { + const updates: Array = [] + const params: Array = [] + if (input.status !== undefined) { + updates.push('status = ?') + params.push(input.status) + } + if (input.beforeCommit !== undefined) { + updates.push('before_commit = ?') + params.push(input.beforeCommit) + } + if (input.afterCommit !== undefined) { + updates.push('after_commit = ?') + params.push(input.afterCommit) + } + if (input.changedPaths !== undefined) { + updates.push('changed_paths_json = ?') + params.push(boundJsonStringArray(input.changedPaths)) + } + if (input.findings !== undefined) { + updates.push('findings_json = ?') + params.push(boundFindingsJson(input.findings)) + } + if (input.checkSummaryJson !== undefined) { + updates.push('check_summary_json = ?') + params.push(boundTextColumn(input.checkSummaryJson)) + } + if (input.error !== undefined) { + updates.push('error = ?') + params.push(boundTextColumn(input.error)) + } + if (input.revertSnapshotKey !== undefined) { + updates.push('revert_snapshot_key = ?') + params.push(input.revertSnapshotKey) + } + const updatedAt = input.updatedAt ?? new Date().toISOString() + updates.push('updated_at = ?') + params.push(updatedAt) + if (updates.length === 1) return + params.push(input.id) + await db + .prepare( + `UPDATE package_codemod_run_items + SET ${updates.join(', ')} + WHERE id = ?`, + ) + .bind(...params) + .run() +} + +export async function listPackageCodemodRunItems( + db: D1Database, + input: { + runId: string + afterId?: string | null + limit?: number + status?: string + userId?: string + }, +): Promise> { + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200) + const conditions = ['run_id = ?', 'id > ?'] + const params: Array = [input.runId, input.afterId ?? ''] + if (input.status != null) { + conditions.push('status = ?') + params.push(input.status) + } + if (input.userId != null) { + conditions.push('user_id = ?') + params.push(input.userId) + } + const rows = await db + .prepare( + `SELECT ${itemSelectColumns} + FROM package_codemod_run_items + WHERE ${conditions.join(' AND ')} + ORDER BY id ASC + LIMIT ?`, + ) + .bind(...params, limit) + .all>() + return (rows.results ?? []).map(mapItemRow) +} + +export async function getPackageCodemodRunItemById( + db: D1Database, + itemId: string, + options?: { userId?: string }, +): Promise { + if (options?.userId != null) { + const row = await db + .prepare( + `SELECT ${itemSelectColumns} + FROM package_codemod_run_items + WHERE id = ? AND user_id = ?`, + ) + .bind(itemId, options.userId) + .first>() + return row ? mapItemRow(row) : null + } + const row = await db + .prepare( + `SELECT ${itemSelectColumns} + FROM package_codemod_run_items + WHERE id = ?`, + ) + .bind(itemId) + .first>() + return row ? mapItemRow(row) : null +} + +/** Exported for unit tests that assert truncation helpers stay under the D1 bound. */ +export const packageCodemodLedgerTextBounds = { + maxRestorableTextColumnBytes, + maxFindingsStored, + maxChangedPathsStored, + boundJsonStringArray, + boundFindingsJson, + boundTextColumn, +} diff --git a/packages/worker/src/package-codemods/registry.ts b/packages/worker/src/package-codemods/registry.ts new file mode 100644 index 0000000000..119476b6da --- /dev/null +++ b/packages/worker/src/package-codemods/registry.ts @@ -0,0 +1,24 @@ +import { ambientStorageToPackageStorageCodemod } from './codemods/0001-ambient-storage-to-package-storage.ts' +import { type PackageCodemod } from './types.ts' + +const packageCodemods: Array = [ + ambientStorageToPackageStorageCodemod, +] + +const packageCodemodsById = new Map( + packageCodemods.map((codemod) => [codemod.id, codemod]), +) + +export function listPackageCodemods(): Array<{ + id: string + description: string +}> { + return packageCodemods.map((codemod) => ({ + id: codemod.id, + description: codemod.description, + })) +} + +export function getPackageCodemodById(id: string): PackageCodemod | null { + return packageCodemodsById.get(id) ?? null +} diff --git a/packages/worker/src/package-codemods/subscription-events.ts b/packages/worker/src/package-codemods/subscription-events.ts new file mode 100644 index 0000000000..f204e3fe9b --- /dev/null +++ b/packages/worker/src/package-codemods/subscription-events.ts @@ -0,0 +1,301 @@ +import { getAppBaseUrl } from '#worker/app-base-url.ts' +import { runWithDynamicWorkerEvaluationBudget } from '#mcp/executor.ts' +import { readPreExecutionPackageInvocationInfrastructureCode } from '#worker/package-invocations/admin-package-subscriptions.ts' +import { invokePackageSubscription } from '#worker/package-invocations/service.ts' +import { listPackageSubscriptions } from '#worker/package-registry/manifest.ts' +import { listSavedPackagesByUserId } from '#worker/package-registry/repo.ts' +import { loadPackageManifestBySourceId } from '#worker/package-registry/source.ts' +import { type SavedPackageRecord } from '#worker/package-registry/types.ts' + +export const packageCodemodAppliedTopic = 'package.codemod.applied' +export const packageCodemodRevertedTopic = 'package.codemod.reverted' + +export type PackageCodemodSubscriptionTopic = + | typeof packageCodemodAppliedTopic + | typeof packageCodemodRevertedTopic + +export type PackageCodemodSubscriptionEnvelope = { + event: PackageCodemodSubscriptionTopic + codemod: { + id: string + description: string + } + package: { + package_id: string + kody_id: string + } + run: { + run_id: string + item_id: string + } + changed_paths: Array + before_commit: string | null + after_commit: string | null +} + +export type LoadedCodemodSubscription = { + savedPackage: SavedPackageRecord + subscription: ReturnType[number] +} + +function buildPackageCodemodEventPayload(input: { + topic: PackageCodemodSubscriptionTopic + codemodId: string + codemodDescription: string + packageId: string + kodyId: string + runId: string + itemId: string + changedPaths: Array + beforeCommit: string | null + afterCommit: string | null +}): PackageCodemodSubscriptionEnvelope { + return { + event: input.topic, + codemod: { + id: input.codemodId, + description: input.codemodDescription, + }, + package: { + package_id: input.packageId, + kody_id: input.kodyId, + }, + run: { + run_id: input.runId, + item_id: input.itemId, + }, + changed_paths: input.changedPaths, + before_commit: input.beforeCommit, + after_commit: input.afterCommit, + } +} + +function buildSubscriptionIdempotencyKey(input: { + itemId: string + topic: PackageCodemodSubscriptionTopic + packageId: string +}) { + return `package-codemod:${input.itemId}:${input.topic}:${input.packageId}` +} + +export async function loadMatchingCodemodSubscriptions(input: { + env: Pick + baseUrl: string + userId: string + topic: PackageCodemodSubscriptionTopic +}): Promise<{ + subscriptions: Array + discoveryErrors: Array +}> { + let savedPackages: Array + try { + savedPackages = await listSavedPackagesByUserId(input.env.APP_DB, { + userId: input.userId, + }) + } catch (error) { + const missingTable = + error instanceof Error && + error.message.includes('no such table: saved_packages') + return { + subscriptions: [], + discoveryErrors: missingTable ? [] : [error], + } + } + const settled = await Promise.allSettled( + savedPackages.map(async (savedPackage) => { + const loaded = await loadPackageManifestBySourceId({ + env: input.env as Env, + baseUrl: input.baseUrl, + userId: input.userId, + sourceId: savedPackage.sourceId, + }) + const subscription = listPackageSubscriptions(loaded.manifest).find( + (candidate) => candidate.topic === input.topic, + ) + if (!subscription) return null + return { + savedPackage, + subscription, + } satisfies LoadedCodemodSubscription + }), + ) + const subscriptions: Array = [] + const discoveryErrors: Array = [] + for (const [index, result] of settled.entries()) { + if (result.status === 'fulfilled') { + if (result.value) subscriptions.push(result.value) + continue + } + const savedPackage = savedPackages[index] + console.warn( + 'Failed to load package manifest for package codemod subscription', + { + sourceId: savedPackage?.sourceId, + packageId: savedPackage?.id, + topic: input.topic, + error: result.reason, + }, + ) + discoveryErrors.push(result.reason) + } + return { subscriptions, discoveryErrors } +} + +export function createPackageCodemodSubscriptionCache() { + const cache = new Map< + string, + Promise<{ + subscriptions: Array + discoveryErrors: Array + }> + >() + return { + load(input: { + env: Pick + baseUrl: string + userId: string + topic: PackageCodemodSubscriptionTopic + }) { + const key = `${input.userId}:${input.topic}` + const existing = cache.get(key) + if (existing) return existing + const pending = loadMatchingCodemodSubscriptions(input) + cache.set(key, pending) + return pending + }, + } +} + +async function invokeCodemodSubscriptions(input: { + env: Pick + baseUrl: string + topic: PackageCodemodSubscriptionTopic + itemId: string + eventPayload: PackageCodemodSubscriptionEnvelope + subscriptions: Array + discoveryErrors: Array + waitUntil?: (promise: Promise) => void +}) { + const settled = await runWithDynamicWorkerEvaluationBudget( + async () => + await Promise.allSettled( + input.subscriptions.map(async ({ savedPackage }) => { + const response = await invokePackageSubscription({ + env: input.env as Env, + baseUrl: input.baseUrl, + savedPackage, + topic: input.topic, + params: input.eventPayload as Record, + idempotencyKey: buildSubscriptionIdempotencyKey({ + itemId: input.itemId, + topic: input.topic, + packageId: savedPackage.id, + }), + source: 'package-codemods', + waitUntil: input.waitUntil, + }) + const retryableCode = + readPreExecutionPackageInvocationInfrastructureCode(response) + if (retryableCode) { + throw new Error( + `Retryable package invocation infrastructure response: ${retryableCode}.`, + ) + } + return response + }), + ), + ) + for (const result of settled) { + if (result.status === 'rejected') { + console.warn('package codemod subscription invoke failed', { + topic: input.topic, + itemId: input.itemId, + error: result.reason, + }) + } + } + if (input.discoveryErrors.length > 0) { + console.warn('package codemod subscription discovery incomplete', { + topic: input.topic, + itemId: input.itemId, + errorCount: input.discoveryErrors.length, + error: input.discoveryErrors[0], + }) + } + return settled.map((result) => + result.status === 'fulfilled' ? result.value : null, + ) +} + +/** + * Fan a package codemod apply/revert event out to the owning user's packages + * that declare the topic. Best-effort: never throws into the codemod engine. + * When `waitUntil` is provided, the fan-out is scheduled there and this + * returns immediately. + */ +export async function dispatchPackageCodemodSubscriptionEvent(input: { + env: Pick + userId: string + topic: PackageCodemodSubscriptionTopic + codemodId: string + codemodDescription: string + packageId: string + kodyId: string + runId: string + itemId: string + changedPaths: Array + beforeCommit: string | null + afterCommit: string | null + waitUntil?: (promise: Promise) => void + subscriptionCache?: ReturnType +}) { + const run = async () => { + try { + const baseUrl = getAppBaseUrl({ env: input.env }) + const loader = + input.subscriptionCache?.load.bind(input.subscriptionCache) ?? + loadMatchingCodemodSubscriptions + const { subscriptions, discoveryErrors } = await loader({ + env: input.env, + baseUrl, + userId: input.userId, + topic: input.topic, + }) + const eventPayload = buildPackageCodemodEventPayload({ + topic: input.topic, + codemodId: input.codemodId, + codemodDescription: input.codemodDescription, + packageId: input.packageId, + kodyId: input.kodyId, + runId: input.runId, + itemId: input.itemId, + changedPaths: input.changedPaths, + beforeCommit: input.beforeCommit, + afterCommit: input.afterCommit, + }) + return await invokeCodemodSubscriptions({ + env: input.env, + baseUrl, + topic: input.topic, + itemId: input.itemId, + eventPayload, + subscriptions, + discoveryErrors, + waitUntil: input.waitUntil, + }) + } catch (error) { + console.warn('package codemod subscription dispatch failed', { + topic: input.topic, + itemId: input.itemId, + error, + }) + return [] + } + } + + if (input.waitUntil) { + input.waitUntil(run()) + return [] + } + return await run() +} diff --git a/packages/worker/src/package-codemods/types.ts b/packages/worker/src/package-codemods/types.ts new file mode 100644 index 0000000000..274136d1aa --- /dev/null +++ b/packages/worker/src/package-codemods/types.ts @@ -0,0 +1,18 @@ +export type PackageCodemodFinding = { + path: string | null + message: string +} + +export type PackageCodemodTransformResult = { + files: Record + changed: boolean + changedPaths: Array + needsManual: Array +} + +export type PackageCodemod = { + id: string + description: string + detect(files: Record): Array + transform(files: Record): PackageCodemodTransformResult +} diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index d21b3546f8..67b8d79fcb 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -463,6 +463,10 @@ { "filename": "0110-enable-execute-pre-exec-typecheck-for-kentcdodds.sql", "sha256": "b5a30dc7757e5b02ff745d23910ebdf94ba2ef284c6f7b29498d5d6ddd9dd4b2" + }, + { + "filename": "0111-package-codemod-ledger.sql", + "sha256": "d42350b7bfa88400d8133a8d8436dd6fa2e25e3ab7650e047c447e7f4704863e" } ] }