From 50e748c1c6b4ee7359e62ae55dd7039f9275a032 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 10 May 2026 17:43:02 +0000 Subject: [PATCH 1/7] Add package invoke contract checks Co-authored-by: Kent C. Dodds --- docs/contributing/packages-and-manifests.md | 42 +- docs/use/execute.md | 5 +- docs/use/packages.md | 67 ++- .../mcp/run-codemode-registry.node.test.ts | 42 ++ .../worker/src/mcp/run-codemode-registry.ts | 45 ++ .../worker/src/mcp/server-instructions.ts | 2 +- packages/worker/src/mcp/tools/execute.ts | 2 +- .../package-invocations/service.node.test.ts | 232 +++++++++- .../worker/src/package-invocations/service.ts | 406 ++++++++++++++---- .../module-graph.workers.test.ts | 26 ++ .../worker/src/package-runtime/package-app.ts | 45 ++ packages/worker/src/repo/checks.ts | 45 ++ 12 files changed, 857 insertions(+), 102 deletions(-) diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index a13d024371..e0c636d234 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -88,12 +88,13 @@ The top-level saved identity is the package. ### Dynamic current-version invocation -Package runtime contexts also expose `packages.invoke` from `kody:runtime`: +Package runtime contexts also expose `packages.check`, `packages.invoke`, and +`packages.invokeChecked` from `kody:runtime`: ```ts import { packages } from 'kody:runtime' -await packages.invoke({ +await packages.invokeChecked({ kodyId: 'discord-general-chat', exportName: './handle-discord-message-created', params: { event }, @@ -106,16 +107,41 @@ time through the package invocation service, using the current authenticated user and package caller context. Package code never handles external package-invocation bearer tokens for this flow. -Use dynamic invocation for runtime dispatch surfaces that must pick up the -target package's current published bundle, such as event subscribers, workflows, -and agents. Continue to use static `kody:@scope/package/export` imports for -library-like dependencies where the caller should keep the dependency bundle it -was published with. +Use dynamic invocation for runtime dispatch surfaces that must pick up the target +package's current published bundle, such as event subscribers, workflows, and +agents. Prefer `packages.invokeChecked` for new dynamic calls so Kody first +checks that the current package and export exist, params are a JSON object, and +the current contract metadata can be surfaced to the caller. Use +`packages.check` directly when a caller wants to inspect the current contract or +warnings before deciding whether to invoke: + +```ts +const check = await packages.check({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + params: { event, dryRun: true }, +}) + +if (!check.ok) throw new Error(check.message) +const result = await packages.invoke(check.invoke) +``` + +Continue to use static `kody:@scope/package/export` imports for library-like +dependencies where the caller should keep the dependency bundle it was published +with. Use bare `packages.invoke` only after a successful `packages.check` or when +the caller intentionally accepts direct runtime failure. + +`packages.check` returns the current package id/kody id/name, source id, +published commit, normalized export name, runtime target, and available +JSDoc/type definition. It also returns warnings when validation is weak. Today +package exports do not publish machine-readable params schemas, so Kody cannot +validate field-level params shape beyond requiring `params` to be a JSON object. `packages.invoke` returns the target export's unwrapped result. Non-2xx package invocation responses reject with an error whose message starts with the underlying code, for example `[package_not_found] ...` or -`[export_not_found] ...`. +`[export_not_found] ...`. `packages.invokeChecked` throws before invoking when +the check fails. Idempotency: diff --git a/docs/use/execute.md b/docs/use/execute.md index fe8e1c2d3e..140b2d5272 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -32,7 +32,10 @@ helpers are runtime exports: code when you need package metadata; it is **`null`** for ad hoc execute calls - use **`import { packages } from 'kody:runtime'`** only inside saved package runtime contexts when you need dynamic current-version invocation through - `packages.invoke(...)`; it is **`null`** for ad hoc execute calls + `packages.check(...)`, `packages.invoke(...)`, or + `packages.invokeChecked(...)`; it is **`null`** for ad hoc execute calls. + Prefer `invokeChecked` unless you already called `check` and are passing + `check.invoke` to `invoke` - use **`import { serviceContext } from 'kody:runtime'`** inside package service code when you need the current service identity; it is **`null`** outside package service runs diff --git a/docs/use/packages.md b/docs/use/packages.md index 89f57e1cd8..13163de617 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -110,18 +110,64 @@ const result = await packages.invoke({ }) ``` -Use `packages.invoke` for event subscribers, workflow dispatchers, agents, and -other runtime fan-out where the caller should pick up the target package's -current published export. The call resolves the target package at runtime, so -republishing `discord-general-chat` changes what `discord-gateway` observes -without republishing `discord-gateway`. +Use `packages.invokeChecked` for event subscribers, workflow dispatchers, +agents, and other runtime fan-out where the caller should pick up the target +package's current published export and wants Kody to validate the current +runtime contract before invoking it. The check resolves the target package at +runtime, so republishing `discord-general-chat` changes what `discord-gateway` +observes without republishing `discord-gateway`. Use static `kody:@scope/package/export` imports for library-like dependencies where bundling a published dependency snapshot with the caller is desired. -`packages.invoke` returns the target export's unwrapped return value. If the -target package, export, or execution fails, the promise rejects with an error -that includes the package invocation error code in the message. +Use `packages.check` when the caller wants to inspect the current contract +before deciding whether to invoke: + +```ts +import { packages } from 'kody:runtime' + +const check = await packages.check({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + params: { event, dryRun: true }, +}) + +if (!check.ok) throw new Error(check.message) +console.log(check.contract) + +const result = await packages.invoke(check.invoke) +``` + +For the common check-then-invoke flow, use the combined helper: + +```ts +import { packages } from 'kody:runtime' + +const result = await packages.invokeChecked({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + params: { event, dryRun: true }, +}) +``` + +Use bare `packages.invoke` only when the caller has already checked the contract +or intentionally wants the older direct invoke behavior. Use static +`kody:@scope/package/export` imports for library-like dependencies where +bundling the currently published dependency with the caller is desired. + +`packages.check` returns `ok: false` with `message` and `problems` when the +package, export, or params are invalid. On success it returns `contract` +metadata, including package id/kody id/name, source id, published commit, +normalized export name, runtime target, available JSDoc/type definition, and +warnings. The warnings are important: Kody currently has JSDoc/type metadata but +no machine-readable params schema for package exports, so params are only +validated as a JSON object. + +`packages.invoke` and `packages.invokeChecked` return the target export's +unwrapped return value. If execution fails, the promise rejects with an error +that includes the package invocation error code in the message. If the +pre-invoke check fails, `packages.invokeChecked` rejects before invoking the +target export. The primary identifier is `kodyId`; `kody_id`, `packageId`, and `package_id` are accepted aliases for compatibility. `exportName` is required, and `export_name` @@ -136,7 +182,10 @@ Ad hoc MCP `execute` code, including saved package modules imported from that execute call, does not get a package runtime context. In those runs `import { packages } from 'kody:runtime'` returns `null`; enter the package through package invocation, a package subscription, a package job, service, or -app when the code needs `packages.invoke`. +app when the code needs `packages.check`, `packages.invoke`, or +`packages.invokeChecked`. Inside package runtime code, the equivalent codemode +helpers are `codemode.package_invoke_check`, `codemode.package_invoke`, and +`codemode.package_invoke_checked`. If `idempotencyKey` is omitted, Kody generates one. In package invocations that already have a parent idempotency key, the generated key is deterministic for diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts index 07ee98aec2..3b0a503d8a 100644 --- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts +++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts @@ -1513,13 +1513,43 @@ test('runBundledModuleWithRegistry injects package invocation helper when provid 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 }), }, }, ) expect(result.result).toBe('ok') expect(providerFns).not.toBeNull() + await expect( + providerFns?.package_invoke_check({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + }), + ).resolves.toMatchObject({ + ok: true, + invoke: { + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + }, + }) await expect( providerFns?.package_invoke({ kodyId: 'discord-general-chat', @@ -1532,6 +1562,18 @@ test('runBundledModuleWithRegistry injects package invocation helper when provid exportName: './handle-discord-message-created', }, }) + await expect( + providerFns?.package_invoke_checked({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + }), + ).resolves.toEqual({ + ok: true, + input: { + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + }, + }) } finally { createExecuteExecutorSpy.mockRestore() getRegistrySpy.mockRestore() diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index 160d7e040f..2c481b8393 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -79,8 +79,45 @@ type EmailToolOptions = { export type PackageInvokeInput = Record +export type PackageInvokeNormalizedInput = { + kodyId?: string + packageId?: string + exportName: string + params?: Record + idempotencyKey?: string + topic?: string +} + +export type PackageInvokeContract = { + packageId: string + kodyId: string + name: string + sourceId: string + publishedCommit: string | null + exportName: string + runtimeTarget: string | null + description?: string | null + typeDefinition?: string | null + warnings: Array +} + +export type PackageInvokeCheckResult = + | { + ok: true + invoke: PackageInvokeNormalizedInput + contract: PackageInvokeContract + } + | { + ok: false + message: string + problems: Array + contract?: Partial + } + export type PackageInvokeTools = { invoke: (input: PackageInvokeInput) => Promise + check: (input: PackageInvokeInput) => Promise + invokeChecked: (input: PackageInvokeInput) => Promise } export type PackageWorkflowTools = { @@ -246,7 +283,9 @@ const workflows = { function createPackagesHelperPrelude() { return ` const packages = { + check: async (input) => await codemode.package_invoke_check(input ?? {}), invoke: async (input) => await codemode.package_invoke(input ?? {}), + invokeChecked: async (input) => await codemode.package_invoke_checked(input ?? {}), }; `.trim() } @@ -419,8 +458,14 @@ export async function buildCodemodeFns( const packageInvokeTools = options?.packageInvokeTools const packageInvokeCodemodeTools: AdditionalCodemodeTools = packageInvokeTools ? { + package_invoke_check: async (args: unknown) => + await packageInvokeTools.check((args ?? {}) as PackageInvokeInput), package_invoke: async (args: unknown) => await packageInvokeTools.invoke((args ?? {}) as PackageInvokeInput), + package_invoke_checked: async (args: unknown) => + await packageInvokeTools.invokeChecked( + (args ?? {}) as PackageInvokeInput, + ), } : {} assertNoCapabilityCollisions(capabilityMap, packageInvokeCodemodeTools) diff --git a/packages/worker/src/mcp/server-instructions.ts b/packages/worker/src/mcp/server-instructions.ts index dc162c2695..99742dcb0b 100644 --- a/packages/worker/src/mcp/server-instructions.ts +++ b/packages/worker/src/mcp/server-instructions.ts @@ -91,7 +91,7 @@ search execute - Single ESM module string with a default export such as \`export default async function main(input = {}) { ... }\`; \`params\` are passed as the first argument. Import runtime APIs from \`kody:runtime\`. Example: \`import { codemode, refreshAccessToken, createAuthenticatedFetch, workflows } from 'kody:runtime'\`. \`workflows.create\` can queue either inline \`code\` or a saved-package \`exportName\` through the shared Workflow hub. Prefer one \`execute\` when the plan is clear. Full rules for \`fetch\`, placeholders, \`secret_list\` / \`value_get\`, and \`x-kody-secret\`: see the \`execute\` tool description. -- Cross-package imports use specifiers such as \`kody:@scope/my-package/export-name\`. Saved package names must be scoped (\`@scope/\`) and the leaf segment must match \`kody.id\`. Package jobs are owned by packages, ad hoc jobs can be scheduled with \`job_schedule\`, and package apps are optional package surfaces. +- Cross-package imports use specifiers such as \`kody:@scope/my-package/export-name\`. For dynamic current-version calls inside package runtime code, prefer \`packages.invokeChecked({ kodyId, exportName, params })\` or \`packages.check(...)\` followed by \`packages.invoke(check.invoke)\`; use static imports for library-like bundled snapshots. Saved package names must be scoped (\`@scope/\`) and the leaf segment must match \`kody.id\`. Package jobs are owned by packages, ad hoc jobs can be scheduled with \`job_schedule\`, and package apps are optional package surfaces. - Official how-to guides from the Kody repo: if a requested package or workflow depends on a third-party integration, secrets, or OAuth, call \`kody_official_guide\` with \`guide: "integration_bootstrap"\` before building the package. Then load the relevant setup guide: \`oauth\` for standard third-party OAuth (\`/connect/oauth\`), \`connect_secret\` for secret collection, and \`secret_backed_integration\` for the default non-OAuth secret-backed recipe after bootstrap. If unsure, \`search\` for this capability and load the right guide before implementing. - Do not save or present an auth-dependent package as complete until \`search\` shows the required integration or secret reference exists and a minimal authenticated \`execute\` smoke test succeeds. diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index edff8cc234..023bff9c12 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -53,7 +53,7 @@ Sandbox surface: - \`import { workflows } from 'kody:runtime'\` for durable Cloudflare Workflows. \`workflows.create\` accepts either inline \`code\` or a saved-package \`exportName\`; use \`workflow_list\` to inspect recent runs. - 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. -- \`import { packages } from 'kody:runtime'\` exposes \`packages.invoke(...)\` only in saved package runtime contexts; it is \`null\` for ad hoc execute calls. +- \`import { packages } from 'kody:runtime'\` exposes \`packages.check(...)\`, \`packages.invoke(...)\`, and \`packages.invokeChecked(...)\` only in saved package runtime contexts; it is \`null\` for ad hoc execute calls. Prefer \`invokeChecked\` for dynamic current-version package calls unless you already called \`check\` and pass \`check.invoke\` to \`invoke\`. - \`fetch(...)\` is the host-provided network global; \`{{secret:name}}\` / \`{{secret:name|scope=user}}\` work in URL, headers, or body on approved hosts only. - 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). diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index 251193dfc6..a8444a0e90 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -471,6 +471,28 @@ function seedRuntimeDispatchPackages() { }), ], ]) + const sourceFiles = new Map([ + [ + 'source-gateway', + { + 'package.json': JSON.stringify(manifests.get('source-gateway')), + 'src/dispatch-message-created.ts': + 'export default async function dispatchMessageCreated(input: Record) { return input }', + }, + ], + [ + 'source-subscriber', + { + 'package.json': JSON.stringify(manifests.get('source-subscriber')), + 'src/handle-discord-message-created.ts': `/** + * Handle a Discord message-created event. + */ +export default async function handleDiscordMessageCreated(input: { event: { id: string }, dryRun?: boolean }): Promise<{ handled: boolean }> { + return { handled: true } +}`, + }, + ], + ]) repoMockModule.getSavedPackageById.mockResolvedValue(null) repoMockModule.getSavedPackageByKodyId.mockImplementation( async (_db: unknown, input: { userId: string; kodyId: string }) => { @@ -486,11 +508,13 @@ function seedRuntimeDispatchPackages() { manifest: manifests.get(input.sourceId), }), ) - repoMockModule.loadPackageSourceBySourceId.mockResolvedValue({ - source: sources.get('source-gateway'), - manifest: manifests.get('source-gateway'), - files: {}, - }) + repoMockModule.loadPackageSourceBySourceId.mockImplementation( + async (input: { sourceId: string }) => ({ + source: sources.get(input.sourceId), + manifest: manifests.get(input.sourceId), + files: sourceFiles.get(input.sourceId) ?? {}, + }), + ) repoMockModule.getEntitySourceById.mockImplementation( async (_db: unknown, sourceId: string) => sources.get(sourceId) ?? null, ) @@ -531,7 +555,36 @@ function seedRuntimeDispatchPackages() { return null }, ) - return { gateway, subscriber } + return { gateway, manifests, sourceFiles, sources, subscriber } +} + +function createRuntimeDispatchTools(db: D1Database) { + return createPackageRuntimeInvokeTools({ + env: createEnv(db), + baseUrl: 'https://kody.dev', + callerContext: createMcpCallerContext({ + baseUrl: 'https://kody.dev', + user: { + userId: 'user-123', + email: 'me@example.com', + displayName: 'Me', + }, + }), + packageContext: { + packageId: 'pkg-gateway', + kodyId: 'discord-gateway', + sourceId: 'source-gateway', + }, + parentRuntimeDebug: { + packageId: 'pkg-gateway', + kodyId: 'discord-gateway', + sourceId: 'source-gateway', + surface: 'export', + name: './dispatch-message-created', + idempotencyKey: 'message-1', + }, + packageInvokeDepth: 0, + }) } test('invokePackageExport executes a scoped package export successfully', async () => { @@ -702,6 +755,173 @@ test('package runtime can dynamically invoke the current published export from a ).toEqual(expect.any(Function)) }) +test('package runtime checks and invokes another package with current contract metadata', async () => { + const db = createDatabase() + seedRuntimeDispatchPackages() + repoMockModule.runBundledModuleWithRegistry.mockResolvedValue({ + result: { handled: true, eventId: 'message-1' }, + logs: [], + }) + const tools = createRuntimeDispatchTools(db) + + const check = await tools.check({ + kodyId: 'discord-general-chat', + exportName: 'handle-discord-message-created', + params: { event: { id: 'message-1' }, dryRun: true }, + idempotencyKey: 'message-1', + 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).toEqual([ + 'No machine-readable params schema is published for package exports; params were only validated as a JSON object.', + ]) + if (!check.ok) throw new Error(check.message) + + const result = await tools.invoke(check.invoke) + + expect(result).toEqual({ handled: true, eventId: 'message-1' }) + expect(repoMockModule.runBundledModuleWithRegistry).toHaveBeenCalledTimes(1) +}) + +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 check returns clear failures for invalid package export and params', async () => { + const db = createDatabase() + seedRuntimeDispatchPackages() + const tools = createRuntimeDispatchTools(db) + + await expect( + tools.check({ + 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.'], + }) + 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.'], + }) +}) + +test('package runtime invokeChecked fails before invocation when check fails', async () => { + const db = createDatabase() + seedRuntimeDispatchPackages() + repoMockModule.runBundledModuleWithRegistry.mockClear() + const tools = createRuntimeDispatchTools(db) + + await expect( + tools.invokeChecked({ + kodyId: 'discord-general-chat', + exportName: './missing-export', + params: {}, + }), + ).rejects.toThrow( + 'packages.invokeChecked check failed: Package "discord-general-chat" does not define export "./missing-export".', + ) + expect(repoMockModule.runBundledModuleWithRegistry).not.toHaveBeenCalled() +}) + test('package runtime invocation errors clearly when the target package is missing', async () => { const db = createDatabase() seedRuntimeDispatchPackages() diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index a972a47dd5..290800679a 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -5,7 +5,10 @@ import { extractRawContent, getExecutionErrorDetails } from '#mcp/executor.ts' import { createMcpCallerContext } from '#mcp/context.ts' import { runBundledModuleWithRegistry, + type PackageInvokeCheckResult, + type PackageInvokeContract, type PackageInvokeInput, + type PackageInvokeNormalizedInput, type PackageInvokeTools, } from '#mcp/run-codemode-registry.ts' import { @@ -22,8 +25,10 @@ import { } from '#worker/package-registry/source.ts' import { type SavedPackageRecord } from '#worker/package-registry/types.ts' import { + buildPackageSearchProjection, normalizePackageWorkspacePath, resolvePackageExportPath, + type PackageExportProjection, } from '#worker/package-registry/manifest.ts' import { typecheckPackageEntrypointsFromSourceFiles } from '#worker/repo/checks.ts' import { @@ -133,45 +138,64 @@ function readOptionalString( return null } -function readSinglePackageIdentifier(input: PackageInvokeInput) { +function readSinglePackageIdentifier( + input: PackageInvokeInput, + operationName = 'packages.invoke', +) { const candidates = [ - readOptionalString(input, ['kodyId', 'kody_id']), - readOptionalString(input, ['packageId', 'package_id']), - ].filter((value): value is string => value !== null) - const unique = Array.from(new Set(candidates)) + { + kind: 'kodyId' as const, + value: readOptionalString(input, ['kodyId', 'kody_id']), + }, + { + kind: 'packageId' as const, + value: readOptionalString(input, ['packageId', 'package_id']), + }, + ].filter( + (candidate): candidate is { kind: 'kodyId' | 'packageId'; value: string } => + candidate.value !== null, + ) + const unique = Array.from(new Set(candidates.map((candidate) => candidate.value))) if (unique.length > 1) { throw new Error( - 'packages.invoke accepts one package identifier. Use kodyId unless you need the saved package id.', + `${operationName} accepts one package identifier. Use kodyId unless you need the saved package id.`, ) } - const packageIdOrKodyId = unique[0] - if (!packageIdOrKodyId) { - throw new Error('packages.invoke requires kodyId or packageId.') + const [identifier] = candidates + if (!identifier) { + throw new Error(`${operationName} requires kodyId or packageId.`) } - return packageIdOrKodyId + return identifier } -function readPackageInvokeParams(input: PackageInvokeInput) { +function readPackageInvokeParams( + input: PackageInvokeInput, + operationName = 'packages.invoke', +) { const params = input['params'] if (params === undefined || params === null) return undefined if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new Error( - 'packages.invoke params must be a JSON object when provided.', + `${operationName} params must be a JSON object when provided.`, ) } return params as Record } -function parsePackageInvokeInput(input: PackageInvokeInput) { - const packageIdOrKodyId = readSinglePackageIdentifier(input) +function parsePackageInvokeInput( + input: PackageInvokeInput, + operationName = 'packages.invoke', +) { + const packageIdentifier = readSinglePackageIdentifier(input, operationName) const exportName = readOptionalString(input, ['exportName', 'export_name']) if (!exportName) { - throw new Error('packages.invoke requires exportName.') + throw new Error(`${operationName} requires exportName.`) } return { - packageIdOrKodyId, + packageIdentifier, + packageIdOrKodyId: packageIdentifier.value, exportName, - params: readPackageInvokeParams(input), + params: readPackageInvokeParams(input, operationName), idempotencyKey: readOptionalString(input, [ 'idempotencyKey', 'idempotency_key', @@ -296,6 +320,203 @@ async function createAutoPackageInvokeIdempotencyKey(input: { ].join(':') } +function createPackageInvokeCheckFailure(input: { + message: string + problems: Array + contract?: Partial +}): PackageInvokeCheckResult { + return { + ok: false, + message: input.message, + problems: input.problems, + ...(input.contract ? { contract: input.contract } : {}), + } +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +function buildNormalizedPackageInvokeInput(input: { + request: ReturnType + exportName: string +}): PackageInvokeNormalizedInput { + return { + ...(input.request.packageIdentifier.kind === 'kodyId' + ? { kodyId: input.request.packageIdentifier.value } + : { packageId: input.request.packageIdentifier.value }), + exportName: input.exportName, + ...(input.request.params === undefined + ? {} + : { params: input.request.params }), + ...(input.request.idempotencyKey + ? { idempotencyKey: input.request.idempotencyKey } + : {}), + ...(input.request.topic ? { topic: input.request.topic } : {}), + } +} + +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 +}) { + const warnings = [ + 'No machine-readable params schema is published for package exports; params were only validated as a JSON object.', + ] + if (input.sourceLoadFailed) { + warnings.push( + 'Source files could not be loaded for metadata extraction; description and type information may be incomplete.', + ) + } + if (!input.exportDetail?.typeDefinition) { + warnings.push( + 'No function type definition was found for this export; callable shape could not be statically confirmed.', + ) + } + return warnings +} + +async function checkPackageInvokeForRuntime(input: { + env: Env + baseUrl: string + userId: string + rawInput: PackageInvokeInput +}): Promise { + let request: ReturnType + try { + request = parsePackageInvokeInput(input.rawInput, 'packages.check') + } catch (error) { + const message = getErrorMessage(error) + return createPackageInvokeCheckFailure({ + message, + problems: [message], + }) + } + const exportName = normalizeExportName(request.exportName) + const invoke = buildNormalizedPackageInvokeInput({ request, exportName }) + const savedPackage = await resolveSavedPackage({ + db: input.env.APP_DB, + userId: input.userId, + packageIdOrKodyId: request.packageIdOrKodyId, + }) + if (!savedPackage) { + const message = `Saved package "${request.packageIdOrKodyId}" was not found for this user.` + return createPackageInvokeCheckFailure({ + message, + problems: [message], + contract: { exportName }, + }) + } + const packageContract = { + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + name: savedPackage.name, + sourceId: savedPackage.sourceId, + exportName, + } + let manifestResult: Awaited> + try { + manifestResult = await loadPackageManifestBySourceId({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.userId, + sourceId: savedPackage.sourceId, + }) + } catch (error) { + const problem = `Could not load current package manifest: ${getErrorMessage(error)}` + return createPackageInvokeCheckFailure({ + message: problem, + problems: [problem], + contract: packageContract, + }) + } + let runtimeTarget: string | null = null + try { + runtimeTarget = resolvePackageExportPath({ + manifest: manifestResult.manifest, + exportName, + }) + } catch (error) { + const problem = getErrorMessage(error) + return createPackageInvokeCheckFailure({ + message: problem, + problems: [problem], + contract: { + ...packageContract, + publishedCommit: manifestResult.source.published_commit ?? null, + }, + }) + } + try { + await ensureModuleArtifact({ + env: input.env, + baseUrl: input.baseUrl, + savedPackage, + selector: { + kind: 'export', + exportName, + }, + userId: input.userId, + }) + } catch (error) { + const problem = `Export "${exportName}" could not be prepared for invocation: ${getErrorMessage(error)}` + return createPackageInvokeCheckFailure({ + message: problem, + problems: [problem], + contract: { + ...packageContract, + publishedCommit: manifestResult.source.published_commit ?? null, + runtimeTarget, + }, + }) + } + 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 { + ok: true, + invoke, + contract: { + ...packageContract, + publishedCommit: manifestResult.source.published_commit ?? null, + runtimeTarget: exportDetail?.runtimeTarget ?? runtimeTarget, + description: exportDetail?.description ?? null, + typeDefinition: exportDetail?.typeDefinition ?? null, + warnings, + }, + } +} + async function resolveSavedPackage(input: { db: D1Database userId: string @@ -1033,70 +1254,103 @@ export function createPackageRuntimeInvokeTools(input: { packageInvokeDepth?: number }): PackageInvokeTools { let autoIdempotencySequence = 0 + const requireRuntimeCaller = (operationName: string) => { + const user = input.callerContext.user + if (!user?.userId) { + throw new Error(`${operationName} requires an authenticated user.`) + } + if (!input.packageContext) { + throw new Error(`${operationName} requires a package runtime context.`) + } + return { user, packageContext: input.packageContext } + } + const invoke = async (rawInput: PackageInvokeInput) => { + const { user, packageContext } = requireRuntimeCaller('packages.invoke') + const packageInvokeDepth = input.packageInvokeDepth ?? 0 + if (packageInvokeDepth >= maxPackageRuntimeInvokeDepth) { + throw new Error( + `packages.invoke exceeded the maximum nested invocation depth (${maxPackageRuntimeInvokeDepth}).`, + ) + } + const request = parsePackageInvokeInput(rawInput) + autoIdempotencySequence += 1 + const idempotencyKey = + request.idempotencyKey ?? + (await createAutoPackageInvokeIdempotencyKey({ + callerPackageContext: packageContext, + parentRuntimeDebug: input.parentRuntimeDebug ?? null, + sequence: autoIdempotencySequence, + request, + })) + const response = await invokePackageExportForPackageRuntime({ + env: input.env, + baseUrl: input.baseUrl, + caller: { + userId: user.userId, + email: user.email ?? '', + displayName: user.displayName ?? '', + remoteConnectors: input.callerContext.remoteConnectors ?? null, + packageContext, + }, + request: { + packageIdOrKodyId: request.packageIdOrKodyId, + exportName: request.exportName, + params: request.params, + idempotencyKey, + source: `package:${packageContext.kodyId}`, + topic: request.topic, + }, + runtimeInvokeDepth: packageInvokeDepth + 1, + }) + if (response.status >= 200 && response.status < 400) { + return response.body['result'] + } + const errorRecord = + (response.body['error'] as Record | undefined) ?? {} + const code = String(errorRecord['code'] ?? 'package_invocation_failed') + const message = String( + errorRecord['message'] ?? + `Package invocation failed with HTTP ${response.status}.`, + ) + const error = new Error(`[${code}] ${message}`) as Error & { + code?: string + status?: number + response?: PackageInvocationResponse + } + error.code = code + error.status = response.status + error.response = response + throw error + } return { - invoke: async (rawInput) => { - const user = input.callerContext.user - if (!user?.userId) { - throw new Error('packages.invoke requires an authenticated user.') - } - if (!input.packageContext) { - throw new Error('packages.invoke requires a package runtime context.') - } - const packageInvokeDepth = input.packageInvokeDepth ?? 0 - if (packageInvokeDepth >= maxPackageRuntimeInvokeDepth) { - throw new Error( - `packages.invoke exceeded the maximum nested invocation depth (${maxPackageRuntimeInvokeDepth}).`, - ) - } - const request = parsePackageInvokeInput(rawInput) - autoIdempotencySequence += 1 - const idempotencyKey = - request.idempotencyKey ?? - (await createAutoPackageInvokeIdempotencyKey({ - callerPackageContext: input.packageContext, - parentRuntimeDebug: input.parentRuntimeDebug ?? null, - sequence: autoIdempotencySequence, - request, - })) - const response = await invokePackageExportForPackageRuntime({ + check: async (rawInput) => { + const { user } = requireRuntimeCaller('packages.check') + return await checkPackageInvokeForRuntime({ env: input.env, baseUrl: input.baseUrl, - caller: { - userId: user.userId, - email: user.email ?? '', - displayName: user.displayName ?? '', - remoteConnectors: input.callerContext.remoteConnectors ?? null, - packageContext: input.packageContext, - }, - request: { - packageIdOrKodyId: request.packageIdOrKodyId, - exportName: request.exportName, - params: request.params, - idempotencyKey, - source: `package:${input.packageContext.kodyId}`, - topic: request.topic, - }, - runtimeInvokeDepth: packageInvokeDepth + 1, + userId: user.userId, + rawInput, }) - if (response.status >= 200 && response.status < 400) { - return response.body['result'] - } - const errorRecord = - (response.body['error'] as Record | undefined) ?? {} - const code = String(errorRecord['code'] ?? 'package_invocation_failed') - const message = String( - errorRecord['message'] ?? - `Package invocation failed with HTTP ${response.status}.`, - ) - const error = new Error(`[${code}] ${message}`) as Error & { - code?: string - status?: number - response?: PackageInvocationResponse + }, + invoke, + invokeChecked: async (rawInput) => { + const { user } = requireRuntimeCaller('packages.invokeChecked') + const check = await checkPackageInvokeForRuntime({ + env: input.env, + baseUrl: input.baseUrl, + userId: user.userId, + rawInput, + }) + if (!check.ok) { + const error = new Error( + `packages.invokeChecked check failed: ${check.message}`, + ) as Error & { + check?: PackageInvokeCheckResult + } + error.check = check + throw error } - error.code = code - error.status = response.status - error.response = response - throw error + return await invoke(check.invoke) }, } } 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 5ed6ae9f00..ea60d9619c 100644 --- a/packages/worker/src/package-runtime/module-graph.workers.test.ts +++ b/packages/worker/src/package-runtime/module-graph.workers.test.ts @@ -152,7 +152,9 @@ test('saved package execution exposes packages.invoke when package invoke tools 'export default async function main(input = {}) {', '\treturn {', '\t\tpackageId: packageContext?.packageId ?? null,', + "\t\thasCheck: typeof packages?.check === 'function',", "\t\thasInvoke: typeof packages?.invoke === 'function',", + "\t\thasInvokeChecked: typeof packages?.invokeChecked === 'function',", '\t\tinvoked: await packages?.invoke({', "\t\t\tkodyId: 'target-package',", "\t\t\texportName: './run',", @@ -187,10 +189,32 @@ 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, }, @@ -199,7 +223,9 @@ test('saved package execution exposes packages.invoke when package invoke tools expect(result.error).toBeUndefined() expect(result.result).toEqual({ packageId: 'pkg-invoker', + hasCheck: true, hasInvoke: true, + hasInvokeChecked: true, invoked: { ok: true, input: { diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 591c34b4c6..35590622e9 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -267,7 +267,10 @@ function createWorkflowsProxy(runtimeBridge) { function createPackagesProxy(runtimeBridge) { return { + check: async (input) => await runtimeBridge.packageInvokeCheck(input ?? {}), invoke: async (input) => await runtimeBridge.packageInvoke(input ?? {}), + invokeChecked: async (input) => + await runtimeBridge.packageInvokeChecked(input ?? {}), }; } @@ -999,6 +1002,48 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< }) return await tools.invoke(input) } + + async packageInvokeCheck(input: Record) { + // Avoid a top-level package-app -> package-invocations cycle during worker + // startup; apps only need this helper when package code calls it. + const { createPackageRuntimeInvokeTools } = + await import('#worker/package-invocations/service.ts') + const packageContext = { + packageId: this.ctx.props.packageId, + kodyId: this.ctx.props.kodyId, + sourceId: this.ctx.props.sourceId, + } + const tools = createPackageRuntimeInvokeTools({ + env: this.env, + baseUrl: this.ctx.props.baseUrl, + callerContext: this.createCallerContext(this.ctx.props.packageId), + packageContext, + parentRuntimeDebug: null, + packageInvokeDepth: 0, + }) + return await tools.check(input) + } + + async packageInvokeChecked(input: Record) { + // Avoid a top-level package-app -> package-invocations cycle during worker + // startup; apps only need this helper when package code calls it. + const { createPackageRuntimeInvokeTools } = + await import('#worker/package-invocations/service.ts') + const packageContext = { + packageId: this.ctx.props.packageId, + kodyId: this.ctx.props.kodyId, + sourceId: this.ctx.props.sourceId, + } + const tools = createPackageRuntimeInvokeTools({ + env: this.env, + baseUrl: this.ctx.props.baseUrl, + callerContext: this.createCallerContext(this.ctx.props.packageId), + packageContext, + parentRuntimeDebug: null, + packageInvokeDepth: 0, + }) + return await tools.invokeChecked(input) + } } export async function buildPackageAppWorker(input: { diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index 27545a659a..3f5180fd4a 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -228,8 +228,53 @@ type KodyPackagesInvokeInput = KodyPackagesInvokeTarget & idempotency_key?: string; topic?: string | null; }; +type KodyPackagesInvokeNormalizedInput = { + kodyId?: string; + packageId?: string; + exportName: string; + params?: Record; + idempotencyKey?: string; + topic?: string; +}; +type KodyPackagesInvokeContract = { + packageId: string; + kodyId: string; + name: string; + sourceId: string; + publishedCommit: string | null; + exportName: string; + runtimeTarget: string | null; + description?: string | null; + typeDefinition?: string | null; + warnings: string[]; +}; +type KodyPackagesInvokeCheckResult = + | { + ok: true; + invoke: KodyPackagesInvokeNormalizedInput; + contract: KodyPackagesInvokeContract; + } + | { + ok: false; + message: string; + problems: string[]; + contract?: Partial; + }; type KodyPackagesRuntime = { + /** + * Actively resolves the current published package export, validates the + * invocation input as far as Kody metadata allows, and returns a normalized + * input suitable for packages.invoke. This is runtime contract visibility, + * not compile-time type safety; warnings explain weak validation. + */ + check(input: KodyPackagesInvokeInput): Promise; invoke(input: KodyPackagesInvokeInput): Promise; + /** + * Runs packages.check first, throws on check failure, then invokes the + * normalized current package export. Prefer this over packages.invoke when + * dynamically calling a package whose contract may have changed. + */ + invokeChecked(input: KodyPackagesInvokeInput): Promise; }; type KodyStorageRuntime = { id: string; From a1db47cb4155e52c9b9b477ad2e885cd08714307 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 10 May 2026 17:46:06 +0000 Subject: [PATCH 2/7] Format package invoke check changes Co-authored-by: Kent C. Dodds --- docs/contributing/packages-and-manifests.md | 10 +++++----- .../src/package-invocations/service.node.test.ts | 3 ++- packages/worker/src/package-invocations/service.ts | 14 ++++++++++---- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index e0c636d234..c8184f4bbb 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -107,9 +107,9 @@ time through the package invocation service, using the current authenticated user and package caller context. Package code never handles external package-invocation bearer tokens for this flow. -Use dynamic invocation for runtime dispatch surfaces that must pick up the target -package's current published bundle, such as event subscribers, workflows, and -agents. Prefer `packages.invokeChecked` for new dynamic calls so Kody first +Use dynamic invocation for runtime dispatch surfaces that must pick up the +target package's current published bundle, such as event subscribers, workflows, +and agents. Prefer `packages.invokeChecked` for new dynamic calls so Kody first checks that the current package and export exist, params are a JSON object, and the current contract metadata can be surfaced to the caller. Use `packages.check` directly when a caller wants to inspect the current contract or @@ -128,8 +128,8 @@ const result = await packages.invoke(check.invoke) Continue to use static `kody:@scope/package/export` imports for library-like dependencies where the caller should keep the dependency bundle it was published -with. Use bare `packages.invoke` only after a successful `packages.check` or when -the caller intentionally accepts direct runtime failure. +with. Use bare `packages.invoke` only after a successful `packages.check` or +when the caller intentionally accepts direct runtime failure. `packages.check` returns the current package id/kody id/name, source id, published commit, normalized export name, runtime target, and available diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index a8444a0e90..b97954540a 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -824,7 +824,8 @@ test('package runtime check reads target package metadata changes without republ }), ) sourceFiles.set('source-subscriber', { - 'package.json': sourceFiles.get('source-subscriber')?.['package.json'] ?? '', + 'package.json': + sourceFiles.get('source-subscriber')?.['package.json'] ?? '', 'src/handle-discord-message-created.ts': `/** * Handle the current Discord message-created event contract. */ diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index 290800679a..17fb6319b1 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -155,7 +155,9 @@ function readSinglePackageIdentifier( (candidate): candidate is { kind: 'kodyId' | 'packageId'; value: string } => candidate.value !== null, ) - const unique = Array.from(new Set(candidates.map((candidate) => candidate.value))) + const unique = Array.from( + new Set(candidates.map((candidate) => candidate.value)), + ) if (unique.length > 1) { throw new Error( `${operationName} accepts one package identifier. Use kodyId unless you need the saved package id.`, @@ -361,8 +363,9 @@ function findPackageExportProjection(input: { exportName: string }) { return ( - input.exports.find((exportDetail) => exportDetail.subpath === input.exportName) ?? - null + input.exports.find( + (exportDetail) => exportDetail.subpath === input.exportName, + ) ?? null ) } @@ -494,7 +497,10 @@ async function checkPackageInvokeForRuntime(input: { } catch { sourceLoadFailed = true } - const projection = buildPackageSearchProjection(manifestResult.manifest, files) + const projection = buildPackageSearchProjection( + manifestResult.manifest, + files, + ) const exportDetail = findPackageExportProjection({ exports: projection.exports, exportName, From 9a590312ea31d845360d77c1b5ee8ebb5353d680 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 10 May 2026 17:52:11 +0000 Subject: [PATCH 3/7] Address package invoke review cleanup Co-authored-by: Kent C. Dodds --- .../worker/src/package-invocations/service.ts | 5 +-- .../worker/src/package-runtime/package-app.ts | 42 ++++--------------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index 17fb6319b1..68e96d5a18 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -3,6 +3,7 @@ import { toHex } from '@kody-internal/shared/hex.ts' import { type RemoteConnectorRef } from '@kody-internal/shared/remote-connectors.ts' import { extractRawContent, getExecutionErrorDetails } from '#mcp/executor.ts' import { createMcpCallerContext } from '#mcp/context.ts' +import { getErrorMessage } from '#mcp/capabilities/error-message.ts' import { runBundledModuleWithRegistry, type PackageInvokeCheckResult, @@ -335,10 +336,6 @@ function createPackageInvokeCheckFailure(input: { } } -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error) -} - function buildNormalizedPackageInvokeInput(input: { request: ReturnType exportName: string diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 35590622e9..1fe2f48174 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -982,7 +982,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< }) } - async packageInvoke(input: Record) { + async createPackageRuntimeInvokeTools() { // Avoid a top-level package-app -> package-invocations cycle during worker // startup; apps only need this helper when package code calls it. const { createPackageRuntimeInvokeTools } = @@ -1000,48 +1000,20 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< parentRuntimeDebug: null, packageInvokeDepth: 0, }) + } + + async packageInvoke(input: Record) { + const tools = await this.createPackageRuntimeInvokeTools() return await tools.invoke(input) } async packageInvokeCheck(input: Record) { - // Avoid a top-level package-app -> package-invocations cycle during worker - // startup; apps only need this helper when package code calls it. - const { createPackageRuntimeInvokeTools } = - await import('#worker/package-invocations/service.ts') - const packageContext = { - packageId: this.ctx.props.packageId, - kodyId: this.ctx.props.kodyId, - sourceId: this.ctx.props.sourceId, - } - const tools = createPackageRuntimeInvokeTools({ - env: this.env, - baseUrl: this.ctx.props.baseUrl, - callerContext: this.createCallerContext(this.ctx.props.packageId), - packageContext, - parentRuntimeDebug: null, - packageInvokeDepth: 0, - }) + const tools = await this.createPackageRuntimeInvokeTools() return await tools.check(input) } async packageInvokeChecked(input: Record) { - // Avoid a top-level package-app -> package-invocations cycle during worker - // startup; apps only need this helper when package code calls it. - const { createPackageRuntimeInvokeTools } = - await import('#worker/package-invocations/service.ts') - const packageContext = { - packageId: this.ctx.props.packageId, - kodyId: this.ctx.props.kodyId, - sourceId: this.ctx.props.sourceId, - } - const tools = createPackageRuntimeInvokeTools({ - env: this.env, - baseUrl: this.ctx.props.baseUrl, - callerContext: this.createCallerContext(this.ctx.props.packageId), - packageContext, - parentRuntimeDebug: null, - packageInvokeDepth: 0, - }) + const tools = await this.createPackageRuntimeInvokeTools() return await tools.invokeChecked(input) } } From 841211cde935d13584a6d9545133b1dfc169e261 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 10 May 2026 17:54:10 +0000 Subject: [PATCH 4/7] Return package app invoke tools Co-authored-by: Kent C. Dodds --- packages/worker/src/package-runtime/package-app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 1fe2f48174..8eb0109287 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -992,7 +992,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< kodyId: this.ctx.props.kodyId, sourceId: this.ctx.props.sourceId, } - const tools = createPackageRuntimeInvokeTools({ + return createPackageRuntimeInvokeTools({ env: this.env, baseUrl: this.ctx.props.baseUrl, callerContext: this.createCallerContext(this.ctx.props.packageId), From 998e62e24debff8c45f0a591d2561923dae36a8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 17:44:27 +0000 Subject: [PATCH 5/7] Reuse package check manifest resolution Co-authored-by: Kent C. Dodds --- .../package-invocations/service.node.test.ts | 2 + .../worker/src/package-invocations/service.ts | 41 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index b97954540a..cbcf43dd7f 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -762,6 +762,7 @@ test('package runtime checks and invokes another package with current contract m result: { handled: true, eventId: 'message-1' }, logs: [], }) + repoMockModule.loadPackageManifestBySourceId.mockClear() const tools = createRuntimeDispatchTools(db) const check = await tools.check({ @@ -797,6 +798,7 @@ test('package runtime checks and invokes another package with current contract m expect(check.ok && check.contract.warnings).toEqual([ 'No machine-readable params schema is published for package exports; params were only validated as a JSON object.', ]) + expect(repoMockModule.loadPackageManifestBySourceId).toHaveBeenCalledTimes(1) if (!check.ok) throw new Error(check.message) const result = await tools.invoke(check.invoke) diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index 68e96d5a18..b9af15fd2c 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -440,11 +440,14 @@ async function checkPackageInvokeForRuntime(input: { contract: packageContract, }) } - let runtimeTarget: string | null = null + let resolution: PackageModuleResolution try { - runtimeTarget = resolvePackageExportPath({ + resolution = resolvePackageModuleResolution({ manifest: manifestResult.manifest, - exportName, + selector: { + kind: 'export', + exportName, + }, }) } catch (error) { const problem = getErrorMessage(error) @@ -461,6 +464,8 @@ async function checkPackageInvokeForRuntime(input: { await ensureModuleArtifact({ env: input.env, baseUrl: input.baseUrl, + packageManifest: manifestResult, + resolution, savedPackage, selector: { kind: 'export', @@ -476,7 +481,7 @@ async function checkPackageInvokeForRuntime(input: { contract: { ...packageContract, publishedCommit: manifestResult.source.published_commit ?? null, - runtimeTarget, + runtimeTarget: resolution.entryPoint, }, }) } @@ -512,7 +517,7 @@ async function checkPackageInvokeForRuntime(input: { contract: { ...packageContract, publishedCommit: manifestResult.source.published_commit ?? null, - runtimeTarget: exportDetail?.runtimeTarget ?? runtimeTarget, + runtimeTarget: exportDetail?.runtimeTarget ?? resolution.entryPoint, description: exportDetail?.description ?? null, typeDefinition: exportDetail?.typeDefinition ?? null, warnings, @@ -570,20 +575,26 @@ function tokenAllowsSource(input: { async function ensureModuleArtifact(input: { env: Env baseUrl: string + packageManifest?: Awaited> + resolution?: PackageModuleResolution savedPackage: SavedPackageRecord selector: PackageModuleSelector userId: string }) { - const packageManifest = await loadPackageManifestBySourceId({ - env: input.env, - baseUrl: input.baseUrl, - userId: input.userId, - sourceId: input.savedPackage.sourceId, - }) - const resolution = resolvePackageModuleResolution({ - manifest: packageManifest.manifest, - selector: input.selector, - }) + const packageManifest = + input.packageManifest ?? + (await loadPackageManifestBySourceId({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.userId, + sourceId: input.savedPackage.sourceId, + })) + const resolution = + input.resolution ?? + resolvePackageModuleResolution({ + manifest: packageManifest.manifest, + selector: input.selector, + }) const loaded = await loadPublishedBundleArtifactByIdentity({ env: input.env, userId: input.userId, From ebc556b24d24360d7a4ca93e32e200ac72d92684 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 17:54:37 +0000 Subject: [PATCH 6/7] Address package check review feedback Co-authored-by: Kent C. Dodds --- docs/use/packages.md | 6 +++--- .../worker/src/package-invocations/service.node.test.ts | 9 +++++++++ packages/worker/src/package-invocations/service.ts | 5 ++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/use/packages.md b/docs/use/packages.md index 13163de617..aefefeb453 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -159,9 +159,9 @@ bundling the currently published dependency with the caller is desired. package, export, or params are invalid. On success it returns `contract` metadata, including package id/kody id/name, source id, published commit, normalized export name, runtime target, available JSDoc/type definition, and -warnings. The warnings are important: Kody currently has JSDoc/type metadata but -no machine-readable params schema for package exports, so params are only -validated as a JSON object. +warnings on `check.contract.warnings`. Those warnings are important: Kody +currently has JSDoc/type metadata but no machine-readable params schema for +package exports, so params are only validated as a JSON object. `packages.invoke` and `packages.invokeChecked` return the target export's unwrapped return value. If execution fails, the promise rejects with an error diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index cbcf43dd7f..24d815295d 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -922,6 +922,15 @@ test('package runtime invokeChecked fails before invocation when check fails', a ).rejects.toThrow( 'packages.invokeChecked check failed: Package "discord-general-chat" does not define export "./missing-export".', ) + await expect( + tools.invokeChecked({ + kodyId: 'discord-general-chat', + exportName: './handle-discord-message-created', + params: 'not-an-object', + }), + ).rejects.toThrow( + 'packages.invokeChecked check failed: packages.invokeChecked params must be a JSON object when provided.', + ) expect(repoMockModule.runBundledModuleWithRegistry).not.toHaveBeenCalled() }) diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index b9af15fd2c..4eb60ec944 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -389,12 +389,13 @@ function buildPackageInvokeCheckWarnings(input: { async function checkPackageInvokeForRuntime(input: { env: Env baseUrl: string + operationName: 'packages.check' | 'packages.invokeChecked' userId: string rawInput: PackageInvokeInput }): Promise { let request: ReturnType try { - request = parsePackageInvokeInput(input.rawInput, 'packages.check') + request = parsePackageInvokeInput(input.rawInput, input.operationName) } catch (error) { const message = getErrorMessage(error) return createPackageInvokeCheckFailure({ @@ -1342,6 +1343,7 @@ export function createPackageRuntimeInvokeTools(input: { return await checkPackageInvokeForRuntime({ env: input.env, baseUrl: input.baseUrl, + operationName: 'packages.check', userId: user.userId, rawInput, }) @@ -1352,6 +1354,7 @@ export function createPackageRuntimeInvokeTools(input: { const check = await checkPackageInvokeForRuntime({ env: input.env, baseUrl: input.baseUrl, + operationName: 'packages.invokeChecked', userId: user.userId, rawInput, }) From 5894908f44ebfca9c5daa0e5df4e14df71a6615a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 18:03:30 +0000 Subject: [PATCH 7/7] Cache package app invoke tools Co-authored-by: Kent C. Dodds --- .../worker/src/package-runtime/package-app.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 8eb0109287..93ddb77954 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -4,7 +4,10 @@ import { getPackageAppEntryPath, parseAuthoredPackageJson, } from '#worker/package-registry/manifest.ts' -import { buildCodemodeFns } from '#mcp/run-codemode-registry.ts' +import { + buildCodemodeFns, + type PackageInvokeTools, +} from '#mcp/run-codemode-registry.ts' import { getCapabilityRegistryForContext } from '#mcp/capabilities/registry.ts' import { createAuthenticatedFetch, @@ -610,6 +613,8 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< Env, PackageAppRuntimeBridgeProps > { + private packageRuntimeInvokeTools: Promise | null = null + private createCallerContext(storageId: string | null) { return createMcpCallerContext({ baseUrl: this.ctx.props.baseUrl, @@ -983,23 +988,30 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< } async createPackageRuntimeInvokeTools() { + if (this.packageRuntimeInvokeTools) + return await this.packageRuntimeInvokeTools + // Avoid a top-level package-app -> package-invocations cycle during worker // startup; apps only need this helper when package code calls it. - const { createPackageRuntimeInvokeTools } = - await import('#worker/package-invocations/service.ts') - const packageContext = { - packageId: this.ctx.props.packageId, - kodyId: this.ctx.props.kodyId, - sourceId: this.ctx.props.sourceId, - } - return createPackageRuntimeInvokeTools({ - env: this.env, - baseUrl: this.ctx.props.baseUrl, - callerContext: this.createCallerContext(this.ctx.props.packageId), - packageContext, - parentRuntimeDebug: null, - packageInvokeDepth: 0, - }) + this.packageRuntimeInvokeTools = + import('#worker/package-invocations/service.ts').then( + ({ createPackageRuntimeInvokeTools }) => { + const packageContext = { + packageId: this.ctx.props.packageId, + kodyId: this.ctx.props.kodyId, + sourceId: this.ctx.props.sourceId, + } + return createPackageRuntimeInvokeTools({ + env: this.env, + baseUrl: this.ctx.props.baseUrl, + callerContext: this.createCallerContext(this.ctx.props.packageId), + packageContext, + parentRuntimeDebug: null, + packageInvokeDepth: 0, + }) + }, + ) + return await this.packageRuntimeInvokeTools } async packageInvoke(input: Record) {