Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/contributing/packages-and-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,63 @@ The top-level saved identity is the package.
registry.
- Packages may also export non-callable helper modules and values for reuse.

### Dynamic current-version invocation

Package runtime contexts also expose `packages.invoke` from `kody:runtime`:

```ts
import { packages } from 'kody:runtime'

await packages.invoke({
kodyId: 'discord-general-chat',
exportName: './handle-discord-message-created',
params: { event },
})
```

This path deliberately does not rewrite to a static `kody:@...` import during
bundle construction. It resolves the target saved package and export at call
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.

`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] ...`.

Idempotency:

- Callers may pass `idempotencyKey` (or `idempotency_key`) explicitly. This is
recommended for domain events such as Discord message ids.
- If omitted during a parent package invocation with its own idempotency key,
Kody derives a nested key from the parent key, parent runtime surface/name,
call order, target, export, and params so retries do not duplicate the same
nested dispatch.
- If omitted in contexts without a parent invocation key, Kody uses a unique key
because replay is not implied.

Security and loop safeguards:

- Resolution is same-user only; package code cannot invoke another user's saved
package.
- `packages.invoke` requires package runtime context, preserving package-owned
storage and caller metadata.
- Nested dynamic package invocations are depth-limited to prevent runaway
package-to-package loops.

For the Discord gateway/subscriber pattern, switch dispatchers from statically
importing subscriber packages to
`packages.invoke({ kodyId, exportName, params, idempotencyKey })`. Republish
subscribers independently; the gateway will observe the current published
subscriber export on its next dispatch without being republished.

## Package apps

A package app is optional.
Expand Down
49 changes: 49 additions & 0 deletions docs/use/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,55 @@ exhaustive.
`types` file. Package search detail surfaces package descriptions, export
descriptions, function signatures, JSDoc, and type definitions.

### Dynamic package invocation

Package runtime code can invoke another package owned by the same user without
statically importing it:

```ts
import { packages } from 'kody:runtime'

const result = await packages.invoke({
kodyId: 'discord-general-chat',
exportName: './handle-discord-message-created',
params: { event },
})
```

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 static `kody:@scope/package/export` imports for library-like dependencies
where bundling the currently published dependency 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.

The primary identifier is `kodyId`; `kody_id`, `packageId`, and `package_id` are
accepted aliases for compatibility. `exportName` is required, and `export_name`
is accepted as an alias.

Only package runtime contexts can call `packages.invoke`, and resolution is
scoped to packages owned by the current authenticated user. Package code does
not need to mint or pass package-invocation bearer tokens. Nested package
invocations are depth-limited to prevent runaway loops.

If `idempotencyKey` is omitted, Kody generates one. In package invocations that
already have a parent idempotency key, the generated key is deterministic for
the parent key, parent runtime surface/name, call order, target, export, and
params. In contexts without a parent invocation key, Kody uses a unique key,
which avoids accidental replay. Pass your own `idempotencyKey` when the target
operation must dedupe against a domain event id.

For `discord-gateway`, subscriber dispatch should move from static imports such
as `kody:@kentcdodds/discord-general-chat/handle-discord-message-created` to the
dynamic shape above, using the Discord event id as the explicit `idempotencyKey`
when available.

## Package apps

