From b6bbb1cfee53299ed7f0fc0d3d80bb7ccc246cdb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:49:12 +0000 Subject: [PATCH 01/13] feat(codemods): package codemod engine, ledger, and ambient-storage codemod Adds a formal codemod system for migrating user package source when the platform package API changes: a pure detect/transform contract, an engine with scan/dry-run/apply/revert modes (drift + unpublished skips, no-new-failures publish gate, mechanical idempotency check, KV revert snapshots), a D1 run ledger, host-dispatched package.codemod.applied / package.codemod.reverted subscription events, and the first codemod (ambient storage import -> packageStorage()). Co-authored-by: Kent C. Dodds --- .../0111-package-codemod-ledger.sql | 39 + ...nt-storage-to-package-storage.node.test.ts | 84 ++ ...0001-ambient-storage-to-package-storage.ts | 411 ++++++++ .../src/package-codemods/engine.node.test.ts | 628 +++++++++++ .../worker/src/package-codemods/engine.ts | 972 ++++++++++++++++++ .../src/package-codemods/ledger.node.test.ts | 140 +++ .../worker/src/package-codemods/ledger.ts | 396 +++++++ .../worker/src/package-codemods/registry.ts | 24 + .../package-codemods/subscription-events.ts | 238 +++++ packages/worker/src/package-codemods/types.ts | 18 + tools/migration-ledger.json | 4 + 11 files changed, 2954 insertions(+) create mode 100644 packages/worker/migrations/0111-package-codemod-ledger.sql create mode 100644 packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.node.test.ts create mode 100644 packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.ts create mode 100644 packages/worker/src/package-codemods/engine.node.test.ts create mode 100644 packages/worker/src/package-codemods/engine.ts create mode 100644 packages/worker/src/package-codemods/ledger.node.test.ts create mode 100644 packages/worker/src/package-codemods/ledger.ts create mode 100644 packages/worker/src/package-codemods/registry.ts create mode 100644 packages/worker/src/package-codemods/subscription-events.ts create mode 100644 packages/worker/src/package-codemods/types.ts 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..90f1c1dce7 --- /dev/null +++ b/packages/worker/migrations/0111-package-codemod-ledger.sql @@ -0,0 +1,39 @@ +-- 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, and check summaries 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, + 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/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..89bb923235 --- /dev/null +++ b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.node.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from 'vitest' +import { ambientStorageToPackageStorageCodemod } from './0001-ambient-storage-to-package-storage.ts' + +test('0001 migrates plain and mixed ambient storage imports, leaves aliases for manual work, and is idempotent', () => { + const plain = { + '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( + 'const storage = packageStorage()', + ) + expect(plainTransform.files['index.ts']).toContain("storage.get('k')") + expect(plainTransform.files['index.ts']).not.toMatch( + /import\s*\{\s*storage[\s,}]/, + ) + + const secondPass = ambientStorageToPackageStorageCodemod.transform( + plainTransform.files, + ) + expect(secondPass.changed).toBe(false) + expect(secondPass.changedPaths).toEqual([]) + expect(secondPass.files['index.ts']).toBe(plainTransform.files['index.ts']) + expect( + ambientStorageToPackageStorageCodemod.detect(plainTransform.files), + ).toEqual([]) + + const mixed = { + 'lib.ts': + "import { kody, storage, packages } from 'kody:runtime'\nexport const value = storage\n", + } + const mixedTransform = ambientStorageToPackageStorageCodemod.transform(mixed) + expect(mixedTransform.changed).toBe(true) + expect(mixedTransform.files['lib.ts']).toContain('packageStorage') + expect(mixedTransform.files['lib.ts']).toContain('kody') + expect(mixedTransform.files['lib.ts']).toContain('packages') + expect(mixedTransform.files['lib.ts']).toContain( + 'const storage = packageStorage()', + ) + expect(mixedTransform.files['lib.ts']).not.toMatch( + /import\s*\{[^}]*\bstorage\b/, + ) + + const aliased = { + 'alias.ts': + "import { storage as packageBucket } from 'kody:runtime'\nexport const value = packageBucket\n", + } + const aliasedDetect = ambientStorageToPackageStorageCodemod.detect(aliased) + expect(aliasedDetect).toHaveLength(1) + const aliasedTransform = + ambientStorageToPackageStorageCodemod.transform(aliased) + expect(aliasedTransform.changed).toBe(false) + expect(aliasedTransform.files['alias.ts']).toBe(aliased['alias.ts']) + expect(aliasedTransform.needsManual).toEqual([ + { + path: 'alias.ts', + message: expect.stringContaining('alias'), + }, + ]) + + const clean = { + 'clean.ts': + "import { packageStorage } from 'kody:runtime'\nconst storage = packageStorage()\nexport const value = storage\n", + 'readme.md': 'no code', + } + expect(ambientStorageToPackageStorageCodemod.detect(clean)).toEqual([]) + const cleanTransform = ambientStorageToPackageStorageCodemod.transform(clean) + expect(cleanTransform.changed).toBe(false) + expect(cleanTransform.needsManual).toEqual([]) + expect(cleanTransform.files).toEqual(clean) +}) 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..1148bc52bd --- /dev/null +++ b/packages/worker/src/package-codemods/codemods/0001-ambient-storage-to-package-storage.ts @@ -0,0 +1,411 @@ +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 packageStorageBindingStatement = 'const storage = packageStorage()' + +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 +} + +function getNodeName(node: { name?: unknown; value?: unknown } | undefined) { + if (!node) return null + if (typeof node.name === 'string') return node.name + if (typeof node.value === 'string') return node.value + return null +} + +function getProgramBody(source: string): Array | null { + let parsed: ModuleAstNode + try { + parsed = parseModuleSource(source) as unknown as ModuleAstNode + } catch { + return null + } + const program = parsed.program as { body?: Array } | undefined + const body = + program?.body ?? (parsed.body as Array | undefined) + return Array.isArray(body) ? body : null +} + +function listRuntimeStorageImports(source: string) { + const body = getProgramBody(source) + if (!body) return null + 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') { + hasUnusualSpecifiers = true + continue + } + if (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 { body, imports } +} + +function hasExportReexportOfStorage(body: Array) { + for (const node of body) { + if (node.type !== 'ExportNamedDeclaration') continue + const declaration = node as ModuleAstNode & { + source?: { value?: unknown } + specifiers?: Array<{ + type?: string + local?: { name?: unknown; value?: unknown } + exported?: { name?: unknown; value?: unknown } + }> + } + if (declaration.source?.value !== 'kody:runtime') continue + const specifiers = Array.isArray(declaration.specifiers) + ? declaration.specifiers + : [] + for (const specifier of specifiers) { + const localName = getNodeName(specifier.local) + const exportedName = getNodeName(specifier.exported) + if (localName === 'storage' || exportedName === 'storage') { + return true + } + } + } + return false +} + +function hasTopLevelStorageBindingBesidesImport(input: { + body: Array + importStarts: Set +}) { + for (const node of input.body) { + if ( + node.type === 'ImportDeclaration' && + typeof node.start === 'number' && + input.importStarts.has(node.start) + ) { + continue + } + if (node.type === 'VariableDeclaration') { + const declarations = (node as { declarations?: Array }) + .declarations + if (!Array.isArray(declarations)) continue + for (const declarator of declarations) { + const id = (declarator as { id?: ModuleAstNode }).id + if ( + id?.type === 'Identifier' && + getNodeName(id as never) === 'storage' + ) { + return true + } + } + } + if ( + node.type === 'FunctionDeclaration' || + node.type === 'ClassDeclaration' + ) { + const id = (node as { id?: ModuleAstNode }).id + if (id?.type === 'Identifier' && getNodeName(id as never) === 'storage') { + return true + } + } + } + return false +} + +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 findImportBlockInsertOffset(body: Array) { + let lastImportEnd: number | null = null + for (const node of body) { + if (node.type !== 'ImportDeclaration') { + if (lastImportEnd != null) break + continue + } + if (typeof node.end === 'number') { + lastImportEnd = node.end + } + } + return lastImportEnd +} + +function transformSourceFile(source: string): { + content: string + changed: boolean + needsManual: string | null +} { + const analyzed = listRuntimeStorageImports(source) + if (!analyzed || analyzed.imports.length === 0) { + return { content: source, changed: false, needsManual: null } + } + if (analyzed.imports.length > 1) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const target = analyzed.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(analyzed.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( + analyzed.imports + .map((entry) => entry.declaration.start) + .filter((start): start is number => typeof start === 'number'), + ) + if ( + hasTopLevelStorageBindingBesidesImport({ + body: analyzed.body, + importStarts, + }) + ) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + + let nextSource: string | null + if (target.hasPackageStorage) { + nextSource = removeSpecifierFromImportText({ + source, + declaration: target.declaration, + specifier: storageSpecifier, + }) + } else { + nextSource = replaceSpecifierNameInImportText({ + source, + specifier: storageSpecifier, + nextName: 'packageStorage', + }) + } + if (nextSource == null) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + + const reanalyzed = getProgramBody(nextSource) + if (!reanalyzed) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const insertAt = findImportBlockInsertOffset(reanalyzed) + if (insertAt == null) { + return { + content: source, + changed: false, + needsManual: manualUnusualMessage, + } + } + const alreadyBound = + nextSource.includes(packageStorageBindingStatement) || + /\bconst\s+storage\s*=\s*packageStorage\s*\(/.test(nextSource) + if (!alreadyBound) { + const before = nextSource.slice(0, insertAt) + const after = nextSource.slice(insertAt) + const prefix = before.endsWith('\n') ? '' : '\n' + const suffix = after.startsWith('\n') ? '' : '\n' + nextSource = `${before}${prefix}${packageStorageBindingStatement}${suffix}${after}` + } + if (nextSource === source) { + return { content: source, changed: false, needsManual: null } + } + return { content: nextSource, changed: true, needsManual: null } +} + +function detectAmbientStorage( + files: Record, +): Array { + return collectAmbientStorageImportFiles(files).map((path) => ({ + path, + message: ambientStorageDetectMessage, + })) +} + +function transformAmbientStorage( + files: Record, +): PackageCodemodTransformResult { + const nextFiles: Record = { ...files } + const changedPaths: Array = [] + const needsManual: Array = [] + const ambientPaths = collectAmbientStorageImportFiles(files) + for (const path of ambientPaths) { + 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) + } + changedPaths.sort((left, right) => left.localeCompare(right)) + needsManual.sort((left, right) => + (left.path ?? '').localeCompare(right.path ?? ''), + ) + return { + files: nextFiles, + changed: changedPaths.length > 0, + changedPaths, + needsManual, + } +} + +export const ambientStorageToPackageStorageCodemod = { + id: ambientStorageToPackageStorageCodemodId, + description: + "Replace ambient `storage` imports from 'kody:runtime' with `packageStorage()`.", + 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..5f97e20998 --- /dev/null +++ b/packages/worker/src/package-codemods/engine.node.test.ts @@ -0,0 +1,628 @@ +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 { + getPackageCodemodRunById, + listPackageCodemodRunItems, +} 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', + dispatchPackageCodemodSubscriptionEvent: (...args: Array) => + mocks.dispatchPackageCodemodSubscriptionEvent(...args), +})) + +const { 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) ?? null + }, + async put(key: string, value: string) { + store.set(key, value) + }, + 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(extra?: Record) { + 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", + ...extra, + } +} + +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'\nconst storage = packageStorage()\nexport async function run() {\n\treturn storage.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 scan dry-run apply revert drift unpublished isolation 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, + }) + const scanByPackage = Object.fromEntries( + scan.items.map((item) => [item.packageId, item.status]), + ) + expect(scanByPackage).toMatchObject({ + 'pkg-ambient': 'detected', + 'pkg-clean': 'clean', + 'pkg-drift': 'skipped_drift', + 'pkg-unpublished': 'skipped_unpublished', + 'pkg-fail': 'failed', + }) + expect( + scan.items.find((item) => item.packageId === 'pkg-fail')?.error, + ).toContain('source boom') + expect(await getPackageCodemodRunById(env.APP_DB, scan.runId)).toMatchObject({ + status: 'completed', + scopeUserId: 'user-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', + }, + ], + 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: 50, + }) + expect(dryRun.summary).toMatchObject({ + dry_run_ok: 1, + clean: 1, + }) + expect( + dryRun.items.find((item) => item.packageId === 'pkg-ambient'), + ).toMatchObject({ + status: 'dry_run_ok', + changedPaths: ['index.ts'], + checkSummary: { ok: true, newFailures: [] }, + }) + 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('const storage = packageStorage()') + 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: 50, + }) + expect(gated.items).toHaveLength(1) + expect(gated.items[0]?.status).toBe('dry_run_new_failures') + expect(gated.items[0]?.checkSummary?.newFailures).toEqual([ + 'lint:new failure only after transform', + ]) + 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' }], + manifest: {}, + sourceFiles: {}, + } + } + return { + ok: true, + results: [{ kind: 'lint', ok: true, message: 'ok' }], + 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: 50, + }) + expect(apply.items).toHaveLength(1) + expect(apply.items[0]).toMatchObject({ + status: 'applied', + packageId: 'pkg-ambient', + beforeCommit: 'commit-repo-ambient', + afterCommit: 'commit-after', + }) + expect(mocks.syncArtifactSourceSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + sourceId: 'source-ambient', + destructiveOverwriteConfirmed: true, + commitMessage: expect.stringContaining(`codemod(${codemodId})`), + files: expect.objectContaining({ + 'index.ts': expect.stringContaining('packageStorage'), + }), + }), + ) + const applyItemId = apply.items[0]!.itemId + const revertKey = `package-codemod-revert:${applyItemId}` + const snapshotRaw = await env.BUNDLE_ARTIFACTS_KV.get(revertKey) + expect(snapshotRaw).toBeTruthy() + const snapshot = JSON.parse(snapshotRaw!) as { + files: Record + beforeCommit: string + } + expect(snapshot.beforeCommit).toBe('commit-repo-ambient') + expect(snapshot.files['index.ts']).toContain( + "import { storage } from 'kody:runtime'", + ) + expect(mocks.dispatchPackageCodemodSubscriptionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'package.codemod.applied', + packageId: 'pkg-ambient', + itemId: applyItemId, + }), + ) + + const ledgerItems = await listPackageCodemodRunItems(env.APP_DB, { + runId: apply.runId, + limit: 10, + }) + expect(ledgerItems).toHaveLength(1) + expect(ledgerItems[0]?.status).toBe('applied') + + const otherUserApply = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-2', + codemodId, + mode: 'apply', + scope: { kind: 'user', userId: 'user-2' }, + filters: { packageIds: ['pkg-ambient'] }, + limit: 50, + }) + expect(otherUserApply.items).toEqual([]) + expect(otherUserApply.summary).toEqual({}) + + const user1CannotSeeUser2 = await runPackageCodemodStep({ + env, + baseUrl: 'https://example.com', + initiatedByUserId: 'user-1', + codemodId, + mode: 'scan', + scope: { kind: 'user', userId: 'user-1' }, + filters: { packageIds: ['pkg-other'] }, + limit: 50, + }) + expect(user1CannotSeeUser2.items).toEqual([]) + + 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: 50, + }) + expect(revert.items).toHaveLength(1) + expect(revert.items[0]).toMatchObject({ + status: 'reverted', + packageId: 'pkg-ambient', + afterCommit: 'commit-reverted', + }) + expect(mocks.syncArtifactSourceSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.objectContaining({ + 'index.ts': expect.stringContaining( + "import { storage } from 'kody:runtime'", + ), + }), + commitMessage: `revert codemod(${codemodId})`, + }), + ) + expect(mocks.dispatchPackageCodemodSubscriptionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + topic: 'package.codemod.reverted', + packageId: 'pkg-ambient', + }), + ) + + 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: 50, + }) + expect(user2CannotRevertUser1.items).toEqual([]) + + expect(kv.store.has(revertKey)).toBe(true) +}) diff --git a/packages/worker/src/package-codemods/engine.ts b/packages/worker/src/package-codemods/engine.ts new file mode 100644 index 0000000000..cc3ee177bb --- /dev/null +++ b/packages/worker/src/package-codemods/engine.ts @@ -0,0 +1,972 @@ +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, + updatePackageCodemodRunStatus, + type PackageCodemodRunRecord, +} from './ledger.ts' +import { getPackageCodemodById } from './registry.ts' +import { + 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> +} + +const defaultStepLimit = 20 +const maxStepLimit = 50 + +type CodemodRevertSnapshot = { + codemodId: string + userId: string + packageId: string + beforeCommit: string | null + files: Record +} + +function buildRevertSnapshotKvKey(itemId: string) { + return `package-codemod-revert:${itemId}` +} + +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 failureKeys(result: RepoCheckRunResult): Set { + const keys = new Set() + for (const check of result.results) { + if (check.ok) continue + keys.add(`${check.kind}:${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 matchesFilters( + savedPackage: SavedPackageRecord, + filters: PackageCodemodRunFilters | undefined, +) { + if (!filters) return true + if ( + filters.userIds != null && + filters.userIds.length > 0 && + !filters.userIds.includes(savedPackage.userId) + ) { + return false + } + if ( + filters.packageIds != null && + filters.packageIds.length > 0 && + !filters.packageIds.includes(savedPackage.id) + ) { + return false + } + return true +} + +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'] +}): PackageCodemodRunItemResult { + 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, + } +} + +function incrementSummary( + summary: Partial>, + status: PackageCodemodItemStatus, +) { + summary[status] = (summary[status] ?? 0) + 1 +} + +async function ensureRun(input: { + env: Env + runId?: string + codemodId: string + mode: PackageCodemodRunMode + scope: PackageCodemodRunScope + initiatedByUserId: string + filters?: PackageCodemodRunFilters + revertOfRunId?: string +}): Promise { + 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}".`, + ) + } + return existing + } + let scopeUserId: string | null + switch (input.scope.kind) { + case 'user': + scopeUserId = input.scope.userId + break + case 'fleet': + scopeUserId = null + break + default: { + const exhaustive: never = input.scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + } + return await createPackageCodemodRun(input.env.APP_DB, { + id: crypto.randomUUID(), + codemodId: input.codemodId, + mode: input.mode, + scopeUserId, + initiatedByUserId: input.initiatedByUserId, + filtersJson: JSON.stringify(input.filters ?? {}), + 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) => left.id.localeCompare(right.id)) + const startIndex = + cursor == null + ? 0 + : ordered.findIndex((savedPackage) => savedPackage.id > cursor!) + 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) => savedPackage.id <= last.id) + return { + packages, + nextCursor: exhausted ? null : (last?.id ?? null), + } + } + case 'fleet': { + for (;;) { + const page = await listSavedPackagesPage(input.env.APP_DB, { + afterId: cursor, + limit: input.limit, + }) + 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 < input.limit) { + return { packages, nextCursor: null } + } + } + } + default: { + const exhaustive: never = input.scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + } +} + +async function detectPublishedDrift(input: { + env: Env + repoId: string + publishedCommit: string +}): Promise { + try { + const head = await resolveArtifactSourceHead(input.env, input.repoId) + if (!head.commit) return true + return head.commit !== input.publishedCommit + } catch { + return true + } +} + +async function persistItem( + env: Env, + result: PackageCodemodRunItemResult, + 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, + }) +} + +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: PackageCodemodRunItemResult + } + | { + 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 +}): 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, + 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 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( + buildRevertSnapshotKvKey(gated.itemId), + JSON.stringify(snapshot), + ) + 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, + error: 'syncArtifactSourceSnapshot returned no 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, + }) + 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, + }) + } + 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 +}): 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 + for (;;) { + const page = await listPackageCodemodRunItems(input.env.APP_DB, { + runId: input.run.revertOfRunId, + afterId: cursor, + limit: input.limit, + status: 'applied', + }) + 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' && + priorItem.userId !== input.scope.userId + ) { + continue + } + if (input.scope.kind !== 'user' && input.scope.kind !== 'fleet') { + const exhaustive: never = input.scope + throw new Error(`Unknown package codemod scope: ${String(exhaustive)}`) + } + const itemId = crypto.randomUUID() + let result: PackageCodemodRunItemResult + try { + const raw = await input.env.BUNDLE_ARTIFACTS_KV.get( + buildRevertSnapshotKvKey(priorItem.id), + ) + 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 + 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 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, + }) + 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(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 < input.limit) { + 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, + } + } + } +} + +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}".`) + } + const limit = Math.min( + Math.max(input.limit ?? defaultStepLimit, 1), + maxStepLimit, + ) + 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)}`) + } + } + if (input.mode === 'revert' && !input.revertOfRunId && !input.runId) { + throw new Error('revert mode requires revertOfRunId.') + } + 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, + }) + 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, + }) + } + + 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: PackageCodemodRunItemResult + try { + result = await processPackageForMode({ + env: input.env, + baseUrl: input.baseUrl, + mode: input.mode, + codemod, + savedPackage, + runId: run.id, + waitUntil: input.waitUntil, + }) + } 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(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..5377b4fff6 --- /dev/null +++ b/packages/worker/src/package-codemods/ledger.node.test.ts @@ -0,0 +1,140 @@ +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, + 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' }], + }) + 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', + }) + + 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']) +}) diff --git a/packages/worker/src/package-codemods/ledger.ts b/packages/worker/src/package-codemods/ledger.ts new file mode 100644 index 0000000000..60ffde7f77 --- /dev/null +++ b/packages/worker/src/package-codemods/ledger.ts @@ -0,0 +1,396 @@ +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 + 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, created_at, updated_at` + +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 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']), + 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, +): Promise { + 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 + 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 ?? [] + 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, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.runId, + input.userId, + input.packageId, + input.kodyId, + input.status, + input.beforeCommit ?? null, + input.afterCommit ?? null, + JSON.stringify(changedPaths), + JSON.stringify(findings), + input.checkSummaryJson ?? null, + input.error ?? null, + 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, + findings, + checkSummaryJson: input.checkSummaryJson ?? null, + error: input.error ?? null, + 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 + 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(JSON.stringify(input.changedPaths)) + } + if (input.findings !== undefined) { + updates.push('findings_json = ?') + params.push(JSON.stringify(input.findings)) + } + if (input.checkSummaryJson !== undefined) { + updates.push('check_summary_json = ?') + params.push(input.checkSummaryJson) + } + if (input.error !== undefined) { + updates.push('error = ?') + params.push(input.error) + } + 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 + }, +): 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) + } + 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, +): Promise { + const row = await db + .prepare( + `SELECT ${itemSelectColumns} + FROM package_codemod_run_items + WHERE id = ?`, + ) + .bind(itemId) + .first>() + return row ? mapItemRow(row) : null +} 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..2a15550ff6 --- /dev/null +++ b/packages/worker/src/package-codemods/subscription-events.ts @@ -0,0 +1,238 @@ +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 +} + +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}` +} + +async function loadMatchingCodemodSubscriptions(input: { + env: Pick + baseUrl: string + userId: string + topic: PackageCodemodSubscriptionTopic +}) { + 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: [] as Array, + 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 } +} + +/** + * 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. + */ +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 +}) { + try { + const baseUrl = getAppBaseUrl({ env: input.env }) + const { subscriptions, discoveryErrors } = + await loadMatchingCodemodSubscriptions({ + 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, + }) + const settled = await runWithDynamicWorkerEvaluationBudget( + async () => + await Promise.allSettled( + subscriptions.map(async ({ savedPackage }) => { + const response = await invokePackageSubscription({ + env: input.env as Env, + baseUrl, + savedPackage, + topic: input.topic, + params: 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 (discoveryErrors.length > 0) { + console.warn('package codemod subscription discovery incomplete', { + topic: input.topic, + itemId: input.itemId, + errorCount: discoveryErrors.length, + error: discoveryErrors[0], + }) + } + return settled.map((result) => + result.status === 'fulfilled' ? result.value : null, + ) + } catch (error) { + console.warn('package codemod subscription dispatch failed', { + topic: input.topic, + itemId: input.itemId, + error, + }) + return [] + } +} 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..6d32950bbd 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": "127240ea9cd2beaa15cb035f2faf31111c2c2352189e691b8622ee5b71ac1942" } ] } From 68478d0309dcb6d7513a6b886caecfff55c35c17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:49:13 +0000 Subject: [PATCH 02/13] feat(admin): /admin/codemods operator UI Admin-only page + JSON routes to run codemods fleet-wide: paged run loop with live status counts, per-package results, run history, and revert on completed apply runs. Co-authored-by: Kent C. Dodds --- .../routes/account-management-components.tsx | 5 + packages/worker/client/routes/admin-area.ts | 4 + .../worker/client/routes/admin-codemods.tsx | 1010 +++++++++++++++++ packages/worker/client/routes/index.tsx | 7 + packages/worker/src/app/document-head.ts | 1 + .../app/handlers/admin-codemods.node.test.ts | 339 ++++++ .../worker/src/app/handlers/admin-codemods.ts | 368 ++++++ packages/worker/src/app/loader-data.ts | 49 + packages/worker/src/app/router.ts | 8 + packages/worker/src/app/routes.ts | 3 + 10 files changed, 1794 insertions(+) create mode 100644 packages/worker/client/routes/admin-codemods.tsx create mode 100644 packages/worker/src/app/handlers/admin-codemods.node.test.ts create mode 100644 packages/worker/src/app/handlers/admin-codemods.ts 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..44ec264e63 --- /dev/null +++ b/packages/worker/client/routes/admin-codemods.tsx @@ -0,0 +1,1010 @@ +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 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 stepLimit = '20' + let revertOfRunId = '' + + let runPhase: RunPhase = 'idle' + 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 limit = Number(stepLimit) + const body: Record = { + codemodId: input.codemodId, + mode: input.mode, + scope: 'fleet', + limit: Number.isInteger(limit) && limit > 0 ? limit : 20, + } + 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' + liveRunId = null + liveItems = [] + liveSummary = {} + message = null + messageTone = 'info' + pendingConfirmKey = null + handle.update() + + let runId: string | undefined + let cursor: string | null | undefined + try { + for (;;) { + const step = await postRunStep( + buildRunBody({ + runId, + cursor, + mode: input.mode, + codemodId: input.codemodId, + revertOfRunId: input.revertOfRunId, + }), + ) + if (!step) return + 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 + 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() + } + } + + 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') { + 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' ? ( + + ) : ( + + )} + {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/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..396f8606fb --- /dev/null +++ b/packages/worker/src/app/handlers/admin-codemods.node.test.ts @@ -0,0 +1,339 @@ +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' + +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/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() +}) + +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', +} + +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 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 }, + } + mockModule.getPackageCodemodById.mockReturnValue({ + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + detect: () => [], + transform: () => ({ + files: {}, + changed: false, + changedPaths: [], + needsManual: [], + }), + }) + mockModule.runPackageCodemodStep.mockResolvedValue(stepResult) + + mockModule.readAuthenticatedAppUser.mockResolvedValue(null) + const unauthorized = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'scan', + }), + ) + 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', + }), + ) + 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', + filters: { packageIds: ['pkg-1'] }, + limit: 10, + }), + ) + 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'] }, + limit: 10, + }) +}) + +test('admin codemods run POST rejects invalid mode and missing revertOfRunId', async () => { + const env = createTestEnv() + const handler = createAdminCodemodsRunApiHandler(env) + mockModule.readAuthenticatedAppUser.mockResolvedValue( + createAdminActor(['admin']), + ) + mockModule.getPackageCodemodById.mockReturnValue({ + id: '0001-ambient-storage-to-package-storage', + description: 'Migrate ambient storage imports.', + detect: () => [], + transform: () => ({ + files: {}, + changed: false, + changedPaths: [], + needsManual: [], + }), + }) + + const invalidMode = await handler.handler( + createRunRequest({ + codemodId: '0001-ambient-storage-to-package-storage', + mode: 'explode', + }), + ) + 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', + }), + ) + expect(missingRevert.status).toBe(400) + await expect(missingRevert.json()).resolves.toMatchObject({ + ok: false, + error: 'revert mode requires revertOfRunId.', + }) + expect(mockModule.runPackageCodemodStep).not.toHaveBeenCalled() +}) 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..f8f3a71571 --- /dev/null +++ b/packages/worker/src/app/handlers/admin-codemods.ts @@ -0,0 +1,368 @@ +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 { 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 defaultStepLimit = 20 +const maxStepLimit = 50 + +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 } + : {}), + }) + 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) + 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 = Math.min(parsedLimit, maxStepLimit) + } else { + limit = defaultStepLimit + } + + if (mode === 'revert' && !revertOfRunId && !runId) { + 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, +): { ok: true; scope: PackageCodemodRunScope } | { ok: false; error: string } { + const record = body as Record + if (!Object.hasOwn(record, 'scope') || record.scope == null) { + 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 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.' } + } + const packageIds = parseOptionalStringArray(filtersRecord, 'packageIds') + if (packageIds === false) { + return { + ok: false, + error: 'filters.packageIds must be an array of strings.', + } + } + if (!userIds && !packageIds) { + return { ok: true } + } + return { + ok: true, + filters: { + ...(userIds ? { userIds } : {}), + ...(packageIds ? { packageIds } : {}), + }, + } +} + +function parseOptionalStringArray( + record: Record, + key: string, +): Array | undefined | false { + 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 : undefined +} diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index a9b164e23f..0f997f8921 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' @@ -1161,6 +1209,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', From d7878de36a6304b4c0ab7fa23deb2becccef6a46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:49:13 +0000 Subject: [PATCH 03/13] feat(mcp): package codemod capabilities Self-scoped package_codemod_* capabilities in the packages domain (own packages only) and admin-gated, audit-logged fleet admin_package_codemod_* capabilities in the admin domain. Co-authored-by: Kent C. Dodds --- .../admin/admin-package-codemod-apply.ts | 55 +++++ ...-package-codemod-capabilities.node.test.ts | 196 +++++++++++++++ .../admin/admin-package-codemod-dry-run.ts | 54 +++++ .../admin/admin-package-codemod-revert.ts | 67 +++++ .../admin/admin-package-codemod-scan.ts | 54 +++++ .../src/mcp/capabilities/admin/domain.ts | 14 +- .../src/mcp/capabilities/packages/domain.ts | 15 +- .../packages/package-codemod-apply.ts | 40 +++ .../package-codemod-capabilities.node.test.ts | 228 ++++++++++++++++++ .../packages/package-codemod-dry-run.ts | 40 +++ .../packages/package-codemod-list.ts | 33 +++ .../packages/package-codemod-revert.ts | 62 +++++ .../packages/package-codemod-scan.ts | 40 +++ .../packages/package-codemod-shared.ts | 227 +++++++++++++++++ 14 files changed, 1123 insertions(+), 2 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-package-codemod-apply.ts create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-package-codemod-capabilities.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-package-codemod-dry-run.ts create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-package-codemod-revert.ts create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-package-codemod-scan.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-apply.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-capabilities.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-dry-run.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-list.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-revert.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-scan.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/package-codemod-shared.ts 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..6e0116fe44 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-capabilities.node.test.ts @@ -0,0 +1,228 @@ +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', + }) +}) + +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..9839bfab7a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-revert.ts @@ -0,0 +1,62 @@ +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 !== 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..6c0b4f690b --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/package-codemod-shared.ts @@ -0,0 +1,227 @@ +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 (default 20, max 50).'), +}) + +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(50) + .optional() + .describe('Max packages to process in this step (default 20, max 50).'), +}) + +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, + }) +} From 9073d3cbd95ab974c87be660dfd5d3afc0ef8b90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:49:13 +0000 Subject: [PATCH 04/13] docs: package codemods contributing guide and event topics Contributor doc for authoring/running codemods with the rollout doctrine, docs map entry, package.codemod.* subscription topics, and admin capability list update. Co-authored-by: Kent C. Dodds --- docs/contributing/adding-capabilities.md | 4 + docs/contributing/index.md | 1 + docs/contributing/package-codemods.md | 230 +++++++++++++++++++++++ docs/guides/package-subscriptions.md | 79 ++++++++ 4 files changed, 314 insertions(+) create mode 100644 docs/contributing/package-codemods.md 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/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..b79403ce56 --- /dev/null +++ b/docs/contributing/package-codemods.md @@ -0,0 +1,230 @@ +# 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`. + +Every codemod exports: + +```ts +type PackageCodemod = { + id: string // matches filename prefix, e.g. '0001-ambient-storage-to-package-storage' + description: string + detect(files: PackageFileTree): PackageCodemodFinding[] + transform(files: PackageFileTree): PackageCodemodTransformResult +} +``` + +`PackageCodemodTransformResult`: + +```ts +type PackageCodemodTransformResult = { + files: PackageFileTree + changed: boolean + changedPaths: string[] + needsManual: PackageCodemodFinding[] +} +``` + +- **`detect(files)`** — read-only scan. Returns findings (paths, messages, + severity) 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. + +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. + +The first shipped codemod is **`0001-ambient-storage-to-package-storage`**: it +replaces deprecated ambient `storage` imports from `kody:runtime` with +`packageStorage()`. + +## Engine + +The engine entry point is `runPackageCodemodStep` in +`packages/worker/src/package-codemods/engine.ts`. Long fleet runs are **paged** +(cursor + limit); operators drive repeated steps until the run completes. + +### 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). | +| `revert` | Republish the KV-stored pre-codemod tree from a prior apply run's ledger items. | + +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 repo HEAD has moved **past** the + published commit (user edited source after publish), the engine skips and + reports drift. It never overwrites unpublished work. +- **Apply snapshots** — before mutating published state, apply persists the + pre-codemod tree to KV keyed from the ledger so revert can restore it. +- **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`): + +- `package_codemod_runs` — run metadata (codemod id, mode, filters, cursor, + timestamps, aggregate counts). +- `package_codemod_run_items` — one row per package attempt (status, findings, + commit before/after, KV revert pointer, error text). + +The ledger makes runs **resumable** (page forward with cursor), **auditable** +(who migrated what, when, with which commits), and **revertible** (revert mode +reads stored pre-codemod snapshots from prior apply items). 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. + +### 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 and records the pointer on +the ledger item. **Revert** mode loads that snapshot for a chosen prior apply +item and republishes it through the same `syncArtifactSourceSnapshot` path, +restoring `entity_sources.published_commit`, KV snapshots, and D1 projections to +the pre-migration state. Revert dispatches `package.codemod.reverted` to +subscribers (see [Package subscriptions](../guides/package-subscriptions.md)). + +Revert does not restore Artifacts working-copy edits made after apply; packages +in `skipped_drift` were never mutated by apply. + +## 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..adc5ca7d24 100644 --- a/docs/guides/package-subscriptions.md +++ b/docs/guides/package-subscriptions.md @@ -258,6 +258,85 @@ 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 PackageCodemodAppliedEvent = { + 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 + after_commit: string +} +``` + +`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. + +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: + +```ts +type PackageCodemodRevertedEvent = { + 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 + after_commit: string +} +``` + +For revert, `before_commit` is the post-codemod published commit and +`after_commit` is the restored pre-codemod commit. `changed_paths` reflects +paths that differ between those commits after revert completes. + +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 From add84eb148bc4cedf5bbebd11e656906112d7052 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:50:33 +0000 Subject: [PATCH 05/13] docs: register package-codemods primitive and admin capability list Co-authored-by: Kent C. Dodds --- docs/contributing/architecture/primitives.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From 395db239224844044c0f32648f65e5ebe0ea03f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 12:22:18 +0000 Subject: [PATCH 06/13] fix(codemods): harden engine and 0001 codemod after independent review - 0001 rewrites storage call sites to packageStorage() (no module-scope binding), AST-verifies no free storage identifiers remain, gates packages with app/service/job/subscription surfaces as needs_manual (storage bucket-identity hazard), and treats parse failures as needs_manual - revert snapshots are user-namespaced KV keys with 90-day TTL, recorded on ledger items (revert_snapshot_key); revert validates snapshot ownership - drift re-checked immediately before every publish; revert drift-checks against the apply item's afterCommit; failed publishes keep their itemId and snapshot key - ledger text columns bounded per backup-restore safety policy; run resume validates scope; user paging cursor comparison fixed; fleet paging bounds scanned pages and always advances; subscription fan-out cached per user and deferred via waitUntil; dry-run/apply/revert limits lowered to 5/10 - new-failure identity normalizes positions; fleet runs self-revertible for the caller's own items; re-revert marks source items reverted - /admin/codemods registered in the lazy-route admin area; ledger tables registered for account export/deletion; admin run steps audit-logged; apply/revert require explicit scope; run loop gains stop control, step ceiling, and stuck-cursor guard; docs reconciled Co-authored-by: Kent C. Dodds --- docs/contributing/package-codemods.md | 142 +++-- docs/guides/package-subscriptions.md | 26 +- packages/worker/client/lazy-route.tsx | 1 + .../worker/client/routes/admin-codemods.tsx | 77 ++- .../0111-package-codemod-ledger.sql | 4 +- packages/worker/src/account/data-targets.ts | 21 + .../app/handlers/admin-codemods.node.test.ts | 106 +++- .../worker/src/app/handlers/admin-codemods.ts | 51 +- .../package-codemod-capabilities.node.test.ts | 32 ++ .../packages/package-codemod-revert.ts | 5 +- ...nt-storage-to-package-storage.node.test.ts | 147 ++++- ...0001-ambient-storage-to-package-storage.ts | 537 ++++++++++++++---- .../src/package-codemods/engine.node.test.ts | 489 ++++++++++++---- .../worker/src/package-codemods/engine.ts | 491 +++++++++++----- .../src/package-codemods/ledger.node.test.ts | 2 + .../worker/src/package-codemods/ledger.ts | 121 +++- .../package-codemods/subscription-events.ts | 205 ++++--- tools/migration-ledger.json | 2 +- 18 files changed, 1846 insertions(+), 613 deletions(-) diff --git a/docs/contributing/package-codemods.md b/docs/contributing/package-codemods.md index b79403ce56..45b16ab961 100644 --- a/docs/contributing/package-codemods.md +++ b/docs/contributing/package-codemods.md @@ -45,30 +45,31 @@ Each codemod lives at `0001-ambient-storage-to-package-storage.ts`) and is registered in `packages/worker/src/package-codemods/registry.ts`. -Every codemod exports: +Types in `packages/worker/src/package-codemods/types.ts`: ```ts -type PackageCodemod = { - id: string // matches filename prefix, e.g. '0001-ambient-storage-to-package-storage' - description: string - detect(files: PackageFileTree): PackageCodemodFinding[] - transform(files: PackageFileTree): PackageCodemodTransformResult +type PackageCodemodFinding = { + path: string | null + message: string } -``` - -`PackageCodemodTransformResult`: -```ts type PackageCodemodTransformResult = { - files: PackageFileTree + files: Record changed: boolean - changedPaths: string[] - needsManual: PackageCodemodFinding[] + changedPaths: Array + needsManual: Array +} + +type PackageCodemod = { + id: string + description: string + detect(files: Record): Array + transform(files: Record): PackageCodemodTransformResult } ``` -- **`detect(files)`** — read-only scan. Returns findings (paths, messages, - severity) without mutating the tree. Used for fleet discovery and reporting. +- **`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. @@ -95,15 +96,45 @@ transformed map. - a required symbol cannot be resolved from static analysis alone, or - the codemod would delete user logic to satisfy the migration. -The first shipped codemod is **`0001-ambient-storage-to-package-storage`**: it -replaces deprecated ambient `storage` imports from `kody:runtime` with -`packageStorage()`. +### `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 fleet runs are **paged** -(cursor + limit); operators drive repeated steps until the run completes. +`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 @@ -111,8 +142,8 @@ The engine entry point is `runPackageCodemodStep` in | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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). | -| `revert` | Republish the KV-stored pre-codemod tree from a prior apply run's ledger items. | +| `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. @@ -120,11 +151,15 @@ items in the same run step. ### Safety rails - **`skipped_unpublished`** — packages with no published commit are skipped. -- **`skipped_drift`** — when Artifacts repo HEAD has moved **past** the - published commit (user edited source after publish), the engine skips and - reports drift. It never overwrites unpublished work. -- **Apply snapshots** — before mutating published state, apply persists the - pre-codemod tree to KV keyed from the ledger so revert can restore it. +- **`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. @@ -138,17 +173,28 @@ Each per-package row in a run records one of: ## Ledger Every run and per-package item is stored in D1 (migration -`0111-package-codemod-ledger.sql`): +`0111-package-codemod-ledger.sql`). Pagination cursors live on step responses, +not in the ledger tables. -- `package_codemod_runs` — run metadata (codemod id, mode, filters, cursor, - timestamps, aggregate counts). -- `package_codemod_run_items` — one row per package attempt (status, findings, - commit before/after, KV revert pointer, error text). +**`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`. -The ledger makes runs **resumable** (page forward with cursor), **auditable** -(who migrated what, when, with which commits), and **revertible** (revert mode -reads stored pre-codemod snapshots from prior apply items). All rows are scoped -by the owning user's saved package identity; cross-user reads are a bug. +**`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 @@ -169,6 +215,10 @@ Authenticated users can migrate **their own** saved packages: - `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) @@ -204,15 +254,17 @@ allows new packages to reintroduce debt. ## Revert -Apply persists the pre-codemod published tree to KV and records the pointer on -the ledger item. **Revert** mode loads that snapshot for a chosen prior apply -item and republishes it through the same `syncArtifactSourceSnapshot` path, -restoring `entity_sources.published_commit`, KV snapshots, and D1 projections to -the pre-migration state. Revert dispatches `package.codemod.reverted` to -subscribers (see [Package subscriptions](../guides/package-subscriptions.md)). - -Revert does not restore Artifacts working-copy edits made after apply; packages -in `skipped_drift` were never mutated by apply. +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. ## Subscription events diff --git a/docs/guides/package-subscriptions.md b/docs/guides/package-subscriptions.md index adc5ca7d24..82826e11e8 100644 --- a/docs/guides/package-subscriptions.md +++ b/docs/guides/package-subscriptions.md @@ -270,7 +270,7 @@ are logged and do not fail the codemod apply. Handlers receive a metadata-first payload: ```ts -type PackageCodemodAppliedEvent = { +type PackageCodemodSubscriptionEnvelope = { event: 'package.codemod.applied' codemod: { id: string @@ -285,8 +285,8 @@ type PackageCodemodAppliedEvent = { item_id: string } changed_paths: Array - before_commit: string - after_commit: string + before_commit: string | null + after_commit: string | null } ``` @@ -295,7 +295,7 @@ type PackageCodemodAppliedEvent = { 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. +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. @@ -307,10 +307,11 @@ After a successful package codemod **revert**, Kody dispatches restored package that declare the topic. Delivery semantics match `package.codemod.applied` and `run.error.recorded`. -Handlers receive: +Handlers receive the same envelope shape with +`event: 'package.codemod.reverted'`: ```ts -type PackageCodemodRevertedEvent = { +type PackageCodemodSubscriptionEnvelope = { event: 'package.codemod.reverted' codemod: { id: string @@ -325,14 +326,17 @@ type PackageCodemodRevertedEvent = { item_id: string } changed_paths: Array - before_commit: string - after_commit: string + before_commit: string | null + after_commit: string | null } ``` -For revert, `before_commit` is the post-codemod published commit and -`after_commit` is the restored pre-codemod commit. `changed_paths` reflects -paths that differ between those commits after revert completes. +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. 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/admin-codemods.tsx b/packages/worker/client/routes/admin-codemods.tsx index 44ec264e63..3054439e54 100644 --- a/packages/worker/client/routes/admin-codemods.tsx +++ b/packages/worker/client/routes/admin-codemods.tsx @@ -52,6 +52,7 @@ type LiveRunItem = { const adminCodemodsApiPath = '/admin/codemods.json' const adminCodemodsRunApiPath = '/admin/codemods/run.json' +const maxRunSteps = 200 const runModes = [ 'scan', @@ -139,10 +140,10 @@ export function AdminCodemodsRoute(handle: Handle) { let selectedMode: RunMode = 'scan' let userIdsFilter = '' let packageIdsFilter = '' - let stepLimit = '20' let revertOfRunId = '' let runPhase: RunPhase = 'idle' + let stopRequested = false let liveRunId: string | null = null let liveItems: Array = [] let liveSummary: Record = {} @@ -290,12 +291,10 @@ export function AdminCodemodsRoute(handle: Handle) { }) { const userIds = parseCommaSeparatedIds(userIdsFilter) const packageIds = parseCommaSeparatedIds(packageIdsFilter) - const limit = Number(stepLimit) const body: Record = { codemodId: input.codemodId, mode: input.mode, scope: 'fleet', - limit: Number.isInteger(limit) && limit > 0 ? limit : 20, } if (userIds.length > 0 || packageIds.length > 0) { body.filters = { @@ -347,6 +346,7 @@ export function AdminCodemodsRoute(handle: Handle) { revertOfRunId?: string }) { runPhase = 'running' + stopRequested = false liveRunId = null liveItems = [] liveSummary = {} @@ -357,8 +357,27 @@ export function AdminCodemodsRoute(handle: Handle) { 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, @@ -369,6 +388,7 @@ export function AdminCodemodsRoute(handle: Handle) { }), ) if (!step) return + stepCount += 1 runId = step.runId liveRunId = step.runId ?? null if (Array.isArray(step.items)) { @@ -391,6 +411,17 @@ export function AdminCodemodsRoute(handle: Handle) { } 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' @@ -406,6 +437,8 @@ export function AdminCodemodsRoute(handle: Handle) { : 'Unable to complete package codemod run.' messageTone = 'error' handle.update() + } finally { + stopRequested = false } } @@ -550,7 +583,7 @@ export function AdminCodemodsRoute(handle: Handle) {
- {selectedMode === 'revert' ? (