From 459386db0a35ed2f2f71b1d6d1f175a5bd2045b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 17:17:00 +0000 Subject: [PATCH 1/2] Remove the legacy dynamic invocation surface with teaching errors Narrow phase of the static-first two-rule model (#1048), gated on the fleet codemod scan showing zero legacy usage across all production packages: - packages.check and packages.invokeChecked no longer exist: the host tool set carries only invoke, and the sandbox prelude (execute and package runtimes, plus the package-app bridge) throws teaching errors naming the exact replacement. - Literal dynamic import("kody:@...") is rewritten at bundle time to a teaching error; no placeholder modules or dynamic-dependency metadata are produced. Hydration keeps resolving placeholders inside bundles published before the removal so pinned snapshots keep working until dependents republish. - Publish checks escalate from non-fatal deprecation warnings to failing lint results naming the replacement and the 0002-static-first-invocation codemod (same collector keeps codemod findings in lockstep). - The invoke contract check drops the dead export-projection branch that only packages.check consumed. - Docs flip widen-phase deprecation notes to removed/teaching text. Co-authored-by: Kent C. Dodds --- docs/contributing/packages-and-manifests.md | 32 ++-- docs/use/execute.md | 16 +- docs/use/packages.md | 21 ++- .../instructions/execute-tool-description.ts | 2 +- .../src/mcp/run-kody-registry.node.test.ts | 46 +---- .../worker/src/mcp/runtime-helper-manifest.ts | 52 ++---- .../src/package-invocations/http-invoke.ts | 4 +- .../src/package-invocations/invoke-check.ts | 105 ++--------- .../runtime-tool-factories.ts | 30 +--- .../package-invocations/service.node.test.ts | 167 +++--------------- .../deprecated-invocation-usage.ts | 42 +++-- .../module-graph-import-rewriting.ts | 50 ++---- .../package-runtime/module-graph.node.test.ts | 31 +--- .../module-graph.workers.test.ts | 135 ++++++-------- .../worker/src/package-runtime/package-app.ts | 26 +-- .../package-runtime/runtime-source-modules.ts | 43 ++--- packages/worker/src/repo/checks.node.test.ts | 38 ++-- packages/worker/src/repo/checks.ts | 23 ++- 18 files changed, 250 insertions(+), 613 deletions(-) diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index dc46d61759..02e7b33a49 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -118,13 +118,13 @@ A saved package is the only top-level persisted primitive. Five concepts: publish-time artifact rebuilds, Kody records the imported saved package's published commit in bundle dependency metadata. Republishing the imported package does not rewrite already-published dependent bundles. -- Literal dynamic imports such as `await import("kody:@scope/pkg/export")` are - **deprecated agent guidance** (keep the mechanism working; stop recommending - it — the replacements are static imports when the name is known at write time - and `packages.invoke` otherwise). Mechanically they are runtime/current - package dependencies: bundle artifacts persist only a host-resolved - placeholder plus review metadata; just before execution, Kody resolves the - target package under the caller's `userId` and hydrates the current published +- Literal dynamic imports such as `await import("kody:@scope/pkg/export")` were + **removed** (the replacements are static imports when the name is known at + write time and `packages.invoke` otherwise). New bundles rewrite the call site + to a teaching error and publish checks fail on the pattern. For bundles + published before the removal, artifacts persisted a host-resolved placeholder + plus review metadata; just before execution, Kody resolves the target package + under the caller's `userId` and hydrates the current published `importable-module` artifact into the dynamic worker module graph. - Direct static `kody:@...` imports are a breaking manifest contract: they must be listed in `package.json#kody.dependencies` by package name, for example @@ -261,15 +261,15 @@ statically importing subscriber packages. Republish subscribers independently; the dispatcher will observe the current published subscriber export on its next dispatch without being republished. -**Deprecated (widen phase):** `packages.invokeChecked`, `packages.check`, and -literal dynamic `import("kody:@...")` keep working while callers migrate, but no -guidance surface (tool descriptions, search detail, error `nextStep` strings, -docs) may recommend them. `packages.invoke` subsumes both helpers because -checking is no longer optional or separate (`invokeChecked` is a plain alias of -`invoke`). The sandbox prelude warns once per run on `check` / `invokeChecked`, -the dynamic import helper warns once per specifier, and publish checks surface -non-fatal deprecation warnings in the passing lint message -(`deprecated-invocation-usage.ts`). See +**Removed (narrow phase):** `packages.invokeChecked`, `packages.check`, and +literal dynamic `import("kody:@...")` were removed. `packages.invoke` subsumes +both helpers because checking is not optional or separate. The sandbox prelude +throws teaching errors for `check` / `invokeChecked`, the bundler rewrites +literal dynamic kody imports to a teaching error, and publish checks fail on all +three with the replacement named (`deprecated-invocation-usage.ts`, shared with +the `0002-static-first-invocation` package codemod). Hydration still resolves +placeholder modules inside bundles published before the removal so pinned +snapshots keep working until dependents republish. See [Invocation overhead guardrails](./architecture/invocation-overhead-guardrails.md) for the performance budget that keeps the keyless path honest. diff --git a/docs/use/execute.md b/docs/use/execute.md index 7e5e7a8c80..018cd9b86c 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -79,12 +79,11 @@ package artifacts do not contain a copy of the host runtime implementation, so old package artifacts automatically observe current host runtime behavior. Literal dynamic imports (`await import('kody:@scope/my-package/export-name')`) -are **deprecated**. They still resolve at runtime for the signed-in user (and -log a deprecation warning naming the replacement), but do not write new code -with them: use a static `kody:@...` import when the target package's name is -known when the code is written, or `packages.invoke` when it is not. Computed -dynamic Kody package imports, including variables and template strings, have -never been supported. +were **removed**. The call site throws a teaching error naming the replacement: +use a static `kody:@...` import when the target package's name is known when the +code is written, or `packages.invoke` when it is not. Computed dynamic Kody +package imports, including variables and template strings, have never been +supported. **execute** also accepts optional **`params`**. Kody passes that JSON object to the module's **default export** as the first function argument. Shared helpers @@ -117,8 +116,9 @@ Execute responses include Server-Timing-style phase entries under - `bundle` — module-graph preparation and bundling. This span contains the typecheck phases below, so subtract `typecheck-total` for bundler-only time. -- `hydrate` — installing published sources for literal dynamic - `import("kody:@…")` targets (a deprecated pattern). +- `hydrate` — refreshing nested runtime modules (and resolving literal dynamic + `import("kody:@…")` placeholders in bundles published before that pattern was + removed). - `provider-assembly` — capability registry, runtime helper, and provider wiring ahead of sandbox startup. - `sandbox` — the dynamic worker evaluation of the module itself. diff --git a/docs/use/packages.md b/docs/use/packages.md index db9e8a1b0e..f818030099 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -148,10 +148,9 @@ exhaustive. Ad hoc execute code bundles per call, so static imports from execute always see the current published version. - Literal dynamic imports such as - `await import("kody:@scope/my-package/export")` are **deprecated**. They still - resolve the target package export at runtime for the signed-in user, but log a - deprecation warning (once per specifier) naming the replacement. Do not write - new code with them: use a static import when the name is known at write time, + `await import("kody:@scope/my-package/export")` were **removed**. The call + site throws a teaching error naming the replacement, and package publish + checks fail on them: use a static import when the name is known at write time, or `packages.invoke` when it is not (see [Dynamic package invocation](#dynamic-package-invocation)). - Every direct static `kody:@...` import must be declared in @@ -262,13 +261,13 @@ Use keyless `packages.invoke` from execute when you need to enter a saved package as that package so it receives `packageContext`, package-owned storage, package-mounted secrets (`kody.secretMounts`), and its own `packages` helper. -**Deprecated:** `packages.invokeChecked`, `packages.check`, and literal dynamic -`import("kody:@...")` still work but should not appear in new code. -`packages.invoke` already performs the contract check that `invokeChecked` and -`check` provided (`invokeChecked` is now a plain alias of `invoke`), and the -static/dynamic rules above cover the literal dynamic import cases. Using any of -the three logs a runtime deprecation warning naming the replacement, and package -publish checks report them as non-fatal warnings. +**Removed:** `packages.invokeChecked`, `packages.check`, and literal dynamic +`import("kody:@...")` no longer exist. Calling any of them throws a teaching +error naming the replacement, and package publish checks fail on them. +`packages.invoke` performs the contract check that `invokeChecked` and `check` +provided, and the static/dynamic rules above cover the literal dynamic import +cases. The `0002-static-first-invocation` package codemod migrates +`invokeChecked` call sites mechanically. ## Package storage diff --git a/packages/worker/src/mcp/instructions/execute-tool-description.ts b/packages/worker/src/mcp/instructions/execute-tool-description.ts index 4940bca5e1..8e050c00bf 100644 --- a/packages/worker/src/mcp/instructions/execute-tool-description.ts +++ b/packages/worker/src/mcp/instructions/execute-tool-description.ts @@ -14,7 +14,7 @@ export const executeToolSandboxSurfaceDescription = `Sandbox surface: - Execute has a hard timeout (~90s by default). For batch sweeps, migrations, polling loops, or work likely to run >~60s, submit one durable \`workflows.create({ code, params })\` from a single execute call instead of chaining many MCP round-trips. - Optional \`params\` are passed as the first argument to the module default export. Prefer \`export default async function main(input = {}) { ... }\`; pass \`input\` to shared helpers explicitly. - \`import { packageContext } from 'kody:runtime'\` in saved package code when you need package metadata; it is \`null\` for ad hoc execute calls. -- Package reuse, two rules. (1) Target package name known when the code is written → static import: \`import fn from 'kody:@scope/my-package/export-name'\` (npm-scoped package name). This is the default from execute and from other packages: typed, publish-verified, dependency-graph-visible, zero per-call platform cost. Ad hoc execute bundles per call, so static imports from execute always see the current published version. (2) Target name is data, the call needs the target package's own runtime (\`packageContext\`, \`kody.secretMounts\` package secrets, its own \`packageStorage()\` bucket), or you need exactly-once → \`import { packages } from 'kody:runtime'\` and \`packages.invoke({ kodyId: 'github', exportName, params })\`, the only dynamic call, always contract-checked before invoking. \`kodyId\` is the bare Kody id (for example \`github\`), not the npm-scoped name. Keyless invoke is lean/ephemeral (current published version, run records on failure only); add the optional \`idempotencyKey\` field only for exactly-once needs such as webhook event ids and retried dispatch. \`packages.invokeChecked\`, \`packages.check\`, and literal dynamic \`import("kody:@...")\` are deprecated — do not use them in new code. +- Package reuse, two rules. (1) Target package name known when the code is written → static import: \`import fn from 'kody:@scope/my-package/export-name'\` (npm-scoped package name). This is the default from execute and from other packages: typed, publish-verified, dependency-graph-visible, zero per-call platform cost. Ad hoc execute bundles per call, so static imports from execute always see the current published version. (2) Target name is data, the call needs the target package's own runtime (\`packageContext\`, \`kody.secretMounts\` package secrets, its own \`packageStorage()\` bucket), or you need exactly-once → \`import { packages } from 'kody:runtime'\` and \`packages.invoke({ kodyId: 'github', exportName, params })\`, the only dynamic call, always contract-checked before invoking. \`kodyId\` is the bare Kody id (for example \`github\`), not the npm-scoped name. Keyless invoke is lean/ephemeral (current published version, run records on failure only); add the optional \`idempotencyKey\` field only for exactly-once needs such as webhook event ids and retried dispatch. \`packages.invokeChecked\`, \`packages.check\`, and literal dynamic \`import("kody:@...")\` were removed and throw errors naming the replacement. - \`fetch(...)\` is the host-provided network global; \`{{secret:name}}\` / \`{{secret:name|scope=user}}\` work in URL, headers, or body on approved hosts only. \`secretHeaders.basic(...)\` returns an opaque placeholder for fetch headers; the gateway resolves both referenced secrets, enforces host approval for both, and sends only the derived Basic header. For host approval failures, use the error’s approval path. - Fields marked \`x-kody-secret: true\` accept the same placeholder form; respect per-secret allowed-capability lists. - Placeholders are not general string interpolation (they do not resolve in arbitrary return values). Never place placeholder text into user-visible or third-party-visible content such as issue bodies, comments, prompts, logs, or returned strings; obfuscate the \`{{secret:...}}\` token if you must describe it literally. diff --git a/packages/worker/src/mcp/run-kody-registry.node.test.ts b/packages/worker/src/mcp/run-kody-registry.node.test.ts index 56dad533fd..4422afbc66 100644 --- a/packages/worker/src/mcp/run-kody-registry.node.test.ts +++ b/packages/worker/src/mcp/run-kody-registry.node.test.ts @@ -1909,25 +1909,7 @@ test('runBundledModuleWithRegistry passes params and injects runtime helpers', a undefined, { packageInvokeTools: { - check: async (input) => ({ - ok: true, - invoke: input as { - kodyId: string - exportName: string - }, - contract: { - packageId: 'pkg-discord-general-chat', - kodyId: 'discord-general-chat', - name: '@kentcdodds/discord-general-chat', - sourceId: 'source-discord-general-chat', - publishedCommit: 'commit-1', - exportName: './handle-discord-message-created', - runtimeTarget: 'src/handle-discord-message-created.ts', - warnings: [], - }, - }), invoke: async (input) => ({ ok: true, input }), - invokeChecked: async (input) => ({ ok: true, input }), }, }, ) @@ -1937,18 +1919,10 @@ test('runBundledModuleWithRegistry passes params and injects runtime helpers', a expect(providerFns?.package_invoke).toBeUndefined() expect(providerFns?.package_invoke_checked).toBeUndefined() expect(packageBridgeFns).not.toBeNull() - await expect( - packageBridgeFns?.check({ - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - }), - ).resolves.toMatchObject({ - ok: true, - invoke: { - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - }, - }) + // The removed check/invokeChecked APIs have no bridge tools; only + // invoke crosses the sandbox boundary. + expect(packageBridgeFns?.check).toBeUndefined() + expect(packageBridgeFns?.invokeChecked).toBeUndefined() await expect( packageBridgeFns?.invoke({ kodyId: 'discord-general-chat', @@ -1961,18 +1935,6 @@ test('runBundledModuleWithRegistry passes params and injects runtime helpers', a exportName: './handle-discord-message-created', }, }) - await expect( - packageBridgeFns?.invokeChecked({ - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - }), - ).resolves.toEqual({ - ok: true, - input: { - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - }, - }) createExecuteExecutorSpy.mockImplementation( () => diff --git a/packages/worker/src/mcp/runtime-helper-manifest.ts b/packages/worker/src/mcp/runtime-helper-manifest.ts index 9578d3f9ee..18845d9ee4 100644 --- a/packages/worker/src/mcp/runtime-helper-manifest.ts +++ b/packages/worker/src/mcp/runtime-helper-manifest.ts @@ -98,8 +98,6 @@ export type PackageInvokeCheckResult = export type PackageInvokeTools = { invoke: (input: PackageInvokeInput) => Promise - check: (input: PackageInvokeInput) => Promise - invokeChecked: (input: PackageInvokeInput) => Promise } export type PackageEventDispatchInput = { @@ -248,33 +246,29 @@ const workflows = { const packageInvokeRuntimeBridgeProviderName = '__kodyPackageInvokeRuntimeBridge' const packageEventRuntimeBridgeProviderName = '__kodyPackageEventRuntimeBridge' + +export const removedPackagesCheckMessage = + 'packages.check was removed: packages.invoke always contract-checks before invoking, so call packages.invoke({ kodyId, exportName, params }) directly. A failing contract rejects with "packages.invoke contract check failed: ..." before any execution.' + +export const removedPackagesInvokeCheckedMessage = + 'packages.invokeChecked was removed: call packages.invoke({ kodyId, exportName, params }) instead — it is always contract-checked (add idempotencyKey only when you need exactly-once) — or use a static import (import fn from "kody:@scope/pkg/export") when the target package is known at write time.' const staticCallMeterRuntimeBridgeProviderName = '__kodyStaticCallMeterRuntimeBridge' function createPackagesHelperPrelude() { - // `check` and `invokeChecked` are deprecated widen-phase shims: they keep - // working but warn once per run naming the replacement, because agents - // learn the current contract from logs and error text. + // `check` and `invokeChecked` were removed with the static-first model. + // They throw teaching errors naming the exact replacement because agents + // learn the current contract from error text. return ` -const packages = (() => { - const warned = new Set(); - const warnOnce = (name, message) => { - if (warned.has(name)) return; - warned.add(name); - console.warn(message); - }; - return { - check: async (input) => { - warnOnce('check', '[deprecated] packages.check: packages.invoke always contract-checks before invoking, so call packages.invoke({ kodyId, exportName, params }) directly (add idempotencyKey only when you need exactly-once).'); - return await ${packageInvokeRuntimeBridgeProviderName}.check(input ?? {}); - }, - invoke: async (input) => await ${packageInvokeRuntimeBridgeProviderName}.invoke(input ?? {}), - invokeChecked: async (input) => { - warnOnce('invokeChecked', '[deprecated] packages.invokeChecked: use a static import (import fn from "kody:@scope/pkg/export") when the target package is known at write time, or packages.invoke({ kodyId, exportName, params }) for dynamic targets (add idempotencyKey only when you need exactly-once).'); - return await ${packageInvokeRuntimeBridgeProviderName}.invokeChecked(input ?? {}); - }, - }; -})(); +const packages = { + check: () => { + throw new Error(${JSON.stringify(removedPackagesCheckMessage)}); + }, + invoke: async (input) => await ${packageInvokeRuntimeBridgeProviderName}.invoke(input ?? {}), + invokeChecked: () => { + throw new Error(${JSON.stringify(removedPackagesInvokeCheckedMessage)}); + }, +}; `.trim() } @@ -428,20 +422,10 @@ function createPackageInvokeRuntimeBridgeProvider( const provider: ToolProvider = { name: packageInvokeRuntimeBridgeProviderName, tools: { - check: { - execute: async (args: unknown) => - await packageInvokeTools.check((args ?? {}) as PackageInvokeInput), - }, invoke: { execute: async (args: unknown) => await packageInvokeTools.invoke((args ?? {}) as PackageInvokeInput), }, - invokeChecked: { - execute: async (args: unknown) => - await packageInvokeTools.invokeChecked( - (args ?? {}) as PackageInvokeInput, - ), - }, }, } return resolveProvider(provider) diff --git a/packages/worker/src/package-invocations/http-invoke.ts b/packages/worker/src/package-invocations/http-invoke.ts index fc42ad9106..63ce263b16 100644 --- a/packages/worker/src/package-invocations/http-invoke.ts +++ b/packages/worker/src/package-invocations/http-invoke.ts @@ -78,7 +78,7 @@ export async function invokePackageExportForExecuteRuntime(input: { conversationId?: string | null toolFactories: PackageRuntimeToolFactories waitUntil?: (promise: Promise) => void - /** Check-phase loads from `packages.invokeChecked`; see invoke-check.ts. */ + /** Check-phase loads from the `packages.invoke` contract check; see invoke-check.ts. */ preloads?: PackageInvokeCheckPreloads | null }): Promise { const packageIdOrKodyId = input.request.packageIdOrKodyId.trim() @@ -164,7 +164,7 @@ export async function invokePackageExportForPackageRuntime(input: { runtimeInvokeDepth?: number toolFactories: PackageRuntimeToolFactories waitUntil?: (promise: Promise) => void - /** Check-phase loads from `packages.invokeChecked`; see invoke-check.ts. */ + /** Check-phase loads from the `packages.invoke` contract check; see invoke-check.ts. */ preloads?: PackageInvokeCheckPreloads | null }): Promise { const packageIdOrKodyId = input.request.packageIdOrKodyId.trim() diff --git a/packages/worker/src/package-invocations/invoke-check.ts b/packages/worker/src/package-invocations/invoke-check.ts index 6b1cb77ed3..dba7828406 100644 --- a/packages/worker/src/package-invocations/invoke-check.ts +++ b/packages/worker/src/package-invocations/invoke-check.ts @@ -4,14 +4,8 @@ import { type PackageInvokeContract, type PackageInvokeInput, } from '#mcp/run-kody-registry.ts' -import { - buildPackageSearchProjection, - type PackageExportProjection, -} from '#worker/package-registry/manifest.ts' -import { - loadPackageManifestBySourceId, - loadPackageSourceBySourceId, -} from '#worker/package-registry/source.ts' +import { type PackageExportProjection } from '#worker/package-registry/manifest.ts' +import { loadPackageManifestBySourceId } from '#worker/package-registry/source.ts' import { buildSavedPackageNotFoundMessage, normalizeExportName, @@ -39,17 +33,6 @@ function createPackageInvokeCheckFailure(input: { } } -function findPackageExportProjection(input: { - exports: Array - exportName: string -}) { - return ( - input.exports.find( - (exportDetail) => exportDetail.subpath === input.exportName, - ) ?? null - ) -} - function buildPackageInvokeCheckWarnings(input: { exportDetail: PackageExportProjection | null sourceLoadFailed: boolean @@ -87,26 +70,7 @@ export type PackageInvokeCheckOutcome = { preloads: PackageInvokeCheckPreloads | null } -export type PackageInvokeCheckOperationName = - | 'packages.invoke' - | 'packages.check' - /** Deprecated widen-phase alias for `packages.invoke`. */ - | 'packages.invokeChecked' - -export async function checkPackageInvokeForRuntime(input: { - env: Env - baseUrl: string - operationName: PackageInvokeCheckOperationName - userId: string - rawInput: PackageInvokeInput -}): Promise { - return ( - await checkPackageInvokeForRuntimeWithPreloads({ - ...input, - includeExportProjection: true, - }) - ).result -} +export type PackageInvokeCheckOperationName = 'packages.invoke' export async function checkPackageInvokeForRuntimeWithPreloads(input: { env: Env @@ -114,12 +78,6 @@ export async function checkPackageInvokeForRuntimeWithPreloads(input: { operationName: PackageInvokeCheckOperationName userId: string rawInput: PackageInvokeInput - /** - * `packages.check` surfaces description / type-definition detail, which - * requires the full package source. `packages.invokeChecked` discards the - * success contract, so it skips that source load entirely. - */ - includeExportProjection: boolean }): Promise { let request: ReturnType try { @@ -234,52 +192,6 @@ export async function checkPackageInvokeForRuntimeWithPreloads(input: { savedPackage, moduleArtifact, } - if (!input.includeExportProjection) { - return { - result: { - ok: true, - invoke, - contract: { - ...packageContract, - publishedCommit: manifestResult.source.published_commit ?? null, - runtimeTarget: resolution.entryPoint, - description: null, - typeDefinition: null, - warnings: buildPackageInvokeCheckWarnings({ - exportDetail: null, - sourceLoadFailed: false, - }), - }, - }, - preloads, - } - } - let files: Record | undefined - let sourceLoadFailed = false - try { - files = ( - await loadPackageSourceBySourceId({ - env: input.env, - baseUrl: input.baseUrl, - userId: input.userId, - sourceId: savedPackage.sourceId, - }) - ).files - } catch { - sourceLoadFailed = true - } - const projection = buildPackageSearchProjection( - manifestResult.manifest, - files, - ) - const exportDetail = findPackageExportProjection({ - exports: projection.exports, - exportName, - }) - const warnings = buildPackageInvokeCheckWarnings({ - exportDetail, - sourceLoadFailed, - }) return { result: { ok: true, @@ -287,10 +199,13 @@ export async function checkPackageInvokeForRuntimeWithPreloads(input: { contract: { ...packageContract, publishedCommit: manifestResult.source.published_commit ?? null, - runtimeTarget: exportDetail?.runtimeTarget ?? resolution.entryPoint, - description: exportDetail?.description ?? null, - typeDefinition: exportDetail?.typeDefinition ?? null, - warnings, + runtimeTarget: resolution.entryPoint, + description: null, + typeDefinition: null, + warnings: buildPackageInvokeCheckWarnings({ + exportDetail: null, + sourceLoadFailed: false, + }), }, }, preloads, diff --git a/packages/worker/src/package-invocations/runtime-tool-factories.ts b/packages/worker/src/package-invocations/runtime-tool-factories.ts index d29313b270..2af4806439 100644 --- a/packages/worker/src/package-invocations/runtime-tool-factories.ts +++ b/packages/worker/src/package-invocations/runtime-tool-factories.ts @@ -16,10 +16,7 @@ import { invokePackageExportForPackageRuntime, } from './http-invoke.ts' import { parsePackageInvokeInput } from './input-parsing.ts' -import { - checkPackageInvokeForRuntime, - checkPackageInvokeForRuntimeWithPreloads, -} from './invoke-check.ts' +import { checkPackageInvokeForRuntimeWithPreloads } from './invoke-check.ts' export function createPackageRuntimeInvokeToolsWithToolFactories(input: { env: Env @@ -100,7 +97,6 @@ function createPackageInvokeTools(input: { operationName: 'packages.invoke', userId: user.userId, rawInput, - includeExportProjection: false, }) if (!check.result.ok || !check.preloads) { const message = check.result.ok @@ -178,24 +174,8 @@ function createPackageInvokeTools(input: { error.response = response throw error } - return { - check: async (rawInput) => { - // Deprecated: packages.invoke always contract-checks before invoking. - // Kept as a working shim during the widen phase; the sandbox prelude - // emits the deprecation warning naming the replacement. - const { user } = requireRuntimeCaller('packages.check') - return await checkPackageInvokeForRuntime({ - env: input.env, - baseUrl: input.baseUrl, - operationName: 'packages.check', - userId: user.userId, - rawInput, - }) - }, - invoke, - // Deprecated alias for the widen phase: packages.invoke is now always - // contract-checked, so invokeChecked adds nothing. Key-less calls take - // the lean path; the sandbox prelude emits the deprecation warning. - invokeChecked: invoke, - } + // `packages.check` and `packages.invokeChecked` were removed with the + // static-first model; the sandbox prelude throws teaching errors for them + // without a host round trip. + return { invoke } } diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index 1d3db8239f..2be78744dc 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -1137,28 +1137,16 @@ test('keyed packages.invoke keeps exactly-once semantics: repeat calls replay th }) }) -test('packages.invokeChecked remains a working key-less alias during the widen phase', async () => { +test('runtime invoke tools expose only invoke (check/invokeChecked were removed)', () => { const db = createDatabase() seedRuntimeDispatchPackages() - repoMockModule.runBundledModuleWithRegistry.mockClear() - let executionCount = 0 - repoMockModule.runBundledModuleWithRegistry.mockImplementation(async () => { - executionCount += 1 - return { result: { handled: true, executionCount }, logs: [] } - }) - const tools = createRuntimeDispatchTools(db) - - const request = { - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - params: { event: { id: 'message-1' } }, - } - const first = await tools.invokeChecked(request) - const second = await tools.invokeChecked(request) + const tools = createRuntimeDispatchTools(db) as Record - expect(first).toEqual({ handled: true, executionCount: 1 }) - expect(second).toEqual({ handled: true, executionCount: 2 }) - expect(repoMockModule.runBundledModuleWithRegistry).toHaveBeenCalledTimes(2) + expect(typeof tools.invoke).toBe('function') + // The sandbox prelude throws the teaching errors for the removed names; + // the host tool set no longer carries them at all. + expect(tools.check).toBeUndefined() + expect(tools.invokeChecked).toBeUndefined() }) test('package runtime dispatches declared events to same-user package subscriptions', async () => { @@ -1292,7 +1280,7 @@ test('package runtime dispatches declared events to same-user package subscripti expect(repoMockModule.runBundledModuleWithRegistry).not.toHaveBeenCalled() }) -test('package runtime checks and invokes another package with current contract metadata', async () => { +test('package runtime invoke contract-checks once and executes the target', async () => { const db = createDatabase() seedRuntimeDispatchPackages() repoMockModule.runBundledModuleWithRegistry.mockResolvedValue({ @@ -1302,7 +1290,7 @@ test('package runtime checks and invokes another package with current contract m repoMockModule.loadPackageManifestBySourceId.mockClear() const tools = createRuntimeDispatchTools(db) - const check = await tools.check({ + const result = await tools.invoke({ kodyId: 'discord-general-chat', exportName: 'handle-discord-message-created', params: { event: { id: 'message-1' }, dryRun: true }, @@ -1310,40 +1298,14 @@ test('package runtime checks and invokes another package with current contract m topic: 'discord.message.created', }) - expect(check).toMatchObject({ - ok: true, - invoke: { - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - params: { event: { id: 'message-1' }, dryRun: true }, - idempotencyKey: 'message-1', - topic: 'discord.message.created', - }, - contract: { - packageId: 'pkg-subscriber', - kodyId: 'discord-general-chat', - name: '@kentcdodds/discord-general-chat', - sourceId: 'source-subscriber', - publishedCommit: 'subscriber-commit-1', - exportName: './handle-discord-message-created', - runtimeTarget: 'src/handle-discord-message-created.ts', - description: 'Handle a Discord message-created event.', - typeDefinition: - 'export default async function handleDiscordMessageCreated(input: { event: { id: string }, dryRun?: boolean }): Promise<{ handled: boolean }>', - }, - }) - expect(check.ok && check.contract.warnings).toHaveLength(1) - expect(check.ok && check.contract.warnings[0]).toMatch(/params schema/i) - expect(repoMockModule.loadPackageManifestBySourceId).toHaveBeenCalledTimes(1) - if (!check.ok) throw new Error(check.message) - - const result = await tools.invoke(check.invoke) - expect(result).toEqual({ handled: true, eventId: 'message-1' }) + // One logical call resolves its package exactly once: the mandatory + // contract check preloads the manifest and the invoke phase reuses it. + expect(repoMockModule.loadPackageManifestBySourceId).toHaveBeenCalledTimes(1) expect(repoMockModule.runBundledModuleWithRegistry).toHaveBeenCalledTimes(1) }) -test('execute runtime invokeChecked invokes target package with execute provenance', async () => { +test('execute runtime invoke invokes target package with execute provenance', async () => { const db = createDatabase() seedRuntimeDispatchPackages() repoMockModule.runBundledModuleWithRegistry.mockResolvedValue({ @@ -1367,7 +1329,7 @@ test('execute runtime invokeChecked invokes target package with execute provenan conversationId: 'conv-execute-1', }) - const result = await tools.invokeChecked({ + const result = await tools.invoke({ kodyId: 'discord-general-chat', exportName: './handle-discord-message-created', params: { event: { id: 'message-1' } }, @@ -1418,77 +1380,23 @@ test('execute runtime invokeChecked invokes target package with execute provenan ) }) -test('package runtime check reads target package metadata changes without republishing caller', async () => { - const db = createDatabase() - const { sourceFiles, sources, subscriber } = seedRuntimeDispatchPackages() - const tools = createRuntimeDispatchTools(db) - - const first = await tools.check({ - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - params: { event: { id: 'message-1' } }, - }) - sources.set( - 'source-subscriber', - createSource({ - id: 'source-subscriber', - entityId: subscriber.id, - commit: 'subscriber-commit-2', - }), - ) - sourceFiles.set('source-subscriber', { - 'package.json': - sourceFiles.get('source-subscriber')?.['package.json'] ?? '', - 'src/handle-discord-message-created.ts': `/** - * Handle the current Discord message-created event contract. - */ -export default async function handleDiscordMessageCreated(input: { event: { id: string, guildId: string } }): Promise<{ handled: boolean, version: 2 }> { - return { handled: true, version: 2 } -}`, - }) - - const second = await tools.check({ - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - params: { event: { id: 'message-2', guildId: 'guild-1' } }, - }) - - expect(first).toMatchObject({ - ok: true, - contract: { - publishedCommit: 'subscriber-commit-1', - description: 'Handle a Discord message-created event.', - }, - }) - expect(second).toMatchObject({ - ok: true, - contract: { - publishedCommit: 'subscriber-commit-2', - description: 'Handle the current Discord message-created event contract.', - typeDefinition: - 'export default async function handleDiscordMessageCreated(input: { event: { id: string, guildId: string } }): Promise<{ handled: boolean, version: 2 }>', - }, - }) -}) - test('package runtime dispatch rejects invalid targets before and during invocation', async () => { const db = createDatabase() seedRuntimeDispatchPackages() const tools = createRuntimeDispatchTools(db) + repoMockModule.runBundledModuleWithRegistry.mockClear() await expect( - tools.check({ + tools.invoke({ kodyId: 'missing-package', exportName: './handle-discord-message-created', params: {}, }), - ).resolves.toMatchObject({ - ok: false, - message: 'Saved package "missing-package" was not found for this user.', - problems: ['Saved package "missing-package" was not found for this user.'], - }) + ).rejects.toThrow( + 'packages.invoke contract check failed: Saved package "missing-package" was not found for this user.', + ) await expect( - tools.invokeChecked({ + tools.invoke({ kodyId: '@kentcdodds/discord-general-chat', exportName: './handle-discord-message-created', params: {}, @@ -1497,38 +1405,7 @@ test('package runtime dispatch rejects invalid targets before and during invocat 'packages.invoke contract check failed: Saved package "@kentcdodds/discord-general-chat" was not found for this user. Dynamic package invocation uses the bare kodyId (for example, "github"), not the npm-scoped package name (for example, "@kentcdodds/github").', ) await expect( - tools.check({ - kodyId: 'discord-general-chat', - exportName: './missing-export', - params: {}, - }), - ).resolves.toMatchObject({ - ok: false, - problems: [ - 'Package "discord-general-chat" does not define export "./missing-export".', - ], - contract: { - packageId: 'pkg-subscriber', - kodyId: 'discord-general-chat', - publishedCommit: 'subscriber-commit-1', - exportName: './missing-export', - }, - }) - await expect( - tools.check({ - kodyId: 'discord-general-chat', - exportName: './handle-discord-message-created', - params: 'not-an-object', - }), - ).resolves.toMatchObject({ - ok: false, - message: 'packages.check params must be a JSON object when provided.', - problems: ['packages.check params must be a JSON object when provided.'], - }) - - repoMockModule.runBundledModuleWithRegistry.mockClear() - await expect( - tools.invokeChecked({ + tools.invoke({ kodyId: 'discord-general-chat', exportName: './missing-export', params: {}, @@ -1537,7 +1414,7 @@ test('package runtime dispatch rejects invalid targets before and during invocat 'packages.invoke contract check failed: Package "discord-general-chat" does not define export "./missing-export".', ) await expect( - tools.invokeChecked({ + tools.invoke({ kodyId: 'discord-general-chat', exportName: './handle-discord-message-created', params: 'not-an-object', diff --git a/packages/worker/src/package-runtime/deprecated-invocation-usage.ts b/packages/worker/src/package-runtime/deprecated-invocation-usage.ts index 0dccb2eecb..aa1b9f10b2 100644 --- a/packages/worker/src/package-runtime/deprecated-invocation-usage.ts +++ b/packages/worker/src/package-runtime/deprecated-invocation-usage.ts @@ -4,10 +4,11 @@ import { packageSpecifierPrefix } from './package-import-resolution.ts' import { isTypeDeclarationFilePath } from './static-kody-imports.ts' /** - * Widen-phase deprecation detection for the legacy dynamic invocation - * surface: `packages.check`, `packages.invokeChecked`, and literal dynamic - * `import("kody:@...")`. Publish checks surface these as non-fatal warnings - * so new usage stops before the narrow phase removes the shims. + * Detection for the removed legacy dynamic invocation surface: + * `packages.check`, `packages.invokeChecked`, and literal dynamic + * `import("kody:@...")`. Publish checks fail on these (the runtime throws + * teaching errors), and the `0002-static-first-invocation` package codemod + * reuses the same collector so codemod findings stay in lockstep. */ export type DeprecatedInvocationUsageKind = | 'packages.check' @@ -105,33 +106,30 @@ export function collectDeprecatedInvocationUsage( ) } -const deprecatedUsageReplacements: Record< - DeprecatedInvocationUsageKind, - string -> = { - 'packages.check': - 'packages.check is deprecated: packages.invoke always contract-checks before invoking, so call it directly', - 'packages.invokeChecked': - 'packages.invokeChecked is deprecated: use a static kody:@scope/pkg/export import when the target package is known at write time, or packages.invoke({ kodyId, exportName, params }) for dynamic targets', - 'dynamic-kody-import': - 'literal dynamic import("kody:@...") is deprecated: use a static import and declare it in package.json#kody.dependencies', -} +const removedUsageReplacements: Record = + { + 'packages.check': + 'packages.check was removed: packages.invoke always contract-checks before invoking, so call it directly', + 'packages.invokeChecked': + 'packages.invokeChecked was removed: use a static kody:@scope/pkg/export import when the target package is known at write time, or packages.invoke({ kodyId, exportName, params }) for dynamic targets', + 'dynamic-kody-import': + 'literal dynamic import("kody:@...") was removed: use a static import and declare it in package.json#kody.dependencies, or packages.invoke when the target is data', + } -const maxReportedDeprecatedUsages = 5 +const maxReportedRemovedUsages = 5 -export function formatDeprecatedInvocationUsageWarning( +export function formatRemovedInvocationUsageFailure( usages: Array, ) { if (usages.length === 0) return null - const shown = usages.slice(0, maxReportedDeprecatedUsages) + const shown = usages.slice(0, maxReportedRemovedUsages) const hiddenCount = usages.length - shown.length const details = shown .map( - (usage) => - `"${usage.filePath}": ${deprecatedUsageReplacements[usage.kind]}`, + (usage) => `"${usage.filePath}": ${removedUsageReplacements[usage.kind]}`, ) .join('; ') - return `Deprecation warnings (non-blocking): ${details}${ + return `Package code uses the removed dynamic invocation surface: ${details}${ hiddenCount > 0 ? ` (and ${hiddenCount} more)` : '' - }.` + }. The 0002-static-first-invocation package codemod migrates invokeChecked call sites mechanically.` } diff --git a/packages/worker/src/package-runtime/module-graph-import-rewriting.ts b/packages/worker/src/package-runtime/module-graph-import-rewriting.ts index 18cbfa90f2..cebcd24d47 100644 --- a/packages/worker/src/package-runtime/module-graph-import-rewriting.ts +++ b/packages/worker/src/package-runtime/module-graph-import-rewriting.ts @@ -27,7 +27,6 @@ import { import { createPackageProxyPathSegment, createRelativeImportSpecifier, - dirname, encodePathKeyAsPath, joinPath, normalizeWorkspaceModulePath, @@ -43,8 +42,7 @@ import { import { buildPackageRuntimeModulePath, createComputedDynamicImportGuardSource, - createDynamicPackageImportHelperSource, - createDynamicPackageImportPlaceholderSource, + createRemovedDynamicKodyImportHelperSource, createMeteredPackageImportProxySource, createPackageImportProxySource, createPackageRuntimeModuleSource, @@ -302,23 +300,6 @@ async function ensurePackageProxy( return proxyPath } -function ensureDynamicPackageImportProxy( - state: RewriteState, - specifier: string, -) { - const existing = state.dynamicPackageImports.get(specifier) - if (existing) return existing - const proxyPath = joinPath( - dynamicPackageImportProxyPrefix, - `${createPackageProxyPathSegment(specifier)}.js`, - ) - state.files[proxyPath] = createDynamicPackageImportPlaceholderSource({ - specifier, - }) - state.dynamicPackageImports.set(specifier, proxyPath) - return proxyPath -} - export function collectDynamicPackageImportProxyModules( files: Record, emittedModules: WorkerLoaderModules, @@ -433,27 +414,22 @@ async function rewriteKodyImports(input: { }) } let computedImportHelperName: string | null = null - let dynamicPackageImportHelperName: string | null = null + let removedDynamicImportHelperName: string | null = null for (const node of dynamicImportNodes) { if (node.literalSpecifier?.startsWith(packageSpecifierPrefix)) { - const proxyPath = ensureDynamicPackageImportProxy( - input.state, - node.literalSpecifier, - ) - input.state.files[joinPath(rootSourcePrefix, proxyPath)] ??= - input.state.files[proxyPath] ?? '' - input.state.files[joinPath(dirname(input.modulePath), proxyPath)] ??= - input.state.files[proxyPath] ?? '' - dynamicPackageImportHelperName ??= createUniqueHelperName( + // Literal dynamic kody:@ imports were removed with the static-first + // model: the call site becomes a teaching error naming the + // replacement (publish checks fail on them too). + removedDynamicImportHelperName ??= createUniqueHelperName( input.source, - '__kodyDynamicPackageImport', + '__kodyRemovedDynamicKodyImport', ) replacements.push({ start: node.start, end: node.end, - value: `${dynamicPackageImportHelperName}(${JSON.stringify( - `./${proxyPath}`, - )}, ${JSON.stringify(node.literalSpecifier)})`, + value: `${removedDynamicImportHelperName}(${JSON.stringify( + node.literalSpecifier, + )})`, }) continue } @@ -477,9 +453,9 @@ async function rewriteKodyImports(input: { assertReplacementsDoNotOverlap(sortedReplacements) const rewritten = applyReplacements(input.source, sortedReplacements) const helpers = [ - dynamicPackageImportHelperName - ? createDynamicPackageImportHelperSource({ - helperName: dynamicPackageImportHelperName, + removedDynamicImportHelperName + ? createRemovedDynamicKodyImportHelperSource({ + helperName: removedDynamicImportHelperName, }) : '', computedImportHelperName diff --git a/packages/worker/src/package-runtime/module-graph.node.test.ts b/packages/worker/src/package-runtime/module-graph.node.test.ts index ba8cb3b8e3..fc3e03d471 100644 --- a/packages/worker/src/package-runtime/module-graph.node.test.ts +++ b/packages/worker/src/package-runtime/module-graph.node.test.ts @@ -1389,7 +1389,7 @@ test('buildKodyModuleBundle refreshes nested artifact runtimes before static imp expect(bundlerInput?.files?.[nestedRuntimePath]).not.toBe(staleRuntimeSource) }) -test('buildKodyModuleBundle keeps static imports pinned while literal dynamic imports resolve current packages', async () => { +test('buildKodyModuleBundle keeps static imports pinned and rewrites literal dynamic imports to teaching errors', async () => { mockModule.createWorker.mockImplementation( async (input: { files: Record; entryPoint: string }) => ({ mainModule: input.entryPoint, @@ -1483,13 +1483,9 @@ export default async function run() { packageId: 'pkg-1', }, ]) - expect(bundle.dynamicDependencies).toEqual([ - { - specifier: 'kody:@kentcdodds/example-package/value', - packageName: '@kentcdodds/example-package', - exportName: './value', - }, - ]) + // Literal dynamic kody imports no longer produce placeholder modules or + // dynamic-dependency metadata; the call site becomes a teaching error. + expect(bundle.dynamicDependencies ?? []).toEqual([]) packageVersion = 'current' const { modules: hydratedModules } = await hydrateKodyRuntimeModules({ env: { @@ -1500,28 +1496,17 @@ export default async function run() { userId: 'user-1', modules: bundle.modules, }) - consoleWarn.mockImplementation(() => {}) const moduleGraph = await createTemporaryModuleGraph(hydratedModules) try { const entry = (await moduleGraph.importModule(bundle.mainModule)) as { default: () => Promise } - await expect(entry.default()).resolves.toEqual({ - staticValue: 'pinned', - dynamicValue: 'current', - dynamicMarker: 'current', - }) + await expect(entry.default()).rejects.toThrow( + 'Dynamic import("kody:@kentcdodds/example-package/value") was removed: use a static import', + ) } finally { await moduleGraph.cleanup() } - // The literal dynamic import shim warns once per specifier, naming the - // static-import replacement. - expect(consoleWarn).toHaveBeenCalledTimes(1) - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining( - '[deprecated] dynamic import("kody:@kentcdodds/example-package/value")', - ), - ) expect(mockModule.getSavedPackageByName).toHaveBeenCalledWith( {}, expect.objectContaining({ @@ -1583,7 +1568,7 @@ export default async function run() { default: () => Promise } await expect(entry.default()).rejects.toThrow( - 'Computed dynamic Kody package imports are unsupported', + 'Dynamic kody:@ package imports were removed', ) } finally { await moduleGraph.cleanup() diff --git a/packages/worker/src/package-runtime/module-graph.workers.test.ts b/packages/worker/src/package-runtime/module-graph.workers.test.ts index c3aede5fad..44b0915639 100644 --- a/packages/worker/src/package-runtime/module-graph.workers.test.ts +++ b/packages/worker/src/package-runtime/module-graph.workers.test.ts @@ -386,21 +386,30 @@ test('saved package artifact imports keep the execute wrapper as the runtime ent "import workflowDiscord from 'kody:@kentcdodds/email-received-subscriber/workflow-discord-message-created'", "import { inspectNestedImport } from './src/deep/nested.ts'", 'export default async function main() {', - "\tconst dynamicListTraces = await import('kody:@kentcdodds/email-received-subscriber/list-traces')", + "\tlet dynamicImportError = ''", + '\ttry {', + "\t\tawait import('kody:@kentcdodds/email-received-subscriber/list-traces')", + '\t} catch (error) {', + '\t\tdynamicImportError = String(error?.message ?? error)', + '\t}', '\tconst nestedImport = await inspectNestedImport()', '\treturn {', '\t\tlistTraces: { staticType: typeof listTraces },', '\t\tsmokeTest: { staticType: typeof smokeTest },', '\t\tworkflowDiscord: { staticType: typeof workflowDiscord },', - '\t\tdynamicListTraces: { defaultType: typeof dynamicListTraces.default },', + '\t\tdynamicImportError,', '\t\tnestedImport,', '\t}', '}', ].join('\n'), 'src/deep/nested.ts': [ 'export async function inspectNestedImport() {', - "\tconst dynamicListTraces = await import('kody:@kentcdodds/email-received-subscriber/list-traces')", - '\treturn { defaultType: typeof dynamicListTraces.default }', + '\ttry {', + "\t\tawait import('kody:@kentcdodds/email-received-subscriber/list-traces')", + '\t\treturn { threw: false }', + '\t} catch (error) {', + '\t\treturn { threw: true }', + '\t}', '}', ].join('\n'), }, @@ -437,11 +446,11 @@ test('saved package artifact imports keep the execute wrapper as the runtime ent workflowDiscord: { staticType: 'function', }, - dynamicListTraces: { - defaultType: 'function', - }, + dynamicImportError: expect.stringContaining( + 'Dynamic import("kody:@kentcdodds/email-received-subscriber/list-traces") was removed: use a static import', + ), nestedImport: { - defaultType: 'function', + threw: true, }, }) }) @@ -638,32 +647,10 @@ test('saved package execution exposes packages.invoke when package invoke tools sourceId: 'source-invoker', }, packageInvokeTools: { - check: async (input) => ({ - ok: true, - invoke: input as { - kodyId: string - exportName: string - params?: Record - }, - contract: { - packageId: 'pkg-target', - kodyId: 'target-package', - name: '@kentcdodds/target-package', - sourceId: 'source-target', - publishedCommit: 'commit-1', - exportName: './run', - runtimeTarget: 'src/run.ts', - warnings: [], - }, - }), invoke: async (input) => { invokedInputs.push(input) return { ok: true, input } }, - invokeChecked: async (input) => { - invokedInputs.push(input) - return { ok: true, input } - }, }, skipCapabilityRegistry: true, }, @@ -698,7 +685,7 @@ test('saved package execution exposes packages.invoke when package invoke tools ]) }) -test('ad hoc execute runtime exposes packages.invoke when package invoke tools are provided', async () => { +test('ad hoc execute runtime exposes packages.invoke and throws teaching errors for removed APIs', async () => { silenceIncidentalRuntimeWarnings() const bundle = await buildKodyModuleBundle({ env, @@ -708,8 +695,17 @@ test('ad hoc execute runtime exposes packages.invoke when package invoke tools a 'entry.ts': [ "import { kody, packageContext, packages } from 'kody:runtime'", '', + 'const captureError = (fn) => {', + '\ttry {', + '\t\tfn()', + "\t\treturn 'resolved'", + '\t} catch (error) {', + '\t\treturn String(error?.message ?? error)', + '\t}', + '}', + '', 'export default async function main(input = {}) {', - '\t// Direct kody.package_invoke_checked should reject; packages.invokeChecked is the public API.', + '\t// Direct kody.package_invoke_checked should reject; packages.invoke is the public API.', '\tlet directKodyInvokeChecked;', '\ttry {', '\t\tawait kody.package_invoke_checked({', @@ -722,9 +718,10 @@ test('ad hoc execute runtime exposes packages.invoke when package invoke tools a '\t}', '\treturn {', '\t\tpackageContextIsNull: packageContext === null,', - "\t\thasInvokeChecked: typeof packages?.invokeChecked === 'function',", + '\t\tremovedCheckError: captureError(() => packages?.check({})),', + '\t\tremovedInvokeCheckedError: captureError(() => packages?.invokeChecked({})),', '\t\tdirectKodyInvokeChecked,', - '\t\tinvoked: await packages?.invokeChecked({', + '\t\tinvoked: await packages?.invoke({', "\t\t\tkodyId: 'target-package',", "\t\t\texportName: './run',", '\t\t\tparams: input,', @@ -754,32 +751,10 @@ test('ad hoc execute runtime exposes packages.invoke when package invoke tools a { packageContext: null, packageInvokeTools: { - check: async (input) => ({ - ok: true, - invoke: input as { - kodyId: string - exportName: string - params?: Record - }, - contract: { - packageId: 'pkg-target', - kodyId: 'target-package', - name: '@kentcdodds/target-package', - sourceId: 'source-target', - publishedCommit: 'commit-1', - exportName: './run', - runtimeTarget: 'src/run.ts', - warnings: [], - }, - }), invoke: async (input) => { invokedInputs.push(input) return { ok: true, input } }, - invokeChecked: async (input) => { - invokedInputs.push(input) - return { ok: true, input } - }, }, skipCapabilityRegistry: true, }, @@ -788,7 +763,10 @@ test('ad hoc execute runtime exposes packages.invoke when package invoke tools a expect(result.error).toBeUndefined() expect(result.result).toEqual({ packageContextIsNull: true, - hasInvokeChecked: true, + removedCheckError: expect.stringContaining('packages.check was removed'), + removedInvokeCheckedError: expect.stringContaining( + 'packages.invokeChecked was removed', + ), directKodyInvokeChecked: expect.stringContaining('package_invoke_checked'), invoked: { ok: true, @@ -950,19 +928,28 @@ test( 'entry.ts': [ "import { packages } from 'kody:runtime'", '', + 'const captureError = (fn: () => unknown) => {', + '\ttry {', + '\t\tfn()', + "\t\treturn 'resolved'", + '\t} catch (error) {', + '\t\treturn String((error as Error)?.message ?? error)', + '\t}', + '}', + '', 'export default async function main() {', ";(globalThis as Record).__kodyLeanCallerMarker = 'caller'", '\tconst startedAt = Date.now()', "\tconst first = await packages?.invoke({ kodyId: 'lean-target', exportName: './probe', params: { marker: 'first' } })", '\tconst firstDurationMs = Date.now() - startedAt', "\tconst second = await packages?.invoke({ kodyId: 'lean-target', exportName: './probe', params: { marker: 'second' } })", - "\tconst checked = await packages?.invokeChecked({ kodyId: 'lean-target', exportName: './probe', params: { marker: 'checked' } })", - "\tconst checkResult = (await packages?.check({ kodyId: 'lean-target', exportName: './probe' })) as { ok?: boolean }", + "\tconst removedInvokeCheckedError = captureError(() => packages?.invokeChecked({ kodyId: 'lean-target', exportName: './probe' }))", + "\tconst removedCheckError = captureError(() => packages?.check({ kodyId: 'lean-target', exportName: './probe' }))", '\treturn {', '\t\tfirst,', '\t\tsecond,', - '\t\tchecked,', - '\t\tcheckOk: checkResult?.ok === true,', + '\t\tremovedInvokeCheckedError,', + '\t\tremovedCheckError,', '\t\tfirstDurationMs,', "\t\ttargetMarkerVisible: typeof (globalThis as Record).__kodyLeanTargetMarker !== 'undefined',", '\t}', @@ -1002,8 +989,8 @@ test( const payload = result.result as { first: Record second: Record - checked: Record - checkOk: boolean + removedInvokeCheckedError: string + removedCheckError: string firstDurationMs: number targetMarkerVisible: boolean } @@ -1021,14 +1008,11 @@ test( targetKodyId: 'lean-target', callerMarkerVisible: false, }) - // Deprecated shims keep working end to end. - expect(payload.checked).toEqual({ - marker: 'checked', - isolateCallCount: 1, - targetKodyId: 'lean-target', - callerMarkerVisible: false, - }) - expect(payload.checkOk).toBe(true) + // Removed APIs throw teaching errors naming the replacement. + expect(payload.removedInvokeCheckedError).toContain( + 'packages.invokeChecked was removed', + ) + expect(payload.removedCheckError).toContain('packages.check was removed') // Realm separation in the other direction: the target's globals never // leak back into the caller realm. expect(payload.targetMarkerVisible).toBe(false) @@ -1036,14 +1020,5 @@ test( // budget; the production lean-path latency claim is validated by live // probes, not this test. expect(payload.firstDurationMs).toBeLessThan(20_000) - // The deprecation warnings surface in the caller's captured logs, once - // per helper, naming the replacement. - const warningLogs = (result.logs ?? []).filter((entry) => - entry.includes('[deprecated]'), - ) - expect(warningLogs).toEqual([ - expect.stringContaining('[deprecated] packages.invokeChecked'), - expect.stringContaining('[deprecated] packages.check'), - ]) }, ) diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index c88f325a34..5427206768 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -357,11 +357,21 @@ function createWorkflowsProxy(runtimeBridge) { } function createPackagesProxy(runtimeBridge) { + // check/invokeChecked were removed with the static-first model; they throw + // teaching errors locally so app code learns the replacement from the + // error text without a bridge round trip. return { - check: async (input) => await runtimeBridge.packageInvokeCheck(input ?? {}), + check: () => { + throw new Error( + 'packages.check was removed: packages.invoke always contract-checks before invoking, so call packages.invoke({ kodyId, exportName, params }) directly. A failing contract rejects with "packages.invoke contract check failed: ..." before any execution.', + ); + }, invoke: async (input) => await runtimeBridge.packageInvoke(input ?? {}), - invokeChecked: async (input) => - await runtimeBridge.packageInvokeChecked(input ?? {}), + invokeChecked: () => { + throw new Error( + 'packages.invokeChecked was removed: call packages.invoke({ kodyId, exportName, params }) instead — it is always contract-checked (add idempotencyKey only when you need exactly-once) — or use a static import (import fn from "kody:@scope/pkg/export") when the target package is known at write time.', + ); + }, }; } @@ -1457,16 +1467,6 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< return await tools.invoke(input) } - async packageInvokeCheck(input: Record) { - const tools = await this.createPackageRuntimeInvokeTools() - return await tools.check(input) - } - - async packageInvokeChecked(input: Record) { - const tools = await this.createPackageRuntimeInvokeTools() - return await tools.invokeChecked(input) - } - async packageEventDispatch(input: Record) { const tools = await this.createPackageEventTools() return await tools.dispatch(input) diff --git a/packages/worker/src/package-runtime/runtime-source-modules.ts b/packages/worker/src/package-runtime/runtime-source-modules.ts index 0c75259979..a5e6372c22 100644 --- a/packages/worker/src/package-runtime/runtime-source-modules.ts +++ b/packages/worker/src/package-runtime/runtime-source-modules.ts @@ -5,7 +5,6 @@ import { decodePathKey, dirname, dynamicPackageImportResolvedMarker, - dynamicPackageImportSpecifierExportName, encodePathKey, joinPath, normalizeWorkspaceModulePath, @@ -639,16 +638,13 @@ ${namedExportLines.join('\n')} `.trim() } -export function createDynamicPackageImportPlaceholderSource(input: { - specifier: string -}) { - return ` -export const ${dynamicPackageImportSpecifierExportName} = ${JSON.stringify(input.specifier)}; - -throw new Error( - ${JSON.stringify(`Kody dynamic package import "${input.specifier}" was not resolved by the host runtime.`)}, -); -`.trim() +export function buildRemovedDynamicKodyImportMessage(specifier: string) { + return ( + `Dynamic import(${JSON.stringify(specifier)}) was removed: use a static import ` + + `(import fn from ${JSON.stringify(specifier)}) — execute bundles always see the current ` + + `published version, and saved packages declare the dependency in package.json#kody.dependencies — ` + + `or packages.invoke({ kodyId, exportName, params }) when the target package is data.` + ) } export function createDynamicPackageImportProxySource(input: { @@ -669,7 +665,7 @@ const ${input.helperName} = async (specifier) => { packageSpecifierPrefix, )})) { throw new Error( - 'Computed dynamic Kody package imports are unsupported. Use a string literal like import("kody:@scope/package/export") for current runtime package resolution.', + 'Dynamic kody:@ package imports were removed. Use a static import (import fn from "kody:@scope/package/export") when the package name is known at write time, or packages.invoke({ kodyId, exportName, params }) when the target is data.', ); } return await import(specifier); @@ -677,23 +673,18 @@ const ${input.helperName} = async (specifier) => { `.trim() } -export function createDynamicPackageImportHelperSource(input: { +export function createRemovedDynamicKodyImportHelperSource(input: { helperName: string }) { - // Literal dynamic kody:@ imports are a deprecated widen-phase shim: they - // keep resolving via host hydration but warn once per specifier naming - // the static-import replacement. + // Literal dynamic kody:@ imports were removed with the static-first model. + // The bundler rewrites each call site to this helper so the failure is an + // actionable teaching error naming the replacement, not a resolution error. return ` -const ${input.helperName} = (() => { - const warned = new Set(); - return async (specifier, kodySpecifier) => { - if (kodySpecifier && !warned.has(kodySpecifier)) { - warned.add(kodySpecifier); - console.warn('[deprecated] dynamic import("' + kodySpecifier + '"): use a static import (import fn from "' + kodySpecifier + '") instead. Static imports from execute always see the current published version; saved packages must also declare the dependency in package.json#kody.dependencies. When the target name is only known at runtime, use packages.invoke({ kodyId, exportName, params }).'); - } - return await import(specifier); - }; -})(); +const ${input.helperName} = (specifier) => { + throw new Error( + 'Dynamic import("' + specifier + '") was removed: use a static import (import fn from "' + specifier + '") — execute bundles always see the current published version, and saved packages declare the dependency in package.json#kody.dependencies — or packages.invoke({ kodyId, exportName, params }) when the target package is data.', + ); +}; `.trim() } diff --git a/packages/worker/src/repo/checks.node.test.ts b/packages/worker/src/repo/checks.node.test.ts index 8dc266392f..af981b00dc 100644 --- a/packages/worker/src/repo/checks.node.test.ts +++ b/packages/worker/src/repo/checks.node.test.ts @@ -1522,18 +1522,18 @@ export default async function main() { expect(aliasedStorageLint?.message).toContain('packageStorage()') }) -test('runRepoChecks warns (without failing) on deprecated dynamic invocation usage', async () => { - // Widen-phase contract: packages.check, packages.invokeChecked, and - // literal dynamic import("kody:@...") keep publishing, but the passing - // lint message names each usage and its replacement. - const deprecated = await runPackageJobTypecheckChecks( +test('runRepoChecks fails on the removed dynamic invocation surface with replacements named', async () => { + // Narrow-phase contract: packages.check, packages.invokeChecked, and + // literal dynamic import("kody:@...") fail new publishes; the message + // names each usage, its replacement, and the migration codemod. + const removed = await runPackageJobTypecheckChecks( new Map([ [ 'package.json', createPackageManifest({ - packageName: '@kody/deprecated-invocation-package', - kodyId: 'deprecated-invocation-package', - description: 'Uses deprecated dynamic invocation APIs', + packageName: '@kody/removed-invocation-package', + kodyId: 'removed-invocation-package', + description: 'Uses the removed dynamic invocation APIs', }), ], [ @@ -1553,21 +1553,19 @@ export default async function main() { ], ]), ) - expect(deprecated.result.ok).toBe(true) - const deprecatedLint = deprecated.result.results.find( + expect(removed.result.ok).toBe(false) + const removedLint = removed.result.results.find( (entry) => entry.kind === 'lint', ) - expect(deprecatedLint).toMatchObject({ kind: 'lint', ok: true }) - expect(deprecatedLint?.message).toContain('Deprecation warnings') - expect(deprecatedLint?.message).toContain('"src/index.ts"') - expect(deprecatedLint?.message).toContain( - 'packages.invokeChecked is deprecated', + expect(removedLint).toMatchObject({ kind: 'lint', ok: false }) + expect(removedLint?.message).toContain('removed dynamic invocation surface') + expect(removedLint?.message).toContain('"src/index.ts"') + expect(removedLint?.message).toContain('packages.invokeChecked was removed') + expect(removedLint?.message).toContain('packages.check was removed') + expect(removedLint?.message).toContain( + 'literal dynamic import("kody:@...") was removed', ) - expect(deprecatedLint?.message).toContain('packages.check is deprecated') - expect(deprecatedLint?.message).toContain( - 'literal dynamic import("kody:@...") is deprecated', - ) - expect(deprecatedLint?.message).toContain('static') + expect(removedLint?.message).toContain('0002-static-first-invocation') }) test('heavy check phases run in throwaway isolates when the env has the bindings', async () => { diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index 2c972e7f9d..a3030cbabf 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -22,7 +22,7 @@ import { } from '#worker/package-runtime/package-artifact-targets.ts' import { collectDeprecatedInvocationUsage, - formatDeprecatedInvocationUsageWarning, + formatRemovedInvocationUsageFailure, } from '#worker/package-runtime/deprecated-invocation-usage.ts' import { collectStaticKodyPackageImportsFromFiles, @@ -995,20 +995,17 @@ function buildLintCheck(sourceFiles: Record): { ok: boolean message: string } { + // Narrow phase: the legacy dynamic invocation surface was removed, so new + // publishes fail with the replacement named (the runtime also throws + // teaching errors for these APIs). + const removedUsageFailure = formatRemovedInvocationUsageFailure( + collectDeprecatedInvocationUsage(sourceFiles), + ) + if (removedUsageFailure) { + return { ok: false, message: removedUsageFailure } + } const ambientStorageFiles = collectAmbientStorageImportFiles(sourceFiles) if (ambientStorageFiles.length === 0) { - // Widen-phase deprecations stay non-fatal: publishes succeed, but the - // passing lint message names each legacy invocation usage and its - // replacement so new packages stop adopting the retired surface. - const deprecationWarning = formatDeprecatedInvocationUsageWarning( - collectDeprecatedInvocationUsage(sourceFiles), - ) - if (deprecationWarning) { - return { - ok: true, - message: `Lint passed. ${deprecationWarning}`, - } - } return { ok: true, message: lintPlaceholderPassedMessage } } const shownFiles = ambientStorageFiles.slice( From c03914d77994fab4b5f3bb10eb4598760202afb7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 17:32:28 +0000 Subject: [PATCH 2/2] review: drop the stale dynamic-import kody.dependencies exemption and qualify removal wording Co-authored-by: Kent C. Dodds --- docs/use/packages.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/use/packages.md b/docs/use/packages.md index f818030099..47d5106f75 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -157,9 +157,7 @@ exhaustive. `package.json#kody.dependencies` using the imported package name, for example `"dependencies": ["@scope/my-package"]` inside the `kody` object. Package checks fail when static imports and declarations differ. Type-only imports do - not count, declaration files such as `.d.ts` are treated as type-only, and - current-version literal dynamic `import("kody:@...")` expressions do not need - `kody.dependencies` declarations. + not count, and declaration files such as `.d.ts` are treated as type-only. - Computed dynamic Kody package imports, including template strings and variables such as `import(packageSpecifier)`, are unsupported. When the target package is not known until runtime, use `packages.invoke` instead. @@ -262,12 +260,14 @@ package as that package so it receives `packageContext`, package-owned storage, package-mounted secrets (`kody.secretMounts`), and its own `packages` helper. **Removed:** `packages.invokeChecked`, `packages.check`, and literal dynamic -`import("kody:@...")` no longer exist. Calling any of them throws a teaching -error naming the replacement, and package publish checks fail on them. -`packages.invoke` performs the contract check that `invokeChecked` and `check` -provided, and the static/dynamic rules above cover the literal dynamic import -cases. The `0002-static-first-invocation` package codemod migrates -`invokeChecked` call sites mechanically. +`import("kody:@...")` no longer exist. Calling any of them from new source or +newly built bundles throws a teaching error naming the replacement, and package +publish checks fail on them (bundles published before the removal keep working +through hydration until their packages republish). `packages.invoke` performs +the contract check that `invokeChecked` and `check` provided, and the +static/dynamic rules above cover the literal dynamic import cases. The +`0002-static-first-invocation` package codemod migrates `invokeChecked` call +sites mechanically. ## Package storage