Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
51 changes: 51 additions & 0 deletions docs/contributing/packages-and-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Use `package.json` as the canonical source of truth for saved package metadata.
- `kody.tags` — search tags
- `kody.app` — optional hosted package app config
- `kody.jobs` — optional package-owned schedules
- `kody.workflows` — optional package-owned durable workflow declarations
- `kody.retrievers` — optional package-owned search/context retrievers

The package manifest is `package.json`.
Expand Down Expand Up @@ -57,6 +58,7 @@ Think in terms of:
- package exports
- package apps
- package-owned jobs
- package-owned workflows
- package-owned retrievers

The top-level saved identity is the package.
Expand Down Expand Up @@ -95,6 +97,55 @@ Jobs belong to packages.

Jobs are not their own top-level saved primitive.

## Package-owned workflows

Workflows belong to packages.

Use `package.json#kody.workflows` to name durable workflow entrypoints that
package code can schedule through `kody:runtime`:

```json
{
"kody": {
"workflows": {
"shade-event": {
"export": "./run-event",
"description": "Runs one planned shade event."
}
}
}
}
```

Runtime code calls `workflows.create(...)` with routing metadata and small
parameters:

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

await workflows.create({
workflowName: 'shade-event',
exportName: './run-event',
runAt: '2026-05-03T12:00:00.000Z',
idempotencyKey: 'shade-event:2026-05-03T12:00:00.000Z:office',
params: { eventId: 'event-123', roomId: 'office' },
})
```

Kody stores Cloudflare Workflow instance payloads as package-routing metadata:
`userId`, package id, `kody.id`, source id, workflow name, export name,
idempotency key, `runAt`/plan date, and small non-secret params. Do not place
secrets, OAuth tokens, full connector configuration, or full device action
payloads in workflow params or metadata. The package export should look up
current secrets/configuration from normal package runtime helpers when it runs.

Workflow instance ids are deterministic for
`(userId, packageId, workflowName, idempotencyKey)`, so repeated planners can
safely attempt to create the same instance without duplicating it. The host
workflow sleeps until `runAt`, then invokes the saved package export through the
same package execution path used by package invocations. Workflow instances are
not search results and are not saved as a new top-level Kody entity.

## Package-owned retrievers

Retrievers let packages return user-owned documents or facts to Kody search and
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AgentTurnRunner } from './agent-turn/runner-do.ts'
import { RepoSession } from './repo/repo-session-do.ts'
import { PackageRealtimeSession } from '#worker/package-runtime/realtime-session.ts'
import { PackageServiceInstance } from '#worker/package-runtime/package-service.ts'
import { PackageWorkflowEntrypoint } from '#worker/package-runtime/package-workflows.ts'
import { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts'
import { getWorkerSentryOptions } from './sentry-options.ts'
import { handleRequest } from '#app/handler.ts'
Expand Down Expand Up @@ -63,6 +64,7 @@ export {
JobManager,
PackageRealtimeSession,
PackageServiceInstance,
PackageWorkflowEntrypoint,
PackageAppRuntimeBridge,
StorageRunner,
}
Expand Down
194 changes: 194 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 @@ -4,6 +4,7 @@ import { createMcpCallerContext } from '#mcp/context.ts'
import { buildKodyModuleBundle } from '#worker/package-runtime/module-graph.ts'
import {
buildCodemodeFns,
createWorkflowTools,
runCodemodeWithRegistry,
runBundledModuleWithRegistry,
runModuleWithRegistry,
Expand All @@ -16,6 +17,125 @@ import {
createCapabilitySecretAccessDeniedMessage,
} from '#mcp/secrets/errors.ts'

test('createWorkflowTools creates package workflow instances from package context', async () => {
const created: Array<WorkflowInstanceCreateOptions<unknown>> = []
const workflowTools = createWorkflowTools({
env: {
PACKAGE_WORKFLOWS: {
get: async () => {
throw new Error('not found')
},
create: async (options?: WorkflowInstanceCreateOptions<unknown>) => {
if (!options) throw new Error('missing options')
created.push(options)
return {
id: options.id ?? 'generated',
status: async () => ({ status: 'queued' }),
} as WorkflowInstance
},
} as Workflow<unknown>,
} as Env,
callerContext: {
baseUrl: 'https://app.example.com',
user: {
userId: 'user-1',
email: 'me@example.com',
displayName: 'Me',
},
storageContext: null,
repoContext: null,
},
packageContext: {
packageId: 'pkg-1',
kodyId: 'shade-automation',
sourceId: 'source-1',
},
})

const result = await workflowTools?.create({
workflowName: 'shade-event',
exportName: './run-event',
runAt: '2026-05-03T12:00:00.000Z',
idempotencyKey: 'event-key',
params: { eventId: 'event-1' },
})

expect(result).toMatchObject({
ok: true,
workflow_name: 'shade-event',
export_name: './run-event',
run_at: '2026-05-03T12:00:00.000Z',
})
expect(created).toHaveLength(1)
expect(created[0]?.params).toEqual(
expect.objectContaining({
userId: 'user-1',
packageId: 'pkg-1',
kodyId: 'shade-automation',
sourceId: 'source-1',
workflowName: 'shade-event',
params: { eventId: 'event-1' },
}),
)
})

test('runModuleWithRegistry preserves caller-provided workflow tools', async () => {
const env = {} as Env
const callerContext = createMcpCallerContext({
baseUrl: 'https://app.example.com',
user: {
userId: 'user-1',
email: 'me@example.com',
displayName: 'Me',
},
storageContext: null,
})
const customWorkflowTools = {
create: vi.fn(async () => ({ ok: true, id: 'custom-workflow' })),
}
const createExecuteExecutorSpy = vi
.spyOn(await import('#mcp/executor.ts'), 'createExecuteExecutor')
.mockReturnValue({
async execute(wrapped) {
expect(wrapped).toContain(
'codemode.package_workflow_create(input ?? {})',
)
return {
result: 'ok',
logs: [],
}
},
} as never)

try {
await runModuleWithRegistry(
env,
callerContext,
`import { workflows } from 'kody:runtime'
export default async function run() {
await workflows.create({
workflowName: 'shade-event',
exportName: './run-event',
runAt: '2026-05-03T12:00:00.000Z',
idempotencyKey: 'event-key',
})
}`,
undefined,
{
packageContext: {
packageId: 'pkg-1',
kodyId: 'shade-automation',
sourceId: 'source-1',
},
workflowTools: customWorkflowTools,
},
)
expect(customWorkflowTools.create).not.toHaveBeenCalled()
} finally {
createExecuteExecutorSpy.mockRestore()
}
})

vi.mock('#worker/package-runtime/module-graph.ts', () => ({
buildKodyModuleBundle: vi.fn(async () => ({
mainModule: 'entry.js',
Expand Down Expand Up @@ -1160,6 +1280,80 @@ test('runBundledModuleWithRegistry injects email helpers', async () => {
}
})

test('runBundledModuleWithRegistry injects workflow helper when custom workflow tools are 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 workflows = {')
expect(wrapped).toContain(
'workflows: typeof workflows === \'undefined\' ? null : workflows',
)
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,
{
workflowTools: {
create: async (input) => ({ ok: true, input }),
},
},
)

expect(result.result).toBe('ok')
expect(providerFns).not.toBeNull()
await expect(
providerFns?.package_workflow_create({ workflowName: 'custom' }),
).resolves.toEqual({
ok: true,
input: { workflowName: 'custom' },
})
} finally {
createExecuteExecutorSpy.mockRestore()
getRegistrySpy.mockRestore()
}
})

test('runModuleWithRegistry injects email helpers for module syntax', async () => {
const env = {} as Env
const callerContext = createMcpCallerContext({
Expand Down
Loading
Loading