A package app is optional.
Expand Down
84 changes: 52 additions & 32 deletions packages/worker/src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,25 +419,55 @@ async function executePublishedJobArtifact(input: {
bypassLogs: Array<string>
}): Promise<ExecuteResult> {
const source = await getEntitySourceById(input.env.APP_DB, input.job.sourceId)
const callerContext = {
...input.callerContext,
repoContext: source
? {
sourceId: source.id,
repoId: source.repo_id,
sessionId: null,
sessionRepoId: null,
baseCommit: source.published_commit,
manifestPath: source.manifest_path,
sourceRoot: source.source_root,
publishedCommit: source.published_commit,
entityKind: source.entity_kind,
entityId: source.entity_id,
}
: null,
}
const packageContext = input.artifact.packageContext ?? null
const runtimeDebug = packageContext
? {
packageId: packageContext.packageId,
kodyId: packageContext.kodyId,
sourceId: packageContext.sourceId ?? input.job.sourceId,
publishedCommit: source?.published_commit ?? input.job.publishedCommit,
surface: 'job' as const,
name: input.job.name,
storageId: input.job.storageId,
jobId: input.job.id,
}
: null
const packageInvokeTools = packageContext
? await (async () => {
// Avoid a top-level jobs -> package-invocations cycle during capability
// registry initialization.
const { createPackageRuntimeInvokeTools } =
await import('#worker/package-invocations/service.ts')
return createPackageRuntimeInvokeTools({
env: input.env,
baseUrl: input.callerContext.baseUrl,
callerContext,
packageContext,
parentRuntimeDebug: runtimeDebug,
packageInvokeDepth: 0,
})
})()
: null
return await runBundledModuleWithRegistry(
input.env,
{
...input.callerContext,
repoContext: source
? {
sourceId: source.id,
repoId: source.repo_id,
sessionId: null,
sessionRepoId: null,
baseCommit: source.published_commit,
manifestPath: source.manifest_path,
sourceRoot: source.source_root,
publishedCommit: source.published_commit,
entityKind: source.entity_kind,
entityId: source.entity_id,
}
: null,
},
callerContext,
{
mainModule: input.artifact.mainModule,
modules: input.artifact.modules,
Expand All @@ -449,23 +479,13 @@ async function executePublishedJobArtifact(input: {
storageId: input.job.storageId,
writable: true,
},
...(input.artifact.packageContext
? { packageContext: input.artifact.packageContext }
: {}),
runtimeDebug: input.artifact.packageContext
...(packageContext ? { packageContext } : {}),
runtimeDebug,
...(packageContext && packageInvokeTools
? {
packageId: input.artifact.packageContext.packageId,
kodyId: input.artifact.packageContext.kodyId,
sourceId:
input.artifact.packageContext.sourceId ?? input.job.sourceId,
publishedCommit:
source?.published_commit ?? input.job.publishedCommit,
surface: 'job',
name: input.job.name,
storageId: input.job.storageId,
jobId: input.job.id,
packageInvokeTools,
}
: null,
: {}),
},
).then((result) => ({
...result,
Expand Down
80 changes: 80 additions & 0 deletions packages/worker/src/mcp/run-codemode-registry.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,86 @@ test('runBundledModuleWithRegistry injects workflow helper when custom workflow
}
})

test('runBundledModuleWithRegistry injects package invocation helper when provided', async () => {
const env = {} as Env
const callerContext = createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-123' },
})
const getRegistrySpy = vi
.spyOn(
await import('#mcp/capabilities/registry.ts'),
'getCapabilityRegistryForContext',
)
.mockResolvedValue({
capabilityDomains: [],
capabilityDomainDescriptionsByName: {} as Record<string, string>,
capabilityHandlers: {},
capabilityList: [],
capabilityMap: {},
capabilitySpecs: {},
capabilityToolDescriptors: {},
} as Awaited<ReturnType<typeof getCapabilityRegistryForContext>>)
let providerFns: Record<string, (args: unknown) => Promise<unknown>> | null =
null
const createExecuteExecutorSpy = vi
.spyOn(await import('#mcp/executor.ts'), 'createExecuteExecutor')
.mockReturnValue({
async execute(wrapped, providers) {
expect(wrapped).toContain('const packages = {')
expect(wrapped).toContain(
"packages: typeof packages === 'undefined' ? null : packages",
)
providerFns = (
providers[0] as {
fns: Record<string, (args: unknown) => Promise<unknown>>
}
).fns
return {
result: 'ok',
logs: [],
}
},
} as never)

try {
const result = await runBundledModuleWithRegistry(
env,
callerContext,
{
mainModule: 'entry.js',
modules: {
'entry.js': 'export default async () => "ok"',
},
},
undefined,
{
packageInvokeTools: {
invoke: async (input) => ({ ok: true, input }),
},
},
)

expect(result.result).toBe('ok')
expect(providerFns).not.toBeNull()
await expect(
providerFns?.package_invoke({
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()
}
})

test('runBundledModuleWithRegistry injects default workflow helper for execute and standalone job contexts', async () => {
const created: Array<WorkflowInstanceCreateOptions<unknown>> = []
const env = {
Expand Down
Loading
Loading