From 4b8c2a72398e7d22ca62609748e3f09656172a59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 22:37:23 +0000 Subject: [PATCH 01/39] Add package services runtime Co-authored-by: Kent C. Dodds --- .../architecture/request-lifecycle.md | 14 + docs/use/execute.md | 7 + docs/use/packages.md | 24 + packages/worker/src/env-schema.ts | 12 + packages/worker/src/index.ts | 2 + packages/worker/src/jobs/service.ts | 1 + .../src/mcp/capabilities/builtin-domains.ts | 2 + .../src/mcp/capabilities/domain-metadata.ts | 1 + .../src/mcp/capabilities/services/domain.ts | 19 + .../mcp/capabilities/services/service-get.ts | 39 ++ .../mcp/capabilities/services/service-list.ts | 56 +++ .../capabilities/services/service-start.ts | 46 ++ .../mcp/capabilities/services/service-stop.ts | 39 ++ .../services/services-domain.node.test.ts | 228 +++++++++ .../src/mcp/capabilities/services/shared.ts | 146 ++++++ .../worker/src/mcp/run-codemode-registry.ts | 4 + .../worker/src/mcp/server-instructions.ts | 3 +- packages/worker/src/mcp/tools/search.ts | 1 + .../package-registry/manifest.node.test.ts | 29 ++ .../worker/src/package-registry/manifest.ts | 36 ++ packages/worker/src/package-registry/types.ts | 12 + .../src/package-runtime/module-graph.ts | 4 + .../worker/src/package-runtime/package-app.ts | 58 +++ .../package-service.node.test.ts | 11 + .../src/package-runtime/package-service.ts | 450 ++++++++++++++++++ .../published-bundle-artifacts.ts | 26 +- .../published-runtime-artifacts.ts | 5 +- packages/worker/src/repo/checks.ts | 11 + packages/worker/worker-configuration.d.ts | 3 +- packages/worker/wrangler.jsonc | 8 + 30 files changed, 1293 insertions(+), 4 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/services/domain.ts create mode 100644 packages/worker/src/mcp/capabilities/services/service-get.ts create mode 100644 packages/worker/src/mcp/capabilities/services/service-list.ts create mode 100644 packages/worker/src/mcp/capabilities/services/service-start.ts create mode 100644 packages/worker/src/mcp/capabilities/services/service-stop.ts create mode 100644 packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/services/shared.ts create mode 100644 packages/worker/src/package-runtime/package-service.node.test.ts create mode 100644 packages/worker/src/package-runtime/package-service.ts diff --git a/docs/contributing/architecture/request-lifecycle.md b/docs/contributing/architecture/request-lifecycle.md index 5970d8fa9b..a96ab35f53 100644 --- a/docs/contributing/architecture/request-lifecycle.md +++ b/docs/contributing/architecture/request-lifecycle.md @@ -54,6 +54,20 @@ Requests are handled in this order: 9. App server routes: - Everything else is handled by `packages/worker/src/app/handler.ts` +## Package service runtime + +Saved packages may also declare long-lived package services under +`package.json#kody.services`. + +- Package services are **not** routed through a public HTTP path the way package + apps are. +- Instead, the Worker hosts them via the `PackageServiceInstance` Durable Object + binding and controls them through package runtime bridges and MCP + capabilities. +- Package services share package identity with apps/jobs and can publish into + package app realtime sessions, but they own their own durable storage bucket + and lifecycle. + ## App server flow `packages/worker/src/app/handler.ts` validates environment variables and diff --git a/docs/use/execute.md b/docs/use/execute.md index 30e180df24..0bb3c8277e 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -21,6 +21,9 @@ and export a default function. These helpers are runtime exports: active execute or job params instead of receiving them as a function argument - use **`import { packageContext } from 'kody:runtime'`** inside saved package code when you need package metadata; it is **`null`** for ad hoc execute calls +- use **`import { serviceContext } from 'kody:runtime'`** inside package service + code when you need the current service identity; it is **`null`** outside + package service runs - use **`import thing from 'kody:@scope/my-package/export-name'`** to reuse a saved package export by full package name @@ -52,6 +55,8 @@ module-oriented runtime model: - package-specific metadata lives under `package.json#kody` - package jobs are schedules declared under `package.json#kody.jobs` - package apps are optional UI surfaces declared under `package.json#kody.app` +- package services are optional long-lived runtimes declared under + `package.json#kody.services` - non-package jobs can also be scheduled directly with **`codemode.job_schedule(...)`** without creating a saved package - **`codemode.job_schedule_once(...)`** remains available as a convenience alias @@ -94,6 +99,8 @@ package-owned jobs and non-package jobs created with `job_schedule` or `job_schedule_once`. - bound storage is execute-, app-, package-, or job-owned durable state +- package service runs also get writable service-owned durable state scoped to + the declared service name - import **`storage`** from **`kody:runtime`** - use **`storage.get(...)`**, **`storage.set(...)`**, **`storage.list(...)`**, and **`storage.sql(query, params?)`** diff --git a/docs/use/packages.md b/docs/use/packages.md index 6e33757943..e2afae9a2c 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -13,6 +13,7 @@ Think in terms of: - packages - package exports - package apps +- package services - package-owned jobs Packages are the saved-entity unit across search, execute, repo editing, and UI @@ -30,6 +31,7 @@ Important fields: - `kody.description` — package description for search/detail - `kody.tags` — package tags - `kody.app` — optional hosted package app config +- `kody.services` — optional package-owned service runtimes - `kody.jobs` — optional package-owned schedules `package.json` is the manifest. @@ -70,6 +72,28 @@ Treat package apps like Worker-style modules: - internal Durable Objects or facets are implementation details, not the public authoring contract +## Package services + +A package service is optional. + +When `package.json#kody.services` is present, the package can declare one or +more named service entrypoints that Kody runs with package-owned storage and +package caller context. + +Use the package service model when the package needs: + +- long-lived or repeated background work +- package-owned daemon-like logic +- package state that is separate from browser sessions +- a service that should publish updates into a package app + +Treat package services like package-owned runtime modules: + +- service code lives in the package repo +- each service entry module is declared by `kody.services..entry` +- service lifecycle is controlled through the `services` capability domain +- services share the same saved package identity as package apps and jobs + ## Package-owned jobs Packages can own jobs, and Kody also supports schedules that are not owned diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts index d9f2b4bf64..eab2fcef11 100644 --- a/packages/worker/src/env-schema.ts +++ b/packages/worker/src/env-schema.ts @@ -208,6 +208,18 @@ export const EnvSchema = object({ context.path, ) }), + PACKAGE_SERVICE_INSTANCE: createSchema< + unknown, + DurableObjectNamespace + >((value, context) => { + if (value) { + return { value: value as DurableObjectNamespace } + } + return fail( + 'Missing PACKAGE_SERVICE_INSTANCE binding for package service runtimes.', + context.path, + ) + }), APP_BASE_URL: optionalUrlStringSchema, APP_COMMIT_SHA: optionalCommitShaSchema, CLOUDFLARE_EMAIL_FROM: optionalNonEmptyStringSchema, diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 672ab6a37d..bd27a3e638 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -9,6 +9,7 @@ import { StorageRunner } from './storage-runner.ts' 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 { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts' import { getWorkerSentryOptions } from './sentry-options.ts' import { handleRequest } from '#app/handler.ts' @@ -54,6 +55,7 @@ export { MCP, JobManager, PackageRealtimeSession, + PackageServiceInstance, PackageAppRuntimeBridge, StorageRunner, } diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 55ee9c2a23..fd63c1efc3 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -185,6 +185,7 @@ async function persistPublishedJobBundleArtifact(input: { modules: bundle.modules, dependencies: bundle.dependencies, packageContext: input.packageContext ?? null, + serviceContext: null, createdAt: new Date().toISOString(), } await persistPublishedBundleArtifact({ diff --git a/packages/worker/src/mcp/capabilities/builtin-domains.ts b/packages/worker/src/mcp/capabilities/builtin-domains.ts index f659504ce8..8382c65090 100644 --- a/packages/worker/src/mcp/capabilities/builtin-domains.ts +++ b/packages/worker/src/mcp/capabilities/builtin-domains.ts @@ -5,6 +5,7 @@ import { metaDomain } from './meta/domain.ts' import { packagesDomain } from './packages/domain.ts' import { repoDomain } from './repo/domain.ts' import { secretsDomain } from './secrets/domain.ts' +import { servicesDomain } from './services/domain.ts' import { storageDomain } from './storage/domain.ts' import { valuesDomain } from './values/domain.ts' @@ -24,6 +25,7 @@ export const builtinDomains = [ packagesDomain, repoDomain, secretsDomain, + servicesDomain, storageDomain, valuesDomain, ] as const diff --git a/packages/worker/src/mcp/capabilities/domain-metadata.ts b/packages/worker/src/mcp/capabilities/domain-metadata.ts index 4b7d68cad7..f9b7d4c2cb 100644 --- a/packages/worker/src/mcp/capabilities/domain-metadata.ts +++ b/packages/worker/src/mcp/capabilities/domain-metadata.ts @@ -9,6 +9,7 @@ export const capabilityDomainNames = { packages: 'packages', repo: 'repo', secrets: 'secrets', + services: 'services', storage: 'storage', values: 'values', } as const diff --git a/packages/worker/src/mcp/capabilities/services/domain.ts b/packages/worker/src/mcp/capabilities/services/domain.ts new file mode 100644 index 0000000000..c4769d1a26 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/domain.ts @@ -0,0 +1,19 @@ +import { defineDomain } from '../define-domain.ts' +import { capabilityDomainNames } from '../domain-metadata.ts' +import { serviceGetCapability } from './service-get.ts' +import { serviceListCapability } from './service-list.ts' +import { serviceStartCapability } from './service-start.ts' +import { serviceStopCapability } from './service-stop.ts' + +export const servicesDomain = defineDomain({ + name: capabilityDomainNames.services, + description: + 'Package service capabilities for inspecting and controlling long-lived package-owned runtimes.', + keywords: ['service', 'package service', 'runtime', 'background', 'gateway'], + capabilities: [ + serviceListCapability, + serviceGetCapability, + serviceStartCapability, + serviceStopCapability, + ], +}) diff --git a/packages/worker/src/mcp/capabilities/services/service-get.ts b/packages/worker/src/mcp/capabilities/services/service-get.ts new file mode 100644 index 0000000000..b637888a4a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/service-get.ts @@ -0,0 +1,39 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { z } from 'zod' +import { + packageServiceStatusSchema, + requirePackageServiceContext, +} from './shared.ts' + +export const serviceGetCapability = defineDomainCapability( + capabilityDomainNames.services, + { + name: 'service_get', + description: + 'Get status for one package service declared under package.json#kody.services.', + keywords: ['service', 'status', 'package', 'inspect'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: z.object({ + service_name: z.string().min(1), + package_id: z.string().min(1).optional(), + }), + outputSchema: packageServiceStatusSchema, + async handler(args, ctx) { + const serviceContext = await requirePackageServiceContext({ + env: ctx.env, + callerContext: ctx.callerContext, + serviceName: args.service_name, + explicitPackageId: args.package_id, + }) + if (!serviceContext.service) { + throw new Error( + `Package service "${args.service_name}" was not found.`, + ) + } + return await serviceContext.service.status() + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/services/service-list.ts b/packages/worker/src/mcp/capabilities/services/service-list.ts new file mode 100644 index 0000000000..879ecb169c --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/service-list.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + listPackageServicesForContext, + packageServiceSummarySchema, + normalizePackageServiceStatus, +} from './shared.ts' + +const inputSchema = z.object({ + package_id: z.string().min(1).optional(), +}) + +const outputSchema = z.object({ + package_id: z.string(), + kody_id: z.string(), + services: z.array(packageServiceSummarySchema), +}) + +export const serviceListCapability = defineDomainCapability( + capabilityDomainNames.services, + { + name: 'service_list', + description: + 'List package services declared by a saved package, including their current runtime status.', + keywords: ['service', 'services', 'package', 'list', 'status'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx) { + const listed = await listPackageServicesForContext({ + env: ctx.env, + callerContext: ctx.callerContext, + explicitPackageId: args.package_id, + }) + const services = await Promise.all( + listed.services.map(async (service) => { + const status = normalizePackageServiceStatus(await listed.rpc(service.name).status()) + return { + name: service.name, + entry: service.entry, + auto_start: service.auto_start, + status: status.status, + } + }), + ) + return { + package_id: listed.savedPackage.id, + kody_id: listed.savedPackage.kodyId, + services, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/services/service-start.ts b/packages/worker/src/mcp/capabilities/services/service-start.ts new file mode 100644 index 0000000000..62e51011e0 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/service-start.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requirePackageServiceContext } from './shared.ts' + +const inputSchema = z.object({ + service_name: z.string().min(1), + package_id: z.string().min(1).optional(), +}) + +const outputSchema = z.object({ + ok: z.boolean(), + result: z.unknown().optional(), + error: z.string().optional(), + started_at: z.string().optional(), + finished_at: z.string().optional(), +}) + +export const serviceStartCapability = defineDomainCapability( + capabilityDomainNames.services, + { + name: 'service_start', + description: + 'Start a package service instance and return the latest execution result.', + keywords: ['service', 'start', 'package service', 'runtime'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx) { + const serviceContext = await requirePackageServiceContext({ + env: ctx.env, + callerContext: ctx.callerContext, + serviceName: args.service_name, + explicitPackageId: args.package_id, + }) + if (!serviceContext.service) { + throw new Error( + `Package service "${args.service_name}" was not found for this package.`, + ) + } + return await serviceContext.service.start() + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/services/service-stop.ts b/packages/worker/src/mcp/capabilities/services/service-stop.ts new file mode 100644 index 0000000000..f90bb096c6 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/service-stop.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requirePackageServiceContext } from './shared.ts' + +const inputSchema = z.object({ + service_name: z.string().min(1), + package_id: z.string().min(1).optional(), +}) + +const outputSchema = z.object({ + ok: z.boolean(), +}) + +export const serviceStopCapability = defineDomainCapability( + capabilityDomainNames.services, + { + name: 'service_stop', + description: 'Stop one declared package service instance.', + keywords: ['service', 'stop', 'package'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx) { + const serviceContext = await requirePackageServiceContext({ + env: ctx.env, + callerContext: ctx.callerContext, + serviceName: args.service_name, + explicitPackageId: args.package_id, + }) + const result = (await serviceContext.service?.stop()) as { ok?: unknown } + return { + ok: result?.ok === true, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts new file mode 100644 index 0000000000..5c2e6e3594 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -0,0 +1,228 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockModule = vi.hoisted(() => ({ + listSavedPackageServices: vi.fn(), + packageServiceRpc: vi.fn(), + getSavedPackageById: vi.fn(), +})) + +vi.mock('#worker/package-runtime/package-service.ts', () => ({ + listSavedPackageServices: (...args: Array) => + mockModule.listSavedPackageServices(...args), + packageServiceRpc: (...args: Array) => + mockModule.packageServiceRpc(...args), +})) + +vi.mock('#worker/package-registry/repo.ts', () => ({ + getSavedPackageById: (...args: Array) => + mockModule.getSavedPackageById(...args), +})) + +const { servicesDomain } = await import('./domain.ts') +const { serviceListCapability } = await import('./service-list.ts') +const { serviceGetCapability } = await import('./service-get.ts') +const { serviceStartCapability } = await import('./service-start.ts') +const { serviceStopCapability } = await import('./service-stop.ts') + +function resetMocks() { + mockModule.listSavedPackageServices.mockReset() + mockModule.packageServiceRpc.mockReset() + mockModule.getSavedPackageById.mockReset() +} + +function createCallerContext() { + return createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-123', + email: 'user@example.com', + displayName: 'User Example', + }, + storageContext: { + sessionId: null, + appId: 'package-123', + storageId: 'package-123', + }, + }) +} + +test('services domain exposes package service lifecycle capabilities', () => { + expect(servicesDomain.capabilities.map((capability) => capability.name)).toEqual( + expect.arrayContaining([ + 'service_list', + 'service_get', + 'service_start', + 'service_stop', + ]), + ) +}) + +test('service_list returns declared package services with live status', async () => { + resetMocks() + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-123', + userId: 'user-123', + name: '@scope/example', + kodyId: 'example', + description: 'Example package', + tags: [], + searchText: null, + sourceId: 'source-123', + hasApp: true, + createdAt: '2026-04-24T00:00:00.000Z', + updatedAt: '2026-04-24T00:00:00.000Z', + }) + mockModule.listSavedPackageServices.mockResolvedValue({ + savedPackage: { + id: 'package-123', + kodyId: 'example', + }, + services: [ + { + name: 'discord-gateway', + entry: 'services/discord-gateway.ts', + autoStart: true, + }, + ], + rpc: () => ({ + status: async () => ({ + package_id: 'package-123', + kody_id: 'example', + service_name: 'discord-gateway', + status: 'stopped', + auto_start: true, + last_error: null, + last_started_at: null, + last_stopped_at: null, + last_run_finished_at: null, + last_result: null, + }), + }), + }) + mockModule.packageServiceRpc.mockImplementation(() => ({ + status: async () => ({ + package_id: 'package-123', + kody_id: 'example', + service_name: 'discord-gateway', + status: 'stopped', + auto_start: true, + last_error: null, + last_started_at: null, + last_stopped_at: null, + last_run_finished_at: null, + last_result: null, + }), + })) + const result = await serviceListCapability.handler( + {}, + { + env: { + APP_DB: {} as D1Database, + } as Env, + callerContext: createCallerContext(), + }, + ) + + expect(result).toEqual({ + package_id: 'package-123', + kody_id: 'example', + services: [ + { + name: 'discord-gateway', + entry: 'services/discord-gateway.ts', + auto_start: true, + status: 'stopped', + }, + ], + }) +}) + +test('service_get, service_start, and service_stop delegate to package service RPC', async () => { + resetMocks() + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-123', + userId: 'user-123', + name: '@scope/example', + kodyId: 'example', + description: 'Example package', + tags: [], + searchText: null, + sourceId: 'source-123', + hasApp: true, + createdAt: '2026-04-24T00:00:00.000Z', + updatedAt: '2026-04-24T00:00:00.000Z', + }) + mockModule.packageServiceRpc.mockImplementation(() => ({ + status: async () => ({ + package_id: 'package-123', + kody_id: 'example', + service_name: 'discord-gateway', + status: 'idle', + auto_start: false, + last_error: null, + last_started_at: null, + last_stopped_at: null, + last_run_finished_at: null, + last_result: null, + }), + start: async () => ({ + ok: true, + started_at: '2026-04-24T00:00:00.000Z', + finished_at: '2026-04-24T00:00:01.000Z', + }), + stop: async () => ({ + ok: true, + }), + })) + + const env = { + APP_DB: {} as D1Database, + } as Env + const callerContext = createCallerContext() + + await expect( + serviceGetCapability.handler( + { + service_name: 'discord-gateway', + }, + { + env, + callerContext, + }, + ), + ).resolves.toMatchObject({ + service_name: 'discord-gateway', + status: 'idle', + }) + + await expect( + serviceStartCapability.handler( + { + service_name: 'discord-gateway', + }, + { + env, + callerContext, + }, + ), + ).resolves.toEqual({ + ok: true, + started_at: '2026-04-24T00:00:00.000Z', + finished_at: '2026-04-24T00:00:01.000Z', + }) + + await expect( + serviceStopCapability.handler( + { + service_name: 'discord-gateway', + }, + { + env, + callerContext, + }, + ), + ).resolves.toEqual({ + ok: true, + }) +}) diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts new file mode 100644 index 0000000000..33cbe41742 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -0,0 +1,146 @@ +import { type McpCallerContext } from '@kody-internal/shared/chat.ts' +import { z } from 'zod' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { getSavedPackageById } from '#worker/package-registry/repo.ts' +import { + listSavedPackageServices, + packageServiceRpc, + type PackageServiceBindingState, +} from '#worker/package-runtime/package-service.ts' + +export const packageServiceRecordSchema = z.object({ + name: z.string(), + entry: z.string(), + auto_start: z.boolean(), +}) + +export const packageServiceSummarySchema = packageServiceRecordSchema.extend({ + status: z.enum(['idle', 'running', 'stopped', 'error']), +}) + +export const packageServiceStatusSchema = z.object({ + package_id: z.string(), + kody_id: z.string(), + service_name: z.string(), + status: z.enum(['idle', 'running', 'stopped', 'error']), + auto_start: z.boolean(), + last_error: z.string().nullable(), + last_started_at: z.string().nullable(), + last_stopped_at: z.string().nullable(), + last_run_finished_at: z.string().nullable(), + last_result: z.unknown(), +}) + +function resolvePackageId( + callerContext: McpCallerContext, + explicitPackageId?: string | null, +) { + const normalizedExplicit = + typeof explicitPackageId === 'string' ? explicitPackageId.trim() : '' + if (normalizedExplicit) { + return normalizedExplicit + } + const appId = callerContext.storageContext?.appId?.trim() || '' + if (appId) { + return appId + } + throw new Error( + 'Package service APIs require package app or package job caller context.', + ) +} + +export async function requirePackageServiceContext(input: { + env: Env + callerContext: McpCallerContext + explicitPackageId?: string | null + serviceName?: string | null +}) { + const user = requireMcpUser(input.callerContext) + const packageId = resolvePackageId( + input.callerContext, + input.explicitPackageId, + ) + const savedPackage = await getSavedPackageById(input.env.APP_DB, { + userId: user.userId, + packageId, + }) + if (!savedPackage) { + throw new Error('Saved package was not found for package service operations.') + } + const serviceName = input.serviceName?.trim() || '' + return { + user, + savedPackage, + serviceName, + service: serviceName + ? packageServiceRpc({ + env: input.env, + userId: user.userId, + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + sourceId: savedPackage.sourceId, + baseUrl: input.callerContext.baseUrl, + serviceName, + }) + : null, + } +} + +export async function listPackageServicesForContext(input: { + env: Env + callerContext: McpCallerContext + explicitPackageId?: string | null +}) { + const context = await requirePackageServiceContext({ + env: input.env, + callerContext: input.callerContext, + explicitPackageId: input.explicitPackageId, + }) + const listed = await listSavedPackageServices({ + env: input.env, + userId: context.user.userId, + baseUrl: input.callerContext.baseUrl, + packageId: context.savedPackage.id, + }) + return { + savedPackage: context.savedPackage, + rpc(serviceName: string) { + return packageServiceRpc({ + env: input.env, + userId: context.user.userId, + packageId: context.savedPackage.id, + kodyId: context.savedPackage.kodyId, + sourceId: context.savedPackage.sourceId, + baseUrl: input.callerContext.baseUrl, + serviceName, + }) + }, + services: listed.services.map((service) => ({ + name: service.name, + entry: service.entry, + auto_start: service.autoStart, + })), + } +} + +export type PackageServiceStatusRecord = z.infer< + typeof packageServiceStatusSchema +> + +export const serviceStatusOutputSchema = packageServiceStatusSchema + +export function normalizePackageServiceStatus( + input: unknown, +): PackageServiceStatusRecord { + const result = packageServiceStatusSchema.safeParse(input) + if (!result.success) { + throw new Error(z.prettifyError(result.error)) + } + return result.data +} + +export function buildPackageServiceBinding(input: { + binding: PackageServiceBindingState +}) { + return input.binding +} diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index fd65d0ecb9..767b86baf1 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -320,6 +320,9 @@ export async function runBundledModuleWithRegistry( packageId: string kodyId: string } | null + serviceContext?: { + serviceName: string + } | null }, ): Promise { const { createExecuteExecutor } = await import('#mcp/executor.ts') @@ -362,6 +365,7 @@ ${storageHelperPrelude ? `${storageHelperPrelude}\n` : ''} createAuthenticatedFetch, agentChatTurnStream, packageContext: ${JSON.stringify(options?.packageContext ?? null)}, + serviceContext: ${JSON.stringify(options?.serviceContext ?? null)}, }; try { const __kodyModule = await import(${JSON.stringify(`./${bundle.mainModule}`)}); diff --git a/packages/worker/src/mcp/server-instructions.ts b/packages/worker/src/mcp/server-instructions.ts index 25c686ab67..16f847bb4b 100644 --- a/packages/worker/src/mcp/server-instructions.ts +++ b/packages/worker/src/mcp/server-instructions.ts @@ -27,8 +27,9 @@ Conventions - \`job_schedule\`: schedule a repo-backed job for the signed-in user without creating a saved package first. Supports one-off, interval, and cron schedules. - \`job_schedule_once\`: compatibility wrapper for one-off repo-backed jobs when you only need a single run time. - \`job_run_now\`: run an existing scheduled job immediately by id and return the updated job view plus execution result for debugging. -- Package jobs are schedules owned by a package. For ad hoc work that is not tied to a package, use \`job_schedule\`. Package apps are optional UI surfaces declared by the package, not a separate top-level primitive. +- Package jobs are schedules owned by a package. For ad hoc work that is not tied to a package, use \`job_schedule\`. Package apps are optional UI surfaces declared by the package, and packages may also declare headless package services for long-lived background runtimes. - Package apps can also use Kody-managed realtime websocket sessions through \`session_list\`, \`session_emit\`, and \`session_broadcast\`, and package jobs can emit to those sessions when running under the same package caller context. +- Package services are inspected and controlled through \`service_list\`, \`service_get\`, \`service_start\`, and \`service_stop\`. - Memory writes are verify-first: always run \`meta_memory_verify\` before \`meta_memory_upsert\` or \`meta_memory_delete\`. Kody retrieves related memories; the consuming agent decides whether to upsert, delete, both, or do nothing. \`meta_memory_upsert\` creates a new memory when \`memory_id\` is omitted and updates an existing memory when \`memory_id\` is provided. - User-specific MCP instructions: \`meta_get_mcp_server_instructions\` / \`meta_set_mcp_server_instructions\` (signed-in users). Updates apply to **new** MCP sessions (reconnect to refresh what the host shows). diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 6fd5dd1d8b..bb5310f2bf 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -170,6 +170,7 @@ function buildFallbackPackageSearchProjection( appEntry: null, exports: [], jobs: [], + services: [], } } diff --git a/packages/worker/src/package-registry/manifest.node.test.ts b/packages/worker/src/package-registry/manifest.node.test.ts index 422afaf9aa..2428ea066c 100644 --- a/packages/worker/src/package-registry/manifest.node.test.ts +++ b/packages/worker/src/package-registry/manifest.node.test.ts @@ -59,3 +59,32 @@ test('parseAuthoredPackageJson rejects unscoped package names', () => { 'package.json name "cursor-cloud-agents" must be a scoped package name like "@scope/cursor-cloud-agents".', ) }) + +test('parseAuthoredPackageJson accepts package service definitions', () => { + const manifest = parseAuthoredPackageJson({ + content: JSON.stringify({ + name: '@kentcdodds/discord-gateway', + exports: { + '.': './index.ts', + }, + kody: { + id: 'discord-gateway', + description: 'Discord gateway package', + services: { + 'discord-gateway': { + entry: './services/discord-gateway.ts', + autoStart: true, + }, + }, + }, + }), + manifestPath: 'package.json', + }) + + expect(manifest.kody.services).toEqual({ + 'discord-gateway': { + entry: './services/discord-gateway.ts', + autoStart: true, + }, + }) +}) diff --git a/packages/worker/src/package-registry/manifest.ts b/packages/worker/src/package-registry/manifest.ts index 6c76f9e889..de564a6a19 100644 --- a/packages/worker/src/package-registry/manifest.ts +++ b/packages/worker/src/package-registry/manifest.ts @@ -117,6 +117,26 @@ export function getPackageAppEntryPath(manifest: AuthoredPackageJson) { return normalizePackageWorkspacePath(appEntry) } +export function getPackageServiceEntryPath(input: { + manifest: AuthoredPackageJson + serviceName: string +}) { + const serviceEntry = + input.manifest.kody.services?.[input.serviceName]?.entry?.trim() + if (!serviceEntry) return null + return normalizePackageWorkspacePath(serviceEntry) +} + +export function listPackageServices(manifest: AuthoredPackageJson) { + return Object.entries(manifest.kody.services ?? {}) + .map(([name, service]) => ({ + name, + entry: normalizePackageWorkspacePath(service.entry), + autoStart: service.autoStart ?? false, + })) + .sort((left, right) => left.name.localeCompare(right.name)) +} + export function getPackageTags(manifest: AuthoredPackageJson) { return [...(manifest.kody.tags ?? [])] } @@ -136,6 +156,11 @@ export type PackageSearchProjection = { schedule: string enabled: boolean }> + services: Array<{ + name: string + entry: string + autoStart: boolean + }> } export function buildPackageSearchProjection( @@ -164,6 +189,7 @@ export function buildPackageSearchProjection( enabled: job.enabled ?? true, })) .sort((left, right) => left.name.localeCompare(right.name)), + services: listPackageServices(manifest), } } @@ -173,6 +199,15 @@ export function buildPackageSearchDocument(projection: PackageSearchProjection) .filter((value) => value.length > 0) .join(' '), ) + const serviceLines = projection.services.map((service) => + [ + service.name, + service.entry, + service.autoStart ? 'auto-start' : 'manual-start', + ] + .filter((value) => value.length > 0) + .join(' '), + ) return [ `package ${projection.kodyId}`, projection.name, @@ -181,6 +216,7 @@ export function buildPackageSearchDocument(projection: PackageSearchProjection) projection.searchText ?? '', projection.exports.join('\n'), jobLines.join('\n'), + serviceLines.join('\n'), projection.appEntry ? `app ${projection.appEntry}` : projection.hasApp diff --git a/packages/worker/src/package-registry/types.ts b/packages/worker/src/package-registry/types.ts index f24a15885c..bba4080b3b 100644 --- a/packages/worker/src/package-registry/types.ts +++ b/packages/worker/src/package-registry/types.ts @@ -35,6 +35,15 @@ export const packageAppDefinitionSchema = z.object({ export type PackageAppDefinition = z.infer +export const packageServiceDefinitionSchema = z.object({ + entry: z.string().min(1), + autoStart: z.boolean().optional(), +}) + +export type PackageServiceDefinition = z.infer< + typeof packageServiceDefinitionSchema +> + const packageExportConditionSchema = z .object({ import: z.string().min(1).optional(), @@ -65,6 +74,9 @@ export const authoredPackageKodySchema = z.object({ tags: z.array(z.string().min(1)).optional(), searchText: z.string().min(1).optional(), app: packageAppDefinitionSchema.optional(), + services: z + .record(z.string().min(1), packageServiceDefinitionSchema) + .optional(), jobs: z.record(z.string().min(1), packageJobDefinitionSchema).optional(), }) diff --git a/packages/worker/src/package-runtime/module-graph.ts b/packages/worker/src/package-runtime/module-graph.ts index 8de0798b1b..f915534586 100644 --- a/packages/worker/src/package-runtime/module-graph.ts +++ b/packages/worker/src/package-runtime/module-graph.ts @@ -518,6 +518,9 @@ export function createPublishedBundleArtifact(input: { packageId: string kodyId: string } | null + serviceContext?: { + serviceName: string + } | null }): PublishedBundleArtifact { return { version: 1, @@ -530,6 +533,7 @@ export function createPublishedBundleArtifact(input: { modules: input.modules, dependencies: input.dependencies, packageContext: input.packageContext ?? null, + serviceContext: input.serviceContext ?? null, createdAt: new Date().toISOString(), } } diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index b43f5d1c28..87520649ce 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -21,6 +21,7 @@ import { } from './published-runtime-artifacts.ts' import { storageRunnerRpc } from '#worker/storage-runner.ts' import { packageRealtimeSessionRpc } from './realtime-session.ts' +import { listSavedPackageServices, packageServiceRpc } from './package-service.ts' const packageAppEntrypointName = 'PackageAppWorker' const packageAppRuntimeBindingName = 'KODY_RUNTIME' @@ -183,6 +184,24 @@ function createRealtimeProxy(runtimeBridge) { }; } +function createServicesProxy(runtimeBridge) { + return { + list: async () => await runtimeBridge.serviceList(), + get: async (serviceName) => + await runtimeBridge.serviceGet({ + serviceName, + }), + start: async (serviceName) => + await runtimeBridge.serviceStart({ + serviceName, + }), + stop: async (serviceName) => + await runtimeBridge.serviceStop({ + serviceName, + }), + }; +} + function createAuthenticatedFetchHelper(runtimeBridge) { return async function createAuthenticatedFetch(providerName) { return async (input, init) => @@ -316,6 +335,7 @@ function createRuntime(runtimeBridge, params, packageContext) { createAuthenticatedFetch: createAuthenticatedFetchHelper(runtimeBridge), agentChatTurnStream: createAgentChatTurnStream(runtimeBridge), realtime: createRealtimeProxy(runtimeBridge), + services: createServicesProxy(runtimeBridge), packageContext, }; } @@ -472,6 +492,18 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< }) } + private getPackageServiceRpc(serviceName: string) { + return packageServiceRpc({ + env: this.env, + userId: this.ctx.props.userId, + packageId: this.ctx.props.packageId, + kodyId: this.ctx.props.kodyId, + sourceId: this.ctx.props.sourceId, + baseUrl: this.ctx.props.baseUrl, + serviceName, + }) + } + async callCapability(input: { name: string; args?: unknown }) { const name = input.name.trim() switch (name) { @@ -668,6 +700,32 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< reason: input.reason ?? undefined, }) } + + async serviceList() { + const result = await listSavedPackageServices({ + env: this.env, + userId: this.ctx.props.userId, + baseUrl: this.ctx.props.baseUrl, + packageId: this.ctx.props.packageId, + }) + return { + package_id: result.savedPackage.id, + kody_id: result.savedPackage.kodyId, + services: result.services, + } + } + + async serviceGet(input: { serviceName: string }) { + return await this.getPackageServiceRpc(input.serviceName).status() + } + + async serviceStart(input: { serviceName: string }) { + return await this.getPackageServiceRpc(input.serviceName).start() + } + + async serviceStop(input: { serviceName: string }) { + return await this.getPackageServiceRpc(input.serviceName).stop() + } } export async function buildPackageAppWorker(input: { diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts new file mode 100644 index 0000000000..337e8ca51e --- /dev/null +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest' +import { buildPackageServiceStorageId } from './package-service.ts' + +test('buildPackageServiceStorageId creates stable per-service storage ids', () => { + expect(buildPackageServiceStorageId('package-1', 'discord-gateway')).toBe( + 'service:package-1:discord-gateway', + ) + expect(buildPackageServiceStorageId('package-1', 'guild sync')).toBe( + 'service:package-1:guild%20sync', + ) +}) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts new file mode 100644 index 0000000000..e15843e290 --- /dev/null +++ b/packages/worker/src/package-runtime/package-service.ts @@ -0,0 +1,450 @@ +import * as Sentry from '@sentry/cloudflare' +import { DurableObject } from 'cloudflare:workers' +import { createMcpCallerContext } from '#mcp/context.ts' +import { + getPackageServiceEntryPath, + listPackageServices, +} from '#worker/package-registry/manifest.ts' +import { getSavedPackageById } from '#worker/package-registry/repo.ts' +import { loadPackageSourceBySourceId } from '#worker/package-registry/source.ts' +import { buildSentryOptions } from '#worker/sentry-options.ts' + +const serviceStateStorageKey = 'package-service-state' + +export type PackageServiceBindingState = { + userId: string + packageId: string + kodyId: string + sourceId: string + baseUrl: string + serviceName: string +} + +type PackageServiceState = { + binding: PackageServiceBindingState | null + lastStartedAt: string | null + lastStoppedAt: string | null + status: 'idle' | 'running' | 'stopped' | 'error' + lastError: string | null + lastResult: unknown + lastRunFinishedAt: string | null +} + +type PackageServiceRunResult = { + ok: boolean + result?: unknown + error?: string + started_at: string + finished_at: string +} + +function createInitialPackageServiceState(): PackageServiceState { + return { + binding: null, + lastStartedAt: null, + lastStoppedAt: null, + status: 'idle', + lastError: null, + lastResult: null, + lastRunFinishedAt: null, + } +} + +function buildPackageServiceName(input: { + userId: string + packageId: string + serviceName: string +}) { + return JSON.stringify([input.userId, input.packageId, input.serviceName]) +} + +function getPackageServiceNamespace(env: Env) { + return env.PACKAGE_SERVICE_INSTANCE +} + +function getPackageServiceStub(input: { + env: Env + userId: string + packageId: string + serviceName: string +}) { + const namespace = getPackageServiceNamespace(input.env) + if (!namespace) { + throw new Error('Missing PACKAGE_SERVICE_INSTANCE binding.') + } + const id = namespace.idFromName( + buildPackageServiceName({ + userId: input.userId, + packageId: input.packageId, + serviceName: input.serviceName, + }), + ) + return namespace.get(id) +} + +export function buildPackageServiceStorageId( + packageId: string, + serviceName: string, +) { + return `service:${packageId}:${encodeURIComponent(serviceName)}` +} + +async function loadSavedPackageService(input: { + env: Env + binding: PackageServiceBindingState +}) { + const savedPackage = await getSavedPackageById(input.env.APP_DB, { + userId: input.binding.userId, + packageId: input.binding.packageId, + }) + if (!savedPackage) { + throw new Error('Saved package was not found for package service operations.') + } + const packageSource = await loadPackageSourceBySourceId({ + env: input.env, + baseUrl: input.binding.baseUrl, + userId: input.binding.userId, + sourceId: input.binding.sourceId, + }) + const serviceEntry = getPackageServiceEntryPath({ + manifest: packageSource.manifest, + serviceName: input.binding.serviceName, + }) + if (!serviceEntry) { + throw new Error( + `Saved package "${packageSource.manifest.kody.id}" does not define service "${input.binding.serviceName}".`, + ) + } + return { + savedPackage, + packageSource, + serviceEntry, + } +} + +class PackageServiceInstanceBase extends DurableObject { + private stateSnapshot: PackageServiceState = createInitialPackageServiceState() + + constructor(state: DurableObjectState, env: Env) { + super(state, env) + this.ctx.blockConcurrencyWhile(async () => { + await this.restoreState() + }) + } + + private async restoreState() { + const stored = + await this.ctx.storage.get(serviceStateStorageKey) + if (!stored) return + this.stateSnapshot = stored + } + + private async persistState() { + await this.ctx.storage.put(serviceStateStorageKey, this.stateSnapshot) + } + + private async initializeBinding(binding: PackageServiceBindingState) { + const existing = this.stateSnapshot.binding + if ( + existing && + (existing.userId !== binding.userId || + existing.packageId !== binding.packageId || + existing.serviceName !== binding.serviceName) + ) { + throw new Error('Package service instance binding mismatch.') + } + if (!existing) { + this.stateSnapshot.binding = binding + await this.persistState() + } + } + + private async runService(binding: PackageServiceBindingState) { + const [{ runBundledModuleWithRegistry }, { buildKodyModuleBundle }, { loadPublishedBundleArtifactByIdentity }] = + await Promise.all([ + import('#mcp/run-codemode-registry.ts'), + import('./module-graph.ts'), + import('./published-bundle-artifacts.ts'), + ]) + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + const artifact = await loadPublishedBundleArtifactByIdentity({ + env: this.env, + userId: binding.userId, + sourceId: binding.sourceId, + kind: 'service', + artifactName: binding.serviceName, + entryPoint: loaded.serviceEntry, + }) + const bundle = + artifact?.artifact ?? + (await buildKodyModuleBundle({ + env: this.env, + baseUrl: binding.baseUrl, + userId: binding.userId, + sourceFiles: loaded.packageSource.files, + entryPoint: loaded.serviceEntry, + })) + const callerContext = createMcpCallerContext({ + baseUrl: binding.baseUrl, + user: { + userId: binding.userId, + email: '', + displayName: `package:${binding.packageId}`, + }, + storageContext: { + sessionId: null, + appId: binding.packageId, + storageId: buildPackageServiceStorageId( + binding.packageId, + binding.serviceName, + ), + }, + }) + const result = await runBundledModuleWithRegistry( + this.env, + callerContext, + { + mainModule: bundle.mainModule, + modules: bundle.modules, + }, + undefined, + { + packageContext: { + packageId: loaded.savedPackage.id, + kodyId: loaded.savedPackage.kodyId, + }, + serviceContext: { + serviceName: binding.serviceName, + }, + storageTools: { + userId: binding.userId, + storageId: buildPackageServiceStorageId( + binding.packageId, + binding.serviceName, + ), + writable: true, + }, + }, + ) + if (result.error) { + const rawError: unknown = result.error + const errorMessage = + typeof rawError === 'string' + ? rawError + : typeof rawError === 'object' && + rawError !== null && + 'message' in rawError && + typeof rawError.message === 'string' + ? rawError.message + : String(rawError) + throw new Error(errorMessage) + } + return result.result ?? null + } + + private async handleStartRequest(input: { + binding: PackageServiceBindingState + }) { + await this.initializeBinding(input.binding) + const startedAt = new Date().toISOString() + this.stateSnapshot.status = 'running' + this.stateSnapshot.lastStartedAt = startedAt + this.stateSnapshot.lastError = null + await this.persistState() + try { + const result = await this.runService(input.binding) + this.stateSnapshot.status = 'stopped' + this.stateSnapshot.lastResult = result + this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() + this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt + await this.ctx.storage.deleteAlarm().catch(() => { + // Best effort cleanup. + }) + await this.persistState() + return Response.json({ + ok: true, + result, + started_at: startedAt, + finished_at: this.stateSnapshot.lastRunFinishedAt, + } satisfies PackageServiceRunResult) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + this.stateSnapshot.status = 'error' + this.stateSnapshot.lastError = errorMessage + this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() + this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt + await this.persistState() + return Response.json( + { + ok: false, + error: errorMessage, + started_at: startedAt, + finished_at: this.stateSnapshot.lastRunFinishedAt, + } satisfies PackageServiceRunResult, + { status: 500 }, + ) + } + } + + private async handleStatusRequest(input: { + binding: PackageServiceBindingState + }) { + await this.initializeBinding(input.binding) + const serviceDefinition = this.stateSnapshot.binding + ? (await loadSavedPackageService({ + env: this.env, + binding: input.binding, + })).packageSource.manifest.kody.services?.[input.binding.serviceName] ?? null + : null + return Response.json({ + package_id: input.binding.packageId, + kody_id: input.binding.kodyId, + service_name: input.binding.serviceName, + status: this.stateSnapshot.status, + auto_start: serviceDefinition?.autoStart ?? false, + last_error: this.stateSnapshot.lastError, + last_started_at: this.stateSnapshot.lastStartedAt, + last_stopped_at: this.stateSnapshot.lastStoppedAt, + last_run_finished_at: this.stateSnapshot.lastRunFinishedAt, + last_result: this.stateSnapshot.lastResult, + }) + } + + private async handleStopRequest(input: { + binding: PackageServiceBindingState + }) { + await this.initializeBinding(input.binding) + this.stateSnapshot.status = 'stopped' + this.stateSnapshot.lastStoppedAt = new Date().toISOString() + await this.ctx.storage.deleteAlarm().catch(() => { + // Best effort cleanup. + }) + await this.persistState() + return Response.json({ ok: true }) + } + + async fetch(request: Request) { + const url = new URL(request.url) + const body = (await request.json().catch(() => null)) as + | { binding?: PackageServiceBindingState } + | null + const binding = body?.binding + if (!binding) { + return new Response('Missing package service binding.', { status: 400 }) + } + if (request.method === 'POST' && url.pathname.endsWith('/start')) { + return await this.handleStartRequest({ binding }) + } + if (request.method === 'POST' && url.pathname.endsWith('/status')) { + return await this.handleStatusRequest({ binding }) + } + if (request.method === 'POST' && url.pathname.endsWith('/stop')) { + return await this.handleStopRequest({ binding }) + } + return new Response('Not found', { status: 404 }) + } + + async alarm() { + const binding = this.stateSnapshot.binding + if (!binding) return + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + if (loaded.packageSource.manifest.kody.services?.[binding.serviceName]?.autoStart) { + await this.handleStartRequest({ binding }) + } + } +} + +export const PackageServiceInstance = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => buildSentryOptions(env), + PackageServiceInstanceBase, +) + +export function packageServiceRpc(input: { + env: Env + userId: string + packageId: string + kodyId: string + sourceId: string + baseUrl: string + serviceName: string +}) { + const binding: PackageServiceBindingState = { + userId: input.userId, + packageId: input.packageId, + kodyId: input.kodyId, + sourceId: input.sourceId, + baseUrl: input.baseUrl, + serviceName: input.serviceName, + } + const stub = getPackageServiceStub(input) + return { + async start() { + const response = await stub.fetch( + new Request('https://package-service.invalid/service/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ binding }), + }), + ) + return await response.json() + }, + async status() { + const response = await stub.fetch( + new Request('https://package-service.invalid/service/status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ binding }), + }), + ) + return await response.json() + }, + async stop() { + const response = await stub.fetch( + new Request('https://package-service.invalid/service/stop', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ binding }), + }), + ) + return await response.json() + }, + } +} + +export async function listSavedPackageServices(input: { + env: Env + userId: string + baseUrl: string + packageId: string +}) { + const savedPackage = await getSavedPackageById(input.env.APP_DB, { + userId: input.userId, + packageId: input.packageId, + }) + if (!savedPackage) { + throw new Error('Saved package was not found.') + } + const loaded = await loadPackageSourceBySourceId({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.userId, + sourceId: savedPackage.sourceId, + }) + return { + savedPackage, + services: listPackageServices(loaded.manifest), + } +} diff --git a/packages/worker/src/package-runtime/published-bundle-artifacts.ts b/packages/worker/src/package-runtime/published-bundle-artifacts.ts index 93238cc2ec..768f0b9a17 100644 --- a/packages/worker/src/package-runtime/published-bundle-artifacts.ts +++ b/packages/worker/src/package-runtime/published-bundle-artifacts.ts @@ -1,4 +1,7 @@ -import { getPackageAppEntryPath } from '#worker/package-registry/manifest.ts' +import { + getPackageAppEntryPath, + listPackageServices, +} from '#worker/package-registry/manifest.ts' import { type AuthoredPackageJson, type SavedPackageRecord, @@ -168,6 +171,7 @@ export async function persistPublishedBundleArtifact(input: PersistPublishedBund modules: input.modules, dependencies: input.dependencies, packageContext: input.packageContext ?? null, + serviceContext: null, createdAt: new Date().toISOString(), } const existing = await getPublishedBundleArtifactByIdentity(input.env.APP_DB, { @@ -292,6 +296,26 @@ export async function rebuildPublishedPackageArtifacts(input: { }) } } + for (const service of listPackageServices(input.manifest)) { + const bundle = await input.buildModuleBundle({ + entryPoint: service.entry, + }) + await persistPublishedBundleArtifact({ + env: input.env, + userId: input.userId, + source: input.source, + kind: 'service', + artifactName: service.name, + entryPoint: service.entry, + mainModule: bundle.mainModule, + modules: bundle.modules, + dependencies: bundle.dependencies ?? fallbackDependencies, + packageContext: { + packageId: input.savedPackage.id, + kodyId: input.savedPackage.kodyId, + }, + }) + } for (const [exportName, exportTarget] of Object.entries( input.manifest.exports, ) as Array<[string, AuthoredPackageJson['exports'][string]]>) { diff --git a/packages/worker/src/package-runtime/published-runtime-artifacts.ts b/packages/worker/src/package-runtime/published-runtime-artifacts.ts index 29c9bc8802..ae8b5ae0c0 100644 --- a/packages/worker/src/package-runtime/published-runtime-artifacts.ts +++ b/packages/worker/src/package-runtime/published-runtime-artifacts.ts @@ -8,7 +8,7 @@ const sourceSnapshotPrefix = 'source-snapshot' const sourceManifestSnapshotPrefix = 'source-manifest-snapshot' const bundleArtifactPrefix = 'bundle-artifact' -export type BundleArtifactKind = 'app' | 'job' | 'module' +export type BundleArtifactKind = 'app' | 'job' | 'module' | 'service' export type BundleArtifactDependency = { sourceId: string @@ -63,6 +63,9 @@ export type PublishedBundleArtifact = { packageId: string kodyId: string } | null + serviceContext: { + serviceName: string + } | null createdAt: string } diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index b7fe61a10a..ef461ccbdf 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -1,5 +1,7 @@ import { getPackageAppEntryPath, + getPackageServiceEntryPath, + listPackageServices, normalizePackageWorkspacePath, parseAuthoredPackageJson, resolvePackageExportPath, @@ -252,6 +254,15 @@ function collectPackageTypecheckTargets( for (const job of Object.values(manifest.kody.jobs ?? {})) { remember(job.entry, true) } + for (const service of listPackageServices(manifest)) { + const serviceEntryPath = getPackageServiceEntryPath({ + manifest, + serviceName: service.name, + }) + if (serviceEntryPath) { + remember(serviceEntryPath, true) + } + } return Array.from(targets.values()) } diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index 0bc804b0ab..9a78db5e41 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -4,7 +4,7 @@ declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "JobManager" | "StorageRunner" | "AgentTurnRunner" | "RepoSession"; + durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "JobManager" | "StorageRunner" | "AgentTurnRunner" | "RepoSession" | "PackageServiceInstance"; } interface Env { OAUTH_KV: KVNamespace; @@ -29,6 +29,7 @@ declare namespace Cloudflare { STORAGE_RUNNER: DurableObjectNamespace; AGENT_TURN_RUNNER: DurableObjectNamespace; REPO_SESSION: DurableObjectNamespace; + PACKAGE_SERVICE_INSTANCE: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 25c7c727bd..046299baea 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -50,6 +50,10 @@ "new_sqlite_classes": ["PackageRealtimeSession"], "tag": "v13", }, + { + "new_sqlite_classes": ["PackageServiceInstance"], + "tag": "v14", + }, ], "observability": { @@ -112,6 +116,10 @@ "class_name": "PackageRealtimeSession", "name": "PACKAGE_REALTIME_SESSION", }, + { + "class_name": "PackageServiceInstance", + "name": "PACKAGE_SERVICE_INSTANCE", + }, ], }, "d1_databases": [ From 8147b28f66046578467cb4dbdf242f8364f9678a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 22:47:43 +0000 Subject: [PATCH 02/39] Bind package services in preview and test Co-authored-by: Kent C. Dodds --- packages/worker/wrangler.jsonc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 046299baea..1afd08b0ff 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -205,6 +205,10 @@ "class_name": "PackageRealtimeSession", "name": "PACKAGE_REALTIME_SESSION", }, + { + "class_name": "PackageServiceInstance", + "name": "PACKAGE_SERVICE_INSTANCE", + }, ], }, "d1_databases": [ @@ -290,6 +294,10 @@ "class_name": "PackageRealtimeSession", "name": "PACKAGE_REALTIME_SESSION", }, + { + "class_name": "PackageServiceInstance", + "name": "PACKAGE_SERVICE_INSTANCE", + }, ], }, "d1_databases": [ From 05e040dafc74844b92cf1d50e7fac380bba0403b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 22:57:00 +0000 Subject: [PATCH 03/39] Polish package services review feedback Co-authored-by: Kent C. Dodds --- .../mcp/capabilities/services/service-get.ts | 7 +++- .../mcp/capabilities/services/service-list.ts | 11 +++++- .../mcp/capabilities/services/service-stop.ts | 9 ++++- .../src/mcp/capabilities/services/shared.ts | 6 --- .../worker/src/package-registry/manifest.ts | 4 +- .../worker/src/package-runtime/package-app.ts | 17 ++++++++- .../package-service.node.test.ts | 3 ++ .../src/package-runtime/package-service.ts | 37 +++++++++++-------- .../published-runtime-artifacts.ts | 13 +++++-- packages/worker/src/repo/checks.ts | 11 +----- 10 files changed, 75 insertions(+), 43 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/services/service-get.ts b/packages/worker/src/mcp/capabilities/services/service-get.ts index b637888a4a..b61a7b10e9 100644 --- a/packages/worker/src/mcp/capabilities/services/service-get.ts +++ b/packages/worker/src/mcp/capabilities/services/service-get.ts @@ -2,6 +2,7 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { z } from 'zod' import { + normalizePackageServiceStatus, packageServiceStatusSchema, requirePackageServiceContext, } from './shared.ts' @@ -17,7 +18,7 @@ export const serviceGetCapability = defineDomainCapability( idempotent: true, destructive: false, inputSchema: z.object({ - service_name: z.string().min(1), + service_name: z.string().trim().min(1), package_id: z.string().min(1).optional(), }), outputSchema: packageServiceStatusSchema, @@ -33,7 +34,9 @@ export const serviceGetCapability = defineDomainCapability( `Package service "${args.service_name}" was not found.`, ) } - return await serviceContext.service.status() + return normalizePackageServiceStatus( + await serviceContext.service.status(), + ) }, }, ) diff --git a/packages/worker/src/mcp/capabilities/services/service-list.ts b/packages/worker/src/mcp/capabilities/services/service-list.ts index 879ecb169c..69cd2b15e8 100644 --- a/packages/worker/src/mcp/capabilities/services/service-list.ts +++ b/packages/worker/src/mcp/capabilities/services/service-list.ts @@ -37,12 +37,19 @@ export const serviceListCapability = defineDomainCapability( }) const services = await Promise.all( listed.services.map(async (service) => { - const status = normalizePackageServiceStatus(await listed.rpc(service.name).status()) + let status: z.infer['status'] = 'error' + try { + status = normalizePackageServiceStatus( + await listed.rpc(service.name).status(), + ).status + } catch { + // Keep the rest of the service list usable if one status lookup fails. + } return { name: service.name, entry: service.entry, auto_start: service.auto_start, - status: status.status, + status, } }), ) diff --git a/packages/worker/src/mcp/capabilities/services/service-stop.ts b/packages/worker/src/mcp/capabilities/services/service-stop.ts index f90bb096c6..66e7feebf3 100644 --- a/packages/worker/src/mcp/capabilities/services/service-stop.ts +++ b/packages/worker/src/mcp/capabilities/services/service-stop.ts @@ -4,7 +4,7 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requirePackageServiceContext } from './shared.ts' const inputSchema = z.object({ - service_name: z.string().min(1), + service_name: z.string().trim().min(1), package_id: z.string().min(1).optional(), }) @@ -30,7 +30,12 @@ export const serviceStopCapability = defineDomainCapability( serviceName: args.service_name, explicitPackageId: args.package_id, }) - const result = (await serviceContext.service?.stop()) as { ok?: unknown } + if (!serviceContext.service) { + throw new Error( + `Package service "${args.service_name}" was not found.`, + ) + } + const result = (await serviceContext.service.stop()) as { ok?: unknown } return { ok: result?.ok === true, } diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index 33cbe41742..2e0ff68efd 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -5,7 +5,6 @@ import { getSavedPackageById } from '#worker/package-registry/repo.ts' import { listSavedPackageServices, packageServiceRpc, - type PackageServiceBindingState, } from '#worker/package-runtime/package-service.ts' export const packageServiceRecordSchema = z.object({ @@ -139,8 +138,3 @@ export function normalizePackageServiceStatus( return result.data } -export function buildPackageServiceBinding(input: { - binding: PackageServiceBindingState -}) { - return input.binding -} diff --git a/packages/worker/src/package-registry/manifest.ts b/packages/worker/src/package-registry/manifest.ts index de564a6a19..2aae062760 100644 --- a/packages/worker/src/package-registry/manifest.ts +++ b/packages/worker/src/package-registry/manifest.ts @@ -194,12 +194,12 @@ export function buildPackageSearchProjection( } export function buildPackageSearchDocument(projection: PackageSearchProjection) { - const jobLines = projection.jobs.map((job) => + const jobLines = (projection.jobs ?? []).map((job) => [job.name, job.entry, job.schedule, job.enabled ? 'enabled' : 'disabled'] .filter((value) => value.length > 0) .join(' '), ) - const serviceLines = projection.services.map((service) => + const serviceLines = (projection.services ?? []).map((service) => [ service.name, service.entry, diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 87520649ce..d719d9b60c 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -708,10 +708,25 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< baseUrl: this.ctx.props.baseUrl, packageId: this.ctx.props.packageId, }) + const services = await Promise.all( + result.services.map(async (service) => { + const status = await this.getPackageServiceRpc(service.name).status() + return { + ...service, + status: + status && + typeof status === 'object' && + 'status' in status && + typeof status.status === 'string' + ? status.status + : 'error', + } + }), + ) return { package_id: result.savedPackage.id, kody_id: result.savedPackage.kodyId, - services: result.services, + services, } } diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 337e8ca51e..41fbdef224 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -8,4 +8,7 @@ test('buildPackageServiceStorageId creates stable per-service storage ids', () = expect(buildPackageServiceStorageId('package-1', 'guild sync')).toBe( 'service:package-1:guild%20sync', ) + expect(buildPackageServiceStorageId('package-1', 'a:b/c')).toBe( + 'service:package-1:a%3Ab%2Fc', + ) }) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index e15843e290..f4cdbab6cb 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -22,6 +22,7 @@ export type PackageServiceBindingState = { type PackageServiceState = { binding: PackageServiceBindingState | null + autoStart: boolean lastStartedAt: string | null lastStoppedAt: string | null status: 'idle' | 'running' | 'stopped' | 'error' @@ -41,6 +42,7 @@ type PackageServiceRunResult = { function createInitialPackageServiceState(): PackageServiceState { return { binding: null, + autoStart: false, lastStartedAt: null, lastStoppedAt: null, status: 'idle', @@ -86,7 +88,7 @@ export function buildPackageServiceStorageId( packageId: string, serviceName: string, ) { - return `service:${packageId}:${encodeURIComponent(serviceName)}` + return `service:${encodeURIComponent(packageId)}:${encodeURIComponent(serviceName)}` } async function loadSavedPackageService(input: { @@ -155,6 +157,17 @@ class PackageServiceInstanceBase extends DurableObject { } if (!existing) { this.stateSnapshot.binding = binding + try { + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + this.stateSnapshot.autoStart = + loaded.packageSource.manifest.kody.services?.[binding.serviceName] + ?.autoStart ?? false + } catch { + this.stateSnapshot.autoStart = false + } await this.persistState() } } @@ -187,6 +200,10 @@ class PackageServiceInstanceBase extends DurableObject { sourceFiles: loaded.packageSource.files, entryPoint: loaded.serviceEntry, })) + const storageId = buildPackageServiceStorageId( + binding.packageId, + binding.serviceName, + ) const callerContext = createMcpCallerContext({ baseUrl: binding.baseUrl, user: { @@ -197,10 +214,7 @@ class PackageServiceInstanceBase extends DurableObject { storageContext: { sessionId: null, appId: binding.packageId, - storageId: buildPackageServiceStorageId( - binding.packageId, - binding.serviceName, - ), + storageId, }, }) const result = await runBundledModuleWithRegistry( @@ -221,10 +235,7 @@ class PackageServiceInstanceBase extends DurableObject { }, storageTools: { userId: binding.userId, - storageId: buildPackageServiceStorageId( - binding.packageId, - binding.serviceName, - ), + storageId, writable: true, }, }, @@ -294,18 +305,12 @@ class PackageServiceInstanceBase extends DurableObject { binding: PackageServiceBindingState }) { await this.initializeBinding(input.binding) - const serviceDefinition = this.stateSnapshot.binding - ? (await loadSavedPackageService({ - env: this.env, - binding: input.binding, - })).packageSource.manifest.kody.services?.[input.binding.serviceName] ?? null - : null return Response.json({ package_id: input.binding.packageId, kody_id: input.binding.kodyId, service_name: input.binding.serviceName, status: this.stateSnapshot.status, - auto_start: serviceDefinition?.autoStart ?? false, + auto_start: this.stateSnapshot.autoStart, last_error: this.stateSnapshot.lastError, last_started_at: this.stateSnapshot.lastStartedAt, last_stopped_at: this.stateSnapshot.lastStoppedAt, diff --git a/packages/worker/src/package-runtime/published-runtime-artifacts.ts b/packages/worker/src/package-runtime/published-runtime-artifacts.ts index ae8b5ae0c0..90ad5cf4b9 100644 --- a/packages/worker/src/package-runtime/published-runtime-artifacts.ts +++ b/packages/worker/src/package-runtime/published-runtime-artifacts.ts @@ -69,6 +69,14 @@ export type PublishedBundleArtifact = { createdAt: string } +type StoredPublishedBundleArtifact = Omit< + PublishedBundleArtifact, + 'modules' | 'serviceContext' +> & { + modules: Record + serviceContext?: PublishedBundleArtifact['serviceContext'] +} + function getBundleArtifactsKv(env: Env) { const kv = (env as Env & { BUNDLE_ARTIFACTS_KV?: KVNamespace }).BUNDLE_ARTIFACTS_KV if (!kv) { @@ -406,9 +414,7 @@ export async function readPublishedBundleArtifact(input: { }) { const stored = await getBundleArtifactsKv(input.env).get(input.kvKey, 'json') if (!stored || typeof stored !== 'object') return null - const artifact = stored as Omit & { - modules: Record - } + const artifact = stored as StoredPublishedBundleArtifact if ( artifact.version !== bundleArtifactVersion || typeof artifact.modules !== 'object' || @@ -418,6 +424,7 @@ export async function readPublishedBundleArtifact(input: { } return { ...artifact, + serviceContext: artifact.serviceContext ?? null, modules: deserializeWorkerLoaderModules(artifact.modules), } satisfies PublishedBundleArtifact } diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index ef461ccbdf..993bf6ba24 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -1,6 +1,5 @@ import { getPackageAppEntryPath, - getPackageServiceEntryPath, listPackageServices, normalizePackageWorkspacePath, parseAuthoredPackageJson, @@ -255,13 +254,7 @@ function collectPackageTypecheckTargets( remember(job.entry, true) } for (const service of listPackageServices(manifest)) { - const serviceEntryPath = getPackageServiceEntryPath({ - manifest, - serviceName: service.name, - }) - if (serviceEntryPath) { - remember(serviceEntryPath, true) - } + remember(service.entry, true) } return Array.from(targets.values()) } @@ -401,7 +394,7 @@ function formatBundleCheckMessage(input: { .join(', ')}.` } if (input.targetCount === 0) { - return 'Package defines no app entry, exports, or jobs to bundle.' + return 'Package defines no app entry, exports, jobs, or services to bundle.' } return `Resolved ${input.targetCount} package runtime entrypoint(s) for bundling.` } From 28f7fb8006834fe06bfb64cb81bf1837b73a870d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 23:01:44 +0000 Subject: [PATCH 04/39] Fix oauth workers env for package services Co-authored-by: Kent C. Dodds --- packages/worker/src/oauth-handlers.workers.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index ed835e15b7..80897ef862 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -110,6 +110,7 @@ function createEnv( JOB_MANAGER: mockJobDoNamespace('job-manager-test-id'), STORAGE_RUNNER: mockJobDoNamespace('storage-runner-test-id'), PACKAGE_REALTIME_SESSION: mockJobDoNamespace('package-realtime-session-test-id'), + PACKAGE_SERVICE_INSTANCE: mockJobDoNamespace('package-service-instance-test-id'), } as unknown as Env } From 8397a00ddea48fc309120baa31d3f06caa925a6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 23:11:47 +0000 Subject: [PATCH 05/39] Tighten package services runtime parity Co-authored-by: Kent C. Dodds --- .../src/package-runtime/module-graph.ts | 1 + .../worker/src/package-runtime/package-app.ts | 24 ++++++++++++------- packages/worker/src/repo/checks.ts | 2 ++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/worker/src/package-runtime/module-graph.ts b/packages/worker/src/package-runtime/module-graph.ts index f915534586..d43e3a0a51 100644 --- a/packages/worker/src/package-runtime/module-graph.ts +++ b/packages/worker/src/package-runtime/module-graph.ts @@ -104,6 +104,7 @@ export const refreshAccessToken = runtime.refreshAccessToken; export const createAuthenticatedFetch = runtime.createAuthenticatedFetch; export const agentChatTurnStream = runtime.agentChatTurnStream; export const packageContext = runtime.packageContext ?? null; +export const serviceContext = runtime.serviceContext ?? null; export default runtime; `.trim() diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index d719d9b60c..bef9cd21a0 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -710,16 +710,24 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< }) const services = await Promise.all( result.services.map(async (service) => { - const status = await this.getPackageServiceRpc(service.name).status() + let status: string = 'error' + try { + const serviceStatus = await this.getPackageServiceRpc( + service.name, + ).status() + status = + serviceStatus && + typeof serviceStatus === 'object' && + 'status' in serviceStatus && + typeof serviceStatus.status === 'string' + ? serviceStatus.status + : 'error' + } catch { + // Keep the rest of the service list usable if one status lookup fails. + } return { ...service, - status: - status && - typeof status === 'object' && - 'status' in status && - typeof status.status === 'string' - ? status.status - : 'error', + status, } }), ) diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index 993bf6ba24..489c2a7864 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -198,6 +198,8 @@ declare function createAuthenticatedFetch( providerName: string, ): Promise<(input: string | URL | Request, init?: RequestInit) => Promise>; declare function agentChatTurnStream(input: KodyCapabilityArgs): AsyncIterable; +declare const packageContext: { packageId: string; kodyId: string } | null; +declare const serviceContext: { serviceName: string } | null; ${ input?.includeStorage === true ? ` From f3d0d475c20a047752162a2497e6d435ac2c1ff5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 23:20:22 +0000 Subject: [PATCH 06/39] Schedule package service auto-start alarms Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index f4cdbab6cb..a00f7211f9 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -157,18 +157,21 @@ class PackageServiceInstanceBase extends DurableObject { } if (!existing) { this.stateSnapshot.binding = binding - try { - const loaded = await loadSavedPackageService({ - env: this.env, - binding, - }) - this.stateSnapshot.autoStart = - loaded.packageSource.manifest.kody.services?.[binding.serviceName] - ?.autoStart ?? false - } catch { - this.stateSnapshot.autoStart = false - } + try { + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + this.stateSnapshot.autoStart = + loaded.packageSource.manifest.kody.services?.[binding.serviceName] + ?.autoStart ?? false + } catch { + this.stateSnapshot.autoStart = false + } await this.persistState() + if (this.stateSnapshot.autoStart) { + await this.ctx.storage.setAlarm(new Date()) + } } } From 21a9553c680cc04d5dfc7ac6b1c11910692c2465 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 23:35:14 +0000 Subject: [PATCH 07/39] Tighten package service capability inputs Co-authored-by: Kent C. Dodds --- packages/worker/src/mcp/capabilities/services/service-start.ts | 2 +- packages/worker/src/mcp/capabilities/services/shared.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/services/service-start.ts b/packages/worker/src/mcp/capabilities/services/service-start.ts index 62e51011e0..ffacb60a97 100644 --- a/packages/worker/src/mcp/capabilities/services/service-start.ts +++ b/packages/worker/src/mcp/capabilities/services/service-start.ts @@ -4,7 +4,7 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requirePackageServiceContext } from './shared.ts' const inputSchema = z.object({ - service_name: z.string().min(1), + service_name: z.string().trim().min(1), package_id: z.string().min(1).optional(), }) diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index 2e0ff68efd..4f570008e4 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -126,8 +126,6 @@ export type PackageServiceStatusRecord = z.infer< typeof packageServiceStatusSchema > -export const serviceStatusOutputSchema = packageServiceStatusSchema - export function normalizePackageServiceStatus( input: unknown, ): PackageServiceStatusRecord { From b2c5bb3681c8100d175f2a76e161abd36810ee19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 00:26:39 +0000 Subject: [PATCH 08/39] Make package services gateway-friendly Co-authored-by: Kent C. Dodds --- docs/use/execute.md | 9 + docs/use/packages.md | 4 + .../capabilities/services/service-start.ts | 6 +- .../services/services-domain.node.test.ts | 20 +- .../src/mcp/capabilities/services/shared.ts | 7 +- packages/worker/src/mcp/executor.ts | 3 +- .../mcp/run-codemode-registry.node.test.ts | 75 ++++++ .../worker/src/mcp/run-codemode-registry.ts | 81 +++++- .../src/package-runtime/package-service.ts | 247 +++++++++++++----- packages/worker/src/repo/checks.ts | 8 + 10 files changed, 381 insertions(+), 79 deletions(-) diff --git a/docs/use/execute.md b/docs/use/execute.md index 0bb3c8277e..8701493f17 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -24,6 +24,12 @@ and export a default function. These helpers are runtime exports: - use **`import { serviceContext } from 'kody:runtime'`** inside package service code when you need the current service identity; it is **`null`** outside package service runs +- package service runs also expose **`service`** through **`kody:runtime`** for + background lifecycle control: + - `await service.getStatus()` — read the current package-service status + - `await service.shouldStop()` — cooperatively observe stop requests + - `await service.setAlarm(runAt)` — schedule the next service wake-up + - `await service.clearAlarm()` — clear a pending service wake-up - use **`import thing from 'kody:@scope/my-package/export-name'`** to reuse a saved package export by full package name @@ -101,6 +107,9 @@ package-owned jobs and non-package jobs created with `job_schedule` or - bound storage is execute-, app-, package-, or job-owned durable state - package service runs also get writable service-owned durable state scoped to the declared service name +- package service runs are background-managed by the service Durable Object, so + `service_start` returns immediately with a running state while the service code + continues in the background until it finishes, errors, or cooperatively stops - import **`storage`** from **`kody:runtime`** - use **`storage.get(...)`**, **`storage.set(...)`**, **`storage.list(...)`**, and **`storage.sql(query, params?)`** diff --git a/docs/use/packages.md b/docs/use/packages.md index e2afae9a2c..33d5007ce2 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -92,6 +92,10 @@ Treat package services like package-owned runtime modules: - service code lives in the package repo - each service entry module is declared by `kody.services..entry` - service lifecycle is controlled through the `services` capability domain +- service starts return immediately and the service keeps running in the + background until it finishes or is stopped +- service code can inspect its own lifecycle through `serviceContext` and the + `service` helper exposed by `kody:runtime` - services share the same saved package identity as package apps and jobs ## Package-owned jobs diff --git a/packages/worker/src/mcp/capabilities/services/service-start.ts b/packages/worker/src/mcp/capabilities/services/service-start.ts index ffacb60a97..9a24f893f8 100644 --- a/packages/worker/src/mcp/capabilities/services/service-start.ts +++ b/packages/worker/src/mcp/capabilities/services/service-start.ts @@ -10,10 +10,10 @@ const inputSchema = z.object({ const outputSchema = z.object({ ok: z.boolean(), - result: z.unknown().optional(), - error: z.string().optional(), + run_id: z.string().optional(), started_at: z.string().optional(), - finished_at: z.string().optional(), + status: z.enum(['running']).optional(), + already_running: z.boolean().optional(), }) export const serviceStartCapability = defineDomainCapability( diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index 5c2e6e3594..cf21ee1814 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -92,6 +92,9 @@ test('service_list returns declared package services with live status', async () service_name: 'discord-gateway', status: 'stopped', auto_start: true, + stop_requested: false, + active_run_id: null, + next_alarm_at: null, last_error: null, last_started_at: null, last_stopped_at: null, @@ -107,6 +110,9 @@ test('service_list returns declared package services with live status', async () service_name: 'discord-gateway', status: 'stopped', auto_start: true, + stop_requested: false, + active_run_id: null, + next_alarm_at: null, last_error: null, last_started_at: null, last_stopped_at: null, @@ -158,8 +164,11 @@ test('service_get, service_start, and service_stop delegate to package service R package_id: 'package-123', kody_id: 'example', service_name: 'discord-gateway', - status: 'idle', + status: 'running', auto_start: false, + stop_requested: false, + active_run_id: 'run-123', + next_alarm_at: null, last_error: null, last_started_at: null, last_stopped_at: null, @@ -168,8 +177,9 @@ test('service_get, service_start, and service_stop delegate to package service R }), start: async () => ({ ok: true, + run_id: 'run-123', started_at: '2026-04-24T00:00:00.000Z', - finished_at: '2026-04-24T00:00:01.000Z', + status: 'running', }), stop: async () => ({ ok: true, @@ -193,7 +203,8 @@ test('service_get, service_start, and service_stop delegate to package service R ), ).resolves.toMatchObject({ service_name: 'discord-gateway', - status: 'idle', + status: 'running', + active_run_id: 'run-123', }) await expect( @@ -208,8 +219,9 @@ test('service_get, service_start, and service_stop delegate to package service R ), ).resolves.toEqual({ ok: true, + run_id: 'run-123', started_at: '2026-04-24T00:00:00.000Z', - finished_at: '2026-04-24T00:00:01.000Z', + status: 'running', }) await expect( diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index 4f570008e4..a881ccb04e 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -14,15 +14,18 @@ export const packageServiceRecordSchema = z.object({ }) export const packageServiceSummarySchema = packageServiceRecordSchema.extend({ - status: z.enum(['idle', 'running', 'stopped', 'error']), + status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), }) export const packageServiceStatusSchema = z.object({ package_id: z.string(), kody_id: z.string(), service_name: z.string(), - status: z.enum(['idle', 'running', 'stopped', 'error']), + status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), auto_start: z.boolean(), + stop_requested: z.boolean(), + active_run_id: z.string().nullable(), + next_alarm_at: z.string().nullable(), last_error: z.string().nullable(), last_started_at: z.string().nullable(), last_stopped_at: z.string().nullable(), diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts index 140b95d453..eb5cd82a16 100644 --- a/packages/worker/src/mcp/executor.ts +++ b/packages/worker/src/mcp/executor.ts @@ -23,6 +23,7 @@ export function createExecuteExecutor(input: { exports?: WorkerLoopbackExports gatewayProps: FetchGatewayProps modules?: WorkerLoaderModules + timeoutMs?: number }) { const loopbackExports = input.exports ?? workerExports if (!loopbackExports?.CodemodeFetchGateway) { @@ -32,7 +33,7 @@ export function createExecuteExecutor(input: { } return new DynamicWorkerExecutor({ loader: input.env.LOADER, - timeout: 90_000, + timeout: input.timeoutMs ?? 90_000, globalOutbound: loopbackExports.CodemodeFetchGateway({ props: input.gatewayProps, }), 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 e2b9e2eade..613d30e8e5 100644 --- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts +++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts @@ -5,6 +5,7 @@ import { buildKodyModuleBundle } from '#worker/package-runtime/module-graph.ts' import { buildCodemodeFns, runCodemodeWithRegistry, + runBundledModuleWithRegistry, runModuleWithRegistry, } from './run-codemode-registry.ts' import * as secretService from '#mcp/secrets/service.ts' @@ -900,3 +901,77 @@ test('runCodemodeWithRegistry keeps legacy snippet execution for non-module code getRegistrySpy.mockRestore() } }) + +test('runBundledModuleWithRegistry injects service helpers and custom timeout', 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, + capabilityHandlers: {}, + capabilityList: [], + capabilityMap: {}, + capabilitySpecs: {}, + capabilityToolDescriptors: {}, + } as Awaited>) + let wrapped = '' + const createExecuteExecutorSpy = vi + .spyOn(await import('#mcp/executor.ts'), 'createExecuteExecutor') + .mockImplementation((input) => { + expect(input.timeoutMs).toBe(300_000) + return { + async execute(source) { + wrapped = String(source) + return { + result: 'ok', + logs: [], + } + }, + } as never + }) + + try { + const result = await runBundledModuleWithRegistry( + env, + callerContext, + { + mainModule: 'entry.js', + modules: { + 'entry.js': 'export default async () => "ok"', + }, + }, + undefined, + { + serviceContext: { + serviceName: 'discord-gateway', + }, + serviceTools: { + getStatus: async () => ({ status: 'running' }), + shouldStop: async () => false, + setAlarm: async () => ({ ok: true, scheduled_at: '2026-04-25T00:00:00.000Z' }), + clearAlarm: async () => ({ ok: true }), + }, + executorTimeoutMs: 300_000, + }, + ) + + expect(result.result).toBe('ok') + expect(wrapped).toContain('const service = {') + expect(wrapped).toContain("service_get_status") + expect(wrapped).toContain("service_should_stop") + expect(wrapped).toContain("service_set_alarm") + expect(wrapped).toContain("service_clear_alarm") + expect(wrapped).toContain('"serviceName":"discord-gateway"') + } 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 767b86baf1..043f9bc266 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -48,6 +48,31 @@ type StorageToolOptions = { writable: boolean } +type ServiceToolOptions = { + getStatus: () => Promise + shouldStop: () => Promise + setAlarm: (runAt: Date) => Promise<{ ok: true; scheduled_at: string }> + clearAlarm: () => Promise<{ ok: true }> +} + +function createServiceHelperPrelude() { + return ` +const service = { + getStatus: async () => await codemode.service_get_status({}), + shouldStop: async () => { + const result = await codemode.service_should_stop({}); + return result?.shouldStop === true; + }, + setAlarm: async (runAt) => { + const normalizedRunAt = + runAt instanceof Date ? runAt.toISOString() : String(runAt ?? ''); + return await codemode.service_set_alarm({ runAt: normalizedRunAt }); + }, + clearAlarm: async () => await codemode.service_clear_alarm({}), +}; + `.trim() +} + export async function buildCodemodeFns( env: Env, callerContext: McpCallerContext, @@ -59,6 +84,7 @@ export async function buildCodemodeFns( trackSecretInputValue?: (value: string) => void additionalTools?: AdditionalCodemodeTools storageTools?: StorageToolOptions + serviceTools?: ServiceToolOptions }, ) { const { capabilityMap } = await getCapabilityRegistryForContext({ @@ -106,9 +132,39 @@ export async function buildCodemodeFns( }) : {} assertNoCapabilityCollisions(capabilityMap, storageCodemodeTools) + const serviceCodemodeTools: AdditionalCodemodeTools = options?.serviceTools + ? { + service_get_status: async () => + await options.serviceTools?.getStatus(), + service_should_stop: async () => ({ + shouldStop: await options.serviceTools?.shouldStop(), + }), + service_set_alarm: async (args: unknown) => { + const payload = + typeof args === 'object' && args !== null + ? (args as { runAt?: unknown }) + : {} + const runAtValue = payload.runAt + const runAtString = + typeof runAtValue === 'string' ? runAtValue.trim() : '' + if (!runAtString) { + throw new Error('service.setAlarm requires a runAt ISO string.') + } + const runAt = new Date(runAtString) + if (Number.isNaN(runAt.getTime())) { + throw new Error('service.setAlarm requires a valid runAt ISO string.') + } + return await options.serviceTools?.setAlarm(runAt) + }, + service_clear_alarm: async () => + await options.serviceTools?.clearAlarm(), + } + : {} + assertNoCapabilityCollisions(capabilityMap, serviceCodemodeTools) return { ...capabilityCodemodeTools, ...storageCodemodeTools, + ...serviceCodemodeTools, ...additionalTools, } } @@ -131,6 +187,7 @@ export async function buildCodemodeProvider( trackSecretInputValue?: (value: string) => void additionalTools?: AdditionalCodemodeTools storageTools?: StorageToolOptions + serviceTools?: ServiceToolOptions }, ): Promise { const tools = await buildCodemodeFns(env, callerContext, options) @@ -201,7 +258,9 @@ export async function runCodemodeWithRegistry( additionalTools?: AdditionalCodemodeTools helperPrelude?: string storageTools?: StorageToolOptions + serviceTools?: ServiceToolOptions executorModules?: WorkerLoaderModules + executorTimeoutMs?: number }, ): Promise { const moduleSource = stripCodeFences(code.trim()) @@ -221,6 +280,7 @@ export async function runCodemodeWithRegistry( const executor = createExecuteExecutor({ env, exports: options?.executorExports ?? workerExports, + timeoutMs: options?.executorTimeoutMs, gatewayProps: { baseUrl: callerContext.baseUrl, userId: callerContext.user?.userId ?? null, @@ -234,6 +294,7 @@ export async function runCodemodeWithRegistry( }, additionalTools: options?.additionalTools, storageTools: options?.storageTools, + serviceTools: options?.serviceTools, }) const wrappedCode = params !== undefined @@ -246,7 +307,14 @@ export async function runCodemodeWithRegistry( writable: options.storageTools.writable, }) : '' - const helperPrelude = [storageHelperPrelude, options?.helperPrelude ?? ''] + const serviceHelperPrelude = options?.serviceTools + ? createServiceHelperPrelude() + : '' + const helperPrelude = [ + storageHelperPrelude, + serviceHelperPrelude, + options?.helperPrelude ?? '', + ] .filter((value) => value.trim().length > 0) .join('\n') const wrapped = `async () => { @@ -279,6 +347,8 @@ export async function runModuleWithRegistry( executorExports?: typeof workerExports additionalTools?: AdditionalCodemodeTools storageTools?: StorageToolOptions + serviceTools?: ServiceToolOptions + executorTimeoutMs?: number }, ): Promise { const userId = callerContext.user?.userId ?? '' @@ -323,6 +393,8 @@ export async function runBundledModuleWithRegistry( serviceContext?: { serviceName: string } | null + serviceTools?: ServiceToolOptions + executorTimeoutMs?: number }, ): Promise { const { createExecuteExecutor } = await import('#mcp/executor.ts') @@ -333,6 +405,7 @@ export async function runBundledModuleWithRegistry( const executor = createExecuteExecutor({ env, exports: options?.executorExports ?? workerExports, + timeoutMs: options?.executorTimeoutMs, gatewayProps: { baseUrl: callerContext.baseUrl, userId: callerContext.user?.userId ?? null, @@ -346,6 +419,7 @@ export async function runBundledModuleWithRegistry( }, additionalTools: options?.additionalTools, storageTools: options?.storageTools, + serviceTools: options?.serviceTools, }) const storageHelperPrelude = options?.storageTools ? createStorageHelperPrelude({ @@ -353,9 +427,13 @@ export async function runBundledModuleWithRegistry( writable: options.storageTools.writable, }) : '' + const serviceHelperPrelude = options?.serviceTools + ? createServiceHelperPrelude() + : '' const wrapped = `async () => { ${createExecuteHelperPrelude()} ${storageHelperPrelude ? `${storageHelperPrelude}\n` : ''} +${serviceHelperPrelude ? `${serviceHelperPrelude}\n` : ''} const __previousRuntime = globalThis.__kodyRuntime; globalThis.__kodyRuntime = { codemode, @@ -366,6 +444,7 @@ ${storageHelperPrelude ? `${storageHelperPrelude}\n` : ''} agentChatTurnStream, packageContext: ${JSON.stringify(options?.packageContext ?? null)}, serviceContext: ${JSON.stringify(options?.serviceContext ?? null)}, + service: typeof service === 'undefined' ? null : service, }; try { const __kodyModule = await import(${JSON.stringify(`./${bundle.mainModule}`)}); diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index a00f7211f9..dd2be77ffa 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -23,9 +23,12 @@ export type PackageServiceBindingState = { type PackageServiceState = { binding: PackageServiceBindingState | null autoStart: boolean + stopRequested: boolean + currentRunId: string | null + nextAlarmAt: string | null lastStartedAt: string | null lastStoppedAt: string | null - status: 'idle' | 'running' | 'stopped' | 'error' + status: 'idle' | 'running' | 'stopping' | 'stopped' | 'error' lastError: string | null lastResult: unknown lastRunFinishedAt: string | null @@ -33,16 +36,19 @@ type PackageServiceState = { type PackageServiceRunResult = { ok: boolean - result?: unknown - error?: string + run_id: string started_at: string - finished_at: string + status: 'running' + already_running?: boolean } function createInitialPackageServiceState(): PackageServiceState { return { binding: null, autoStart: false, + stopRequested: false, + currentRunId: null, + nextAlarmAt: null, lastStartedAt: null, lastStoppedAt: null, status: 'idle', @@ -126,6 +132,7 @@ async function loadSavedPackageService(input: { class PackageServiceInstanceBase extends DurableObject { private stateSnapshot: PackageServiceState = createInitialPackageServiceState() + private activeRunPromise: Promise | null = null constructor(state: DurableObjectState, env: Env) { super(state, env) @@ -170,12 +177,126 @@ class PackageServiceInstanceBase extends DurableObject { } await this.persistState() if (this.stateSnapshot.autoStart) { - await this.ctx.storage.setAlarm(new Date()) + const nextAlarmAt = new Date().toISOString() + await this.ctx.storage.setAlarm(new Date(nextAlarmAt)) + this.stateSnapshot.nextAlarmAt = nextAlarmAt + await this.persistState() } } } - private async runService(binding: PackageServiceBindingState) { + private async scheduleAlarm(input: { runAt: Date }) { + const scheduledAt = input.runAt.toISOString() + await this.ctx.storage.setAlarm(new Date(scheduledAt)) + this.stateSnapshot.nextAlarmAt = scheduledAt + await this.persistState() + return { + ok: true, + scheduled_at: scheduledAt, + } + } + + private async clearAlarm() { + await this.ctx.storage.deleteAlarm().catch(() => { + // Best effort cleanup. + }) + this.stateSnapshot.nextAlarmAt = null + await this.persistState() + return { + ok: true, + } + } + + private buildServiceStatusResponse(binding: PackageServiceBindingState) { + return { + package_id: binding.packageId, + kody_id: binding.kodyId, + service_name: binding.serviceName, + status: this.stateSnapshot.status, + auto_start: this.stateSnapshot.autoStart, + stop_requested: this.stateSnapshot.stopRequested, + active_run_id: this.stateSnapshot.currentRunId, + next_alarm_at: this.stateSnapshot.nextAlarmAt, + last_error: this.stateSnapshot.lastError, + last_started_at: this.stateSnapshot.lastStartedAt, + last_stopped_at: this.stateSnapshot.lastStoppedAt, + last_run_finished_at: this.stateSnapshot.lastRunFinishedAt, + last_result: this.stateSnapshot.lastResult, + } + } + + private async runServiceInBackground(input: { + binding: PackageServiceBindingState + runId: string + }) { + try { + const loaded = await loadSavedPackageService({ + env: this.env, + binding: input.binding, + }) + const storageId = buildPackageServiceStorageId( + input.binding.packageId, + input.binding.serviceName, + ) + const result = await this.runService(input.binding, { + getStatus: async () => this.buildServiceStatusResponse(input.binding), + shouldStop: async () => this.stateSnapshot.stopRequested, + setAlarm: async (runAt) => + (await this.scheduleAlarm({ + runAt, + })) as { ok: true; scheduled_at: string }, + clearAlarm: async () => + (await this.clearAlarm()) as { ok: true }, + packageContext: { + packageId: loaded.savedPackage.id, + kodyId: loaded.savedPackage.kodyId, + }, + storageId, + }) + if (this.stateSnapshot.currentRunId !== input.runId) return + this.stateSnapshot.status = this.stateSnapshot.stopRequested + ? 'stopped' + : 'stopped' + this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false + this.stateSnapshot.lastResult = result + this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() + this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt + await this.persistState() + } catch (error) { + if (this.stateSnapshot.currentRunId !== input.runId) return + const errorMessage = + error instanceof Error ? error.message : String(error) + this.stateSnapshot.status = this.stateSnapshot.stopRequested + ? 'stopped' + : 'error' + this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false + this.stateSnapshot.lastError = errorMessage + this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() + this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt + await this.persistState() + } finally { + if (this.activeRunPromise) { + this.activeRunPromise = null + } + } + } + + private async runService( + binding: PackageServiceBindingState, + runtime: { + getStatus: () => Promise> + shouldStop: () => Promise + setAlarm: (runAt: Date) => Promise<{ ok: true; scheduled_at: string }> + clearAlarm: () => Promise<{ ok: true }> + packageContext: { + packageId: string + kodyId: string + } + storageId: string + }, + ) { const [{ runBundledModuleWithRegistry }, { buildKodyModuleBundle }, { loadPublishedBundleArtifactByIdentity }] = await Promise.all([ import('#mcp/run-codemode-registry.ts'), @@ -203,10 +324,6 @@ class PackageServiceInstanceBase extends DurableObject { sourceFiles: loaded.packageSource.files, entryPoint: loaded.serviceEntry, })) - const storageId = buildPackageServiceStorageId( - binding.packageId, - binding.serviceName, - ) const callerContext = createMcpCallerContext({ baseUrl: binding.baseUrl, user: { @@ -217,7 +334,7 @@ class PackageServiceInstanceBase extends DurableObject { storageContext: { sessionId: null, appId: binding.packageId, - storageId, + storageId: runtime.storageId, }, }) const result = await runBundledModuleWithRegistry( @@ -229,16 +346,19 @@ class PackageServiceInstanceBase extends DurableObject { }, undefined, { - packageContext: { - packageId: loaded.savedPackage.id, - kodyId: loaded.savedPackage.kodyId, - }, + packageContext: runtime.packageContext, serviceContext: { serviceName: binding.serviceName, }, + serviceTools: { + getStatus: runtime.getStatus, + shouldStop: runtime.shouldStop, + setAlarm: runtime.setAlarm, + clearAlarm: runtime.clearAlarm, + }, storageTools: { userId: binding.userId, - storageId, + storageId: runtime.storageId, writable: true, }, }, @@ -263,76 +383,57 @@ class PackageServiceInstanceBase extends DurableObject { binding: PackageServiceBindingState }) { await this.initializeBinding(input.binding) + if (this.stateSnapshot.currentRunId) { + return Response.json({ + ok: true, + run_id: this.stateSnapshot.currentRunId, + started_at: this.stateSnapshot.lastStartedAt ?? new Date().toISOString(), + status: 'running', + already_running: true, + } satisfies PackageServiceRunResult) + } const startedAt = new Date().toISOString() + const runId = crypto.randomUUID() + this.stateSnapshot.stopRequested = false + this.stateSnapshot.currentRunId = runId this.stateSnapshot.status = 'running' this.stateSnapshot.lastStartedAt = startedAt this.stateSnapshot.lastError = null await this.persistState() - try { - const result = await this.runService(input.binding) - this.stateSnapshot.status = 'stopped' - this.stateSnapshot.lastResult = result - this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() - this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt - await this.ctx.storage.deleteAlarm().catch(() => { - // Best effort cleanup. - }) - await this.persistState() - return Response.json({ - ok: true, - result, - started_at: startedAt, - finished_at: this.stateSnapshot.lastRunFinishedAt, - } satisfies PackageServiceRunResult) - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - this.stateSnapshot.status = 'error' - this.stateSnapshot.lastError = errorMessage - this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() - this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt - await this.persistState() - return Response.json( - { - ok: false, - error: errorMessage, - started_at: startedAt, - finished_at: this.stateSnapshot.lastRunFinishedAt, - } satisfies PackageServiceRunResult, - { status: 500 }, - ) - } + const task = this.runServiceInBackground({ + binding: input.binding, + runId, + }) + this.activeRunPromise = task + this.ctx.waitUntil(task) + return Response.json({ + ok: true, + run_id: runId, + started_at: startedAt, + status: 'running', + } satisfies PackageServiceRunResult) } private async handleStatusRequest(input: { binding: PackageServiceBindingState }) { await this.initializeBinding(input.binding) - return Response.json({ - package_id: input.binding.packageId, - kody_id: input.binding.kodyId, - service_name: input.binding.serviceName, - status: this.stateSnapshot.status, - auto_start: this.stateSnapshot.autoStart, - last_error: this.stateSnapshot.lastError, - last_started_at: this.stateSnapshot.lastStartedAt, - last_stopped_at: this.stateSnapshot.lastStoppedAt, - last_run_finished_at: this.stateSnapshot.lastRunFinishedAt, - last_result: this.stateSnapshot.lastResult, - }) + return Response.json(this.buildServiceStatusResponse(input.binding)) } private async handleStopRequest(input: { binding: PackageServiceBindingState }) { await this.initializeBinding(input.binding) - this.stateSnapshot.status = 'stopped' + this.stateSnapshot.stopRequested = true + this.stateSnapshot.status = this.stateSnapshot.currentRunId + ? 'stopping' + : 'stopped' this.stateSnapshot.lastStoppedAt = new Date().toISOString() - await this.ctx.storage.deleteAlarm().catch(() => { - // Best effort cleanup. + await this.clearAlarm() + return Response.json({ + ok: true, }) - await this.persistState() - return Response.json({ ok: true }) } async fetch(request: Request) { @@ -353,17 +454,27 @@ class PackageServiceInstanceBase extends DurableObject { if (request.method === 'POST' && url.pathname.endsWith('/stop')) { return await this.handleStopRequest({ binding }) } + if (request.method === 'POST' && url.pathname.endsWith('/service-status')) { + return Response.json(this.buildServiceStatusResponse(binding)) + } return new Response('Not found', { status: 404 }) } async alarm() { const binding = this.stateSnapshot.binding if (!binding) return + this.stateSnapshot.nextAlarmAt = null + await this.persistState() + if (this.stateSnapshot.currentRunId) return const loaded = await loadSavedPackageService({ env: this.env, binding, }) - if (loaded.packageSource.manifest.kody.services?.[binding.serviceName]?.autoStart) { + if ( + loaded.packageSource.manifest.kody.services?.[binding.serviceName] + ?.autoStart && + !this.stateSnapshot.stopRequested + ) { await this.handleStartRequest({ binding }) } } diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index 489c2a7864..e674e6bc1d 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -200,6 +200,14 @@ declare function createAuthenticatedFetch( declare function agentChatTurnStream(input: KodyCapabilityArgs): AsyncIterable; declare const packageContext: { packageId: string; kodyId: string } | null; declare const serviceContext: { serviceName: string } | null; +declare const service: + | { + getStatus(): Promise; + shouldStop(): Promise; + setAlarm(runAt: string | Date): Promise; + clearAlarm(): Promise; + } + | null; ${ input?.includeStorage === true ? ` From 5c894ada29f571dc861eeeb379a23d50ede87e5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 00:45:32 +0000 Subject: [PATCH 09/39] Add package service timeout controls Co-authored-by: Kent C. Dodds --- docs/use/execute.md | 3 +++ docs/use/packages.md | 2 ++ .../src/mcp/capabilities/services/service-list.ts | 1 + .../capabilities/services/services-domain.node.test.ts | 5 +++++ .../worker/src/mcp/capabilities/services/shared.ts | 3 +++ .../worker/src/package-registry/manifest.node.test.ts | 2 ++ packages/worker/src/package-registry/manifest.ts | 3 +++ packages/worker/src/package-registry/types.ts | 1 + packages/worker/src/package-runtime/package-service.ts | 10 ++++++++++ 9 files changed, 30 insertions(+) diff --git a/docs/use/execute.md b/docs/use/execute.md index 8701493f17..4b99b49266 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -30,6 +30,9 @@ and export a default function. These helpers are runtime exports: - `await service.shouldStop()` — cooperatively observe stop requests - `await service.setAlarm(runAt)` — schedule the next service wake-up - `await service.clearAlarm()` — clear a pending service wake-up +- package service runs may also declare **`kody.services..timeoutMs`** in + `package.json` when they need a longer executor budget than the default + package-service timeout - use **`import thing from 'kody:@scope/my-package/export-name'`** to reuse a saved package export by full package name diff --git a/docs/use/packages.md b/docs/use/packages.md index 33d5007ce2..6418d4acd3 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -91,6 +91,8 @@ Treat package services like package-owned runtime modules: - service code lives in the package repo - each service entry module is declared by `kody.services..entry` +- services may optionally declare `kody.services..timeoutMs` to raise the + executor timeout for long-lived or connector-style runs - service lifecycle is controlled through the `services` capability domain - service starts return immediately and the service keeps running in the background until it finishes or is stopped diff --git a/packages/worker/src/mcp/capabilities/services/service-list.ts b/packages/worker/src/mcp/capabilities/services/service-list.ts index 69cd2b15e8..cf2d3a21d0 100644 --- a/packages/worker/src/mcp/capabilities/services/service-list.ts +++ b/packages/worker/src/mcp/capabilities/services/service-list.ts @@ -49,6 +49,7 @@ export const serviceListCapability = defineDomainCapability( name: service.name, entry: service.entry, auto_start: service.auto_start, + timeout_ms: service.timeout_ms ?? null, status, } }), diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index cf21ee1814..3b024c9e5c 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -83,6 +83,7 @@ test('service_list returns declared package services with live status', async () name: 'discord-gateway', entry: 'services/discord-gateway.ts', autoStart: true, + timeoutMs: 300000, }, ], rpc: () => ({ @@ -92,6 +93,7 @@ test('service_list returns declared package services with live status', async () service_name: 'discord-gateway', status: 'stopped', auto_start: true, + timeout_ms: 300000, stop_requested: false, active_run_id: null, next_alarm_at: null, @@ -110,6 +112,7 @@ test('service_list returns declared package services with live status', async () service_name: 'discord-gateway', status: 'stopped', auto_start: true, + timeout_ms: 300000, stop_requested: false, active_run_id: null, next_alarm_at: null, @@ -139,6 +142,7 @@ test('service_list returns declared package services with live status', async () entry: 'services/discord-gateway.ts', auto_start: true, status: 'stopped', + timeout_ms: 300000, }, ], }) @@ -166,6 +170,7 @@ test('service_get, service_start, and service_stop delegate to package service R service_name: 'discord-gateway', status: 'running', auto_start: false, + timeout_ms: 300000, stop_requested: false, active_run_id: 'run-123', next_alarm_at: null, diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index a881ccb04e..6735f5ca1e 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -11,6 +11,7 @@ export const packageServiceRecordSchema = z.object({ name: z.string(), entry: z.string(), auto_start: z.boolean(), + timeout_ms: z.number().int().positive().nullable().optional(), }) export const packageServiceSummarySchema = packageServiceRecordSchema.extend({ @@ -23,6 +24,7 @@ export const packageServiceStatusSchema = z.object({ service_name: z.string(), status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), auto_start: z.boolean(), + timeout_ms: z.number().int().positive().nullable(), stop_requested: z.boolean(), active_run_id: z.string().nullable(), next_alarm_at: z.string().nullable(), @@ -121,6 +123,7 @@ export async function listPackageServicesForContext(input: { name: service.name, entry: service.entry, auto_start: service.autoStart, + timeout_ms: service.timeoutMs ?? null, })), } } diff --git a/packages/worker/src/package-registry/manifest.node.test.ts b/packages/worker/src/package-registry/manifest.node.test.ts index 2428ea066c..57e7ad7c9c 100644 --- a/packages/worker/src/package-registry/manifest.node.test.ts +++ b/packages/worker/src/package-registry/manifest.node.test.ts @@ -74,6 +74,7 @@ test('parseAuthoredPackageJson accepts package service definitions', () => { 'discord-gateway': { entry: './services/discord-gateway.ts', autoStart: true, + timeoutMs: 300000, }, }, }, @@ -85,6 +86,7 @@ test('parseAuthoredPackageJson accepts package service definitions', () => { 'discord-gateway': { entry: './services/discord-gateway.ts', autoStart: true, + timeoutMs: 300000, }, }) }) diff --git a/packages/worker/src/package-registry/manifest.ts b/packages/worker/src/package-registry/manifest.ts index 2aae062760..5646682cd5 100644 --- a/packages/worker/src/package-registry/manifest.ts +++ b/packages/worker/src/package-registry/manifest.ts @@ -133,6 +133,7 @@ export function listPackageServices(manifest: AuthoredPackageJson) { name, entry: normalizePackageWorkspacePath(service.entry), autoStart: service.autoStart ?? false, + timeoutMs: service.timeoutMs ?? null, })) .sort((left, right) => left.name.localeCompare(right.name)) } @@ -160,6 +161,7 @@ export type PackageSearchProjection = { name: string entry: string autoStart: boolean + timeoutMs: number | null }> } @@ -204,6 +206,7 @@ export function buildPackageSearchDocument(projection: PackageSearchProjection) service.name, service.entry, service.autoStart ? 'auto-start' : 'manual-start', + service.timeoutMs != null ? `timeout-ms:${service.timeoutMs}` : '', ] .filter((value) => value.length > 0) .join(' '), diff --git a/packages/worker/src/package-registry/types.ts b/packages/worker/src/package-registry/types.ts index bba4080b3b..78351db3ba 100644 --- a/packages/worker/src/package-registry/types.ts +++ b/packages/worker/src/package-registry/types.ts @@ -38,6 +38,7 @@ export type PackageAppDefinition = z.infer export const packageServiceDefinitionSchema = z.object({ entry: z.string().min(1), autoStart: z.boolean().optional(), + timeoutMs: z.number().int().positive().optional(), }) export type PackageServiceDefinition = z.infer< diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index dd2be77ffa..b2885fc29d 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -23,6 +23,7 @@ export type PackageServiceBindingState = { type PackageServiceState = { binding: PackageServiceBindingState | null autoStart: boolean + timeoutMs: number | null stopRequested: boolean currentRunId: string | null nextAlarmAt: string | null @@ -46,6 +47,7 @@ function createInitialPackageServiceState(): PackageServiceState { return { binding: null, autoStart: false, + timeoutMs: null, stopRequested: false, currentRunId: null, nextAlarmAt: null, @@ -172,8 +174,12 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.autoStart = loaded.packageSource.manifest.kody.services?.[binding.serviceName] ?.autoStart ?? false + this.stateSnapshot.timeoutMs = + loaded.packageSource.manifest.kody.services?.[binding.serviceName] + ?.timeoutMs ?? null } catch { this.stateSnapshot.autoStart = false + this.stateSnapshot.timeoutMs = null } await this.persistState() if (this.stateSnapshot.autoStart) { @@ -214,6 +220,7 @@ class PackageServiceInstanceBase extends DurableObject { service_name: binding.serviceName, status: this.stateSnapshot.status, auto_start: this.stateSnapshot.autoStart, + timeout_ms: this.stateSnapshot.timeoutMs, stop_requested: this.stateSnapshot.stopRequested, active_run_id: this.stateSnapshot.currentRunId, next_alarm_at: this.stateSnapshot.nextAlarmAt, @@ -251,6 +258,7 @@ class PackageServiceInstanceBase extends DurableObject { packageId: loaded.savedPackage.id, kodyId: loaded.savedPackage.kodyId, }, + executorTimeoutMs: this.stateSnapshot.timeoutMs ?? 300_000, storageId, }) if (this.stateSnapshot.currentRunId !== input.runId) return @@ -294,6 +302,7 @@ class PackageServiceInstanceBase extends DurableObject { packageId: string kodyId: string } + executorTimeoutMs: number storageId: string }, ) { @@ -361,6 +370,7 @@ class PackageServiceInstanceBase extends DurableObject { storageId: runtime.storageId, writable: true, }, + executorTimeoutMs: runtime.executorTimeoutMs, }, ) if (result.error) { From 97404a3b75941d87956ea26d9854e351aa144265 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 00:51:09 +0000 Subject: [PATCH 10/39] Export service helper from kody runtime Co-authored-by: Kent C. Dodds --- .../package-runtime/module-graph.node.test.ts | 46 +++++++++++++++++++ .../src/package-runtime/module-graph.ts | 1 + 2 files changed, 47 insertions(+) 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 40a8e95e5c..798c157c40 100644 --- a/packages/worker/src/package-runtime/module-graph.node.test.ts +++ b/packages/worker/src/package-runtime/module-graph.node.test.ts @@ -682,3 +682,49 @@ test('buildKodyAppBundle rewrites dynamic kody runtime imports inside TypeScript "import('kody:runtime')", ) }) + +test('buildKodyAppBundle runtime module exports service helper', async () => { + mockModule.createWorker.mockResolvedValue(createBundleResult('runtime-service-helper')) + + await buildKodyAppBundle({ + env: { + APP_DB: {}, + REPO_SESSION: {}, + } as Env, + baseUrl: 'https://heykody.dev', + userId: 'user-1', + sourceFiles: { + 'package.json': JSON.stringify({ + name: '@kentcdodds/example-package', + exports: { + '.': './index.ts', + }, + kody: { + id: 'example-package', + description: 'Example package', + app: { + entry: 'app.ts', + }, + }, + }), + 'app.ts': `export default { + async fetch() { + return Response.json({ ok: true }) + }, +} +`, + 'index.ts': 'export const value = "ok"', + }, + entryPoint: 'app.ts', + cacheKey: null, + }) + + const firstCall = mockModule.createWorker.mock.calls.at(-1)?.[0] as + | { + files?: Record + } + | undefined + expect(firstCall?.files?.['.__kody_virtual__/runtime.js']).toContain( + 'export const service = runtime.service ?? null;', + ) +}) diff --git a/packages/worker/src/package-runtime/module-graph.ts b/packages/worker/src/package-runtime/module-graph.ts index d43e3a0a51..d318908d92 100644 --- a/packages/worker/src/package-runtime/module-graph.ts +++ b/packages/worker/src/package-runtime/module-graph.ts @@ -105,6 +105,7 @@ export const createAuthenticatedFetch = runtime.createAuthenticatedFetch; export const agentChatTurnStream = runtime.agentChatTurnStream; export const packageContext = runtime.packageContext ?? null; export const serviceContext = runtime.serviceContext ?? null; +export const service = runtime.service ?? null; export default runtime; `.trim() From cf3b66875cb66fdd4cee6f44e61ece7e23e2c969 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:02:26 +0000 Subject: [PATCH 11/39] Add Discord gateway package guide Co-authored-by: Kent C. Dodds --- docs/guides/README.md | 1 + docs/guides/discord-gateway-package.md | 180 ++++++++++++++++++ .../coding/kody-official-guide.node.test.ts | 5 + .../coding/kody-official-guide.ts | 18 +- 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docs/guides/discord-gateway-package.md diff --git a/docs/guides/README.md b/docs/guides/README.md index 9d1f64331b..e46e4b3634 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -10,6 +10,7 @@ available `guide` ids). | [integration-bootstrap.md](./integration-bootstrap.md) | **Start here** for third-party integrations that must work before saving a dependent package or package app | | [secret-backed-integration.md](./secret-backed-integration.md) | Default recipe for non-OAuth integrations that use one or more saved secrets | | [integration-backed-app-happy-path.md](./integration-backed-app-happy-path.md) | Default package app pattern after integration smoke test passes | +| [discord-gateway-package.md](./discord-gateway-package.md) | Package-service pattern for building a native Discord gateway inside Kody | | [oauth.md](./oauth.md) | **Start here** for third-party OAuth (`/connect/oauth`, redirect URI, params) | | [generated-ui-oauth.md](./generated-ui-oauth.md) | Edge case: OAuth in a hosted package app (`open_generated_ui` on a saved package) | | [connect-secret.md](./connect-secret.md) | `/connect/secret` URL parameters and policies | diff --git a/docs/guides/discord-gateway-package.md b/docs/guides/discord-gateway-package.md new file mode 100644 index 0000000000..7b52c0de7f --- /dev/null +++ b/docs/guides/discord-gateway-package.md @@ -0,0 +1,180 @@ +# Discord gateway package pattern + +Use this guide when you want a **native Kody package** to own a Discord gateway +connection instead of relying on a separate external service. + +This guide assumes: + +- the package runtime already supports `package.json#kody.services` +- the package will likely also expose a package app for operator UI +- Discord credentials and downstream delivery details are handled separately + +## Recommended package shape + +Prefer one saved package with: + +- `package.json#kody.app.entry` for setup UI, dashboards, and callback endpoints +- `package.json#kody.services.discord-gateway.entry` for the gateway runtime +- package exports for reusable helpers and formatting logic + +Example manifest shape: + +```json +{ + "name": "@scope/discord-gateway", + "exports": { + ".": "./src/index.ts", + "./format-dispatch": "./src/format-dispatch.ts" + }, + "kody": { + "id": "discord-gateway", + "description": "Native Discord gateway package", + "app": { + "entry": "./src/app.ts" + }, + "services": { + "discord-gateway": { + "entry": "./src/services/discord-gateway.ts", + "autoStart": true, + "timeoutMs": 600000 + } + } + } +} +``` + +## Runtime model + +Package services now run as **background-managed** service instances: + +- `service_start` returns immediately with a running state +- the service Durable Object keeps lifecycle state such as + `status`, `active_run_id`, `stop_requested`, and `next_alarm_at` +- the service module runs with: + - `packageContext` + - `serviceContext` + - service-owned writable `storage` + - a `service` helper from `kody:runtime` + +Import shape inside a service module: + +```ts +import { service, serviceContext, storage } from 'kody:runtime' +``` + +The `service` helper exposes: + +- `await service.getStatus()` +- `await service.shouldStop()` +- `await service.setAlarm(runAt)` +- `await service.clearAlarm()` + +## Recommended Discord gateway loop + +Treat the service entry as the **gateway supervisor**, not just a one-shot job. + +Recommended loop: + +1. Load persisted session state from `storage` + - token metadata / config references + - Discord session id + - last sequence number + - resume URL + - shard id / shard count +2. Open the outbound Discord WebSocket +3. Identify or resume +4. Enter a loop that: + - reads gateway events + - persists sequence/session updates before acting on them + - publishes normalized events to the package app or package exports + - periodically checks `await service.shouldStop()` +5. On clean shutdown: + - close the socket + - clear alarms when no reconnect is desired +6. On transient disconnect: + - persist the latest resumable state + - schedule reconnect with `await service.setAlarm(...)` + - exit the current run + +Pseudo-code shape: + +```ts +import { service, storage } from 'kody:runtime' + +export default async function run() { + const session = (await storage.get('gateway-session')) ?? null + const socket = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json') + + try { + // Identify or resume here. + + while (socket.readyState === WebSocket.OPEN) { + if (await service.shouldStop()) { + socket.close(1000, 'service stop requested') + await service.clearAlarm() + return { stopped: true } + } + + const event = await readNextDiscordEvent(socket) + await persistGatewayState(storage, event) + await handleGatewayEvent(event) + } + } catch (error) { + await persistFailure(storage, error) + await service.setAlarm(new Date(Date.now() + 5_000)) + throw error + } +} +``` + +## Use the package app as the operator plane + +Do not push all gateway interaction into the service itself. + +Use the package app for: + +- guild / shard health dashboards +- reconnect / pause / resume controls +- logs +- live dispatch inspection +- setup flows + +The package app can call the package service lifecycle surface and consume +service status through the existing package runtime bridge. + +## Prefer bounded reconnect loops over one immortal run + +Cloudflare Durable Objects can open outbound WebSockets, but outgoing WebSockets +do **not** hibernate. Favor a design where the service can: + +- reconnect intentionally +- reschedule itself with alarms +- persist resumable state early +- stop cooperatively + +Use `timeoutMs` to give the gateway enough room to run, but do not rely on +indefinite execution as the only lifecycle mechanism. + +## What to persist + +At minimum, persist: + +- `session_id` +- latest sequence / `s` +- shard id / count +- resume URL +- guild routing metadata if needed +- last error / last disconnect reason + +Persist before any expensive or lossy downstream work so reconnects can resume +from durable state. + +## Downstream delivery + +A Discord gateway package usually needs a second delivery plane: + +- package app realtime sessions for human/operator UIs +- package exports or jobs for reusable downstream logic + +Keep gateway ingest, downstream formatting, and UI concerns separate so the +service can stay focused on connection lifecycle and event normalization. diff --git a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts index cc59ffc0c0..bc21821bf8 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts @@ -32,6 +32,11 @@ test('kody_official_guide returns markdown when fetch succeeds', async () => { } }) +test('kody_official_guide exposes the discord gateway package guide', () => { + const url = buildKodyOfficialGuideUrlForTest('discord_gateway_package') + expect(url).toMatch(/\/discord-gateway-package\.md$/) +}) + test('kody_official_guide surfaces fetch failures', async () => { const originalFetch = globalThis.fetch try { diff --git a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts index a350d21798..e8c8044eab 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts @@ -52,6 +52,12 @@ export const kodyOfficialGuideCatalog = { summary: 'Hosted /connect/secret URL shape, query params, and approval policy for API keys and PATs.', }, + discord_gateway_package: { + file: 'discord-gateway-package.md', + title: 'Discord gateway package guide', + summary: + 'Build a package-native Discord gateway using kody.services, package app realtime fanout, service-owned storage, background lifecycle helpers, and reconnect scheduling.', + }, } as const const guideIds = Object.keys(kodyOfficialGuideCatalog) as [ @@ -61,6 +67,7 @@ const guideIds = Object.keys(kodyOfficialGuideCatalog) as [ 'oauth', 'generated_ui_oauth', 'connect_secret', + 'discord_gateway_package', ] function buildRawGithubUrl(file: string): string { @@ -110,7 +117,7 @@ function buildCapabilityDescription(): string { return `- \`${id}\`: ${g.summary}` }) return [ - 'Load an official Kody guide from the kody GitHub repository (markdown). **For third-party integrations that will power a package, package app, or workflow, use `guide: "integration_bootstrap"` first.** For non-OAuth APIs backed by saved secrets, then use `guide: "secret_backed_integration"` as the default recipe. After the smoke test passes and you are ready to build a package app, use `guide: "integration_backed_app"` for the default package-app pattern. For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom package-app OAuth. For API keys/PATs, use `connect_secret` for secret collection. If you are unsure, **call this capability** with the right `guide` instead of guessing.', + 'Load an official Kody guide from the kody GitHub repository (markdown). **For third-party integrations that will power a package, package app, or workflow, use `guide: "integration_bootstrap"` first.** For non-OAuth APIs backed by saved secrets, then use `guide: "secret_backed_integration"` as the default recipe. After the smoke test passes and you are ready to build a package app, use `guide: "integration_backed_app"` for the default package-app pattern. For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom package-app OAuth. For API keys/PATs, use `connect_secret` for secret collection. For package-native Discord gateway work built on `kody.services`, use `discord_gateway_package`. If you are unsure, **call this capability** with the right `guide` instead of guessing.', '', 'Available guides (order matters—start with `integration_bootstrap` for integration-dependent work):', ...lines, @@ -130,6 +137,7 @@ const guideFieldSchema = z '`oauth`: standard third-party OAuth via /connect/oauth (read this first for OAuth).', '`generated_ui_oauth`: edge case—OAuth in a hosted package app.', '`connect_secret`: /connect/secret for API keys, PATs, and other secret collection steps.', + '`discord_gateway_package`: package-native Discord gateway architecture built on package services and package app realtime.', ].join(' '), ) @@ -175,6 +183,14 @@ const allKeywords = [ 'api key', 'personal access token', 'connect secret', + 'discord', + 'discord gateway', + 'gateway package', + 'package service', + 'background service', + 'heartbeat', + 'reconnect', + 'resume', 'credentials', 'official guide', 'documentation', From 1bf4480be60fab42bdc290f3a4fdb7abf90972ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:08:48 +0000 Subject: [PATCH 12/39] Keep service runtime helpers in module execution Co-authored-by: Kent C. Dodds --- packages/worker/src/mcp/run-codemode-registry.ts | 2 ++ packages/worker/src/package-runtime/package-service.ts | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index 043f9bc266..660539f2d1 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -269,6 +269,8 @@ export async function runCodemodeWithRegistry( executorExports: options?.executorExports, additionalTools: options?.additionalTools, storageTools: options?.storageTools, + serviceTools: options?.serviceTools, + executorTimeoutMs: options?.executorTimeoutMs, }) } const { createExecuteExecutor } = await import('#mcp/executor.ts') diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index b2885fc29d..958eb38df4 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -262,9 +262,7 @@ class PackageServiceInstanceBase extends DurableObject { storageId, }) if (this.stateSnapshot.currentRunId !== input.runId) return - this.stateSnapshot.status = this.stateSnapshot.stopRequested - ? 'stopped' - : 'stopped' + this.stateSnapshot.status = 'stopped' this.stateSnapshot.currentRunId = null this.stateSnapshot.stopRequested = false this.stateSnapshot.lastResult = result From a3a31bdcbe7a2e3cf048fe57eee81028dd799d54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:16:30 +0000 Subject: [PATCH 13/39] Polish package service runtime flow Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 958eb38df4..63120d154e 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -191,9 +191,14 @@ class PackageServiceInstanceBase extends DurableObject { } } - private async scheduleAlarm(input: { runAt: Date }) { - const scheduledAt = input.runAt.toISOString() - await this.ctx.storage.setAlarm(new Date(scheduledAt)) + private async scheduleAlarm(input: { runAt: Date | string }) { + const runAtDate = + typeof input.runAt === 'string' ? new Date(input.runAt) : input.runAt + if (Number.isNaN(runAtDate.getTime())) { + throw new Error('Invalid runAt value provided to setAlarm.') + } + const scheduledAt = runAtDate.toISOString() + await this.ctx.storage.setAlarm(runAtDate) this.stateSnapshot.nextAlarmAt = scheduledAt await this.persistState() return { @@ -258,6 +263,7 @@ class PackageServiceInstanceBase extends DurableObject { packageId: loaded.savedPackage.id, kodyId: loaded.savedPackage.kodyId, }, + loaded, executorTimeoutMs: this.stateSnapshot.timeoutMs ?? 300_000, storageId, }) @@ -300,6 +306,7 @@ class PackageServiceInstanceBase extends DurableObject { packageId: string kodyId: string } + loaded: Awaited> executorTimeoutMs: number storageId: string }, @@ -310,17 +317,13 @@ class PackageServiceInstanceBase extends DurableObject { import('./module-graph.ts'), import('./published-bundle-artifacts.ts'), ]) - const loaded = await loadSavedPackageService({ - env: this.env, - binding, - }) const artifact = await loadPublishedBundleArtifactByIdentity({ env: this.env, userId: binding.userId, sourceId: binding.sourceId, kind: 'service', artifactName: binding.serviceName, - entryPoint: loaded.serviceEntry, + entryPoint: runtime.loaded.serviceEntry, }) const bundle = artifact?.artifact ?? @@ -328,8 +331,8 @@ class PackageServiceInstanceBase extends DurableObject { env: this.env, baseUrl: binding.baseUrl, userId: binding.userId, - sourceFiles: loaded.packageSource.files, - entryPoint: loaded.serviceEntry, + sourceFiles: runtime.loaded.packageSource.files, + entryPoint: runtime.loaded.serviceEntry, })) const callerContext = createMcpCallerContext({ baseUrl: binding.baseUrl, @@ -462,9 +465,6 @@ class PackageServiceInstanceBase extends DurableObject { if (request.method === 'POST' && url.pathname.endsWith('/stop')) { return await this.handleStopRequest({ binding }) } - if (request.method === 'POST' && url.pathname.endsWith('/service-status')) { - return Response.json(this.buildServiceStatusResponse(binding)) - } return new Response('Not found', { status: 404 }) } From 02503bca99b2c6177ff5146b573762439649e7ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:25:16 +0000 Subject: [PATCH 14/39] Recover package services after DO restart Co-authored-by: Kent C. Dodds --- .../package-runtime/package-service.node.test.ts | 13 +++++++++++++ .../worker/src/package-runtime/package-service.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 41fbdef224..9471d6d2ec 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -12,3 +12,16 @@ test('buildPackageServiceStorageId creates stable per-service storage ids', () = 'service:package-1:a%3Ab%2Fc', ) }) + +test('package service runtime source clears stale in-flight state on restore', async () => { + const source = await import('./package-service.ts') + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain("this.stateSnapshot.status === 'running'") + expect(fileText).toContain("this.stateSnapshot.status === 'stopping'") + expect(fileText).toContain("this.stateSnapshot.status = 'stopped'") + expect(fileText).toContain('this.stateSnapshot.currentRunId = null') + expect(fileText).toContain('this.stateSnapshot.stopRequested = false') + expect(source.buildPackageServiceStorageId).toBeTypeOf('function') +}) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 63120d154e..654887b269 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -148,6 +148,19 @@ class PackageServiceInstanceBase extends DurableObject { await this.ctx.storage.get(serviceStateStorageKey) if (!stored) return this.stateSnapshot = stored + if ( + this.stateSnapshot.currentRunId && + (this.stateSnapshot.status === 'running' || + this.stateSnapshot.status === 'stopping') + ) { + // Background execution does not survive Durable Object eviction, so a + // restored in-flight run must be downgraded to a recoverable stopped + // state. + this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false + this.stateSnapshot.status = 'stopped' + this.stateSnapshot.lastStoppedAt = new Date().toISOString() + } } private async persistState() { From 9f66dedaa9fa7ca9b2d1b139fcd9cd3b9201831f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:35:53 +0000 Subject: [PATCH 15/39] Preserve explicit package service stops Co-authored-by: Kent C. Dodds --- .../package-runtime/package-service.node.test.ts | 14 ++++++++++++++ .../worker/src/package-runtime/package-service.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 9471d6d2ec..0d1c48ec03 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -25,3 +25,17 @@ test('package service runtime source clears stale in-flight state on restore', a expect(fileText).toContain('this.stateSnapshot.stopRequested = false') expect(source.buildPackageServiceStorageId).toBeTypeOf('function') }) + +test('package service runtime preserves explicit stop requests after a run ends', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain( + 'const stopRequested = this.stateSnapshot.stopRequested', + ) + expect(fileText).toContain( + "this.stateSnapshot.status = this.stateSnapshot.stopRequested\n\t\t\t\t? 'stopped'\n\t\t\t\t: 'error'", + ) + expect(fileText).toContain("if (stopRequested) {\n\t\t\t\tawait this.clearAlarm()") + expect(fileText).toContain('!this.stateSnapshot.stopRequested') +}) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 654887b269..c70a62e7f3 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -281,26 +281,32 @@ class PackageServiceInstanceBase extends DurableObject { storageId, }) if (this.stateSnapshot.currentRunId !== input.runId) return + const stopRequested = this.stateSnapshot.stopRequested this.stateSnapshot.status = 'stopped' this.stateSnapshot.currentRunId = null - this.stateSnapshot.stopRequested = false this.stateSnapshot.lastResult = result this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt await this.persistState() + if (stopRequested) { + await this.clearAlarm() + } } catch (error) { if (this.stateSnapshot.currentRunId !== input.runId) return const errorMessage = error instanceof Error ? error.message : String(error) + const stopRequested = this.stateSnapshot.stopRequested this.stateSnapshot.status = this.stateSnapshot.stopRequested ? 'stopped' : 'error' this.stateSnapshot.currentRunId = null - this.stateSnapshot.stopRequested = false this.stateSnapshot.lastError = errorMessage this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt await this.persistState() + if (stopRequested) { + await this.clearAlarm() + } } finally { if (this.activeRunPromise) { this.activeRunPromise = null From 7c6611ca4ccf4623bdd250fe8468dfc41788c243 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:49:03 +0000 Subject: [PATCH 16/39] Harden package service alarm behavior Co-authored-by: Kent C. Dodds --- .../package-registry/manifest.node.test.ts | 24 ++++ packages/worker/src/package-registry/types.ts | 2 +- .../package-service.node.test.ts | 17 +++ .../src/package-runtime/package-service.ts | 107 ++++++++++-------- 4 files changed, 103 insertions(+), 47 deletions(-) diff --git a/packages/worker/src/package-registry/manifest.node.test.ts b/packages/worker/src/package-registry/manifest.node.test.ts index 57e7ad7c9c..76d25b1f1d 100644 --- a/packages/worker/src/package-registry/manifest.node.test.ts +++ b/packages/worker/src/package-registry/manifest.node.test.ts @@ -90,3 +90,27 @@ test('parseAuthoredPackageJson accepts package service definitions', () => { }, }) }) + +test('parseAuthoredPackageJson rejects service timeoutMs values above the supported maximum', () => { + expect(() => + parseAuthoredPackageJson({ + content: JSON.stringify({ + name: '@kentcdodds/discord-gateway', + exports: { + '.': './index.ts', + }, + kody: { + id: 'discord-gateway', + description: 'Discord gateway package', + services: { + 'discord-gateway': { + entry: './services/discord-gateway.ts', + timeoutMs: 300001, + }, + }, + }, + }), + manifestPath: 'package.json', + }), + ).toThrow('expected number to be <=300000') +}) diff --git a/packages/worker/src/package-registry/types.ts b/packages/worker/src/package-registry/types.ts index 78351db3ba..afacc260a5 100644 --- a/packages/worker/src/package-registry/types.ts +++ b/packages/worker/src/package-registry/types.ts @@ -38,7 +38,7 @@ export type PackageAppDefinition = z.infer export const packageServiceDefinitionSchema = z.object({ entry: z.string().min(1), autoStart: z.boolean().optional(), - timeoutMs: z.number().int().positive().optional(), + timeoutMs: z.number().int().positive().max(300_000).optional(), }) export type PackageServiceDefinition = z.infer< diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 0d1c48ec03..41dbae013d 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -39,3 +39,20 @@ test('package service runtime preserves explicit stop requests after a run ends' expect(fileText).toContain("if (stopRequested) {\n\t\t\t\tawait this.clearAlarm()") expect(fileText).toContain('!this.stateSnapshot.stopRequested') }) + +test('package service runtime refreshes manifest-backed service settings for alarms and status', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain('const loaded = await loadSavedPackageService({') + expect(fileText).toContain('this.stateSnapshot.binding = loaded.resolvedBinding') + expect(fileText).toContain( + 'this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null', + ) + expect(fileText).toContain( + 'return Response.json(this.buildServiceStatusResponse(loaded.resolvedBinding))', + ) + expect(fileText).toContain( + 'if (!this.stateSnapshot.stopRequested) {\n\t\t\t\tawait this.handleStartRequest({ binding: loaded.resolvedBinding })', + ) +}) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index c70a62e7f3..d76b096955 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -114,8 +114,15 @@ async function loadSavedPackageService(input: { env: input.env, baseUrl: input.binding.baseUrl, userId: input.binding.userId, - sourceId: input.binding.sourceId, + sourceId: savedPackage.sourceId, }) + const resolvedBinding: PackageServiceBindingState = { + ...input.binding, + kodyId: savedPackage.kodyId, + sourceId: savedPackage.sourceId, + } + const serviceDefinition = + packageSource.manifest.kody.services?.[input.binding.serviceName] const serviceEntry = getPackageServiceEntryPath({ manifest: packageSource.manifest, serviceName: input.binding.serviceName, @@ -126,8 +133,10 @@ async function loadSavedPackageService(input: { ) } return { + resolvedBinding, savedPackage, packageSource, + serviceDefinition, serviceEntry, } } @@ -177,31 +186,21 @@ class PackageServiceInstanceBase extends DurableObject { ) { throw new Error('Package service instance binding mismatch.') } - if (!existing) { - this.stateSnapshot.binding = binding - try { - const loaded = await loadSavedPackageService({ - env: this.env, - binding, - }) - this.stateSnapshot.autoStart = - loaded.packageSource.manifest.kody.services?.[binding.serviceName] - ?.autoStart ?? false - this.stateSnapshot.timeoutMs = - loaded.packageSource.manifest.kody.services?.[binding.serviceName] - ?.timeoutMs ?? null - } catch { - this.stateSnapshot.autoStart = false - this.stateSnapshot.timeoutMs = null - } + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + this.stateSnapshot.binding = loaded.resolvedBinding + this.stateSnapshot.autoStart = loaded.serviceDefinition?.autoStart ?? false + this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null + await this.persistState() + if (!existing && this.stateSnapshot.autoStart) { + const nextAlarmAt = new Date().toISOString() + await this.ctx.storage.setAlarm(new Date(nextAlarmAt)) + this.stateSnapshot.nextAlarmAt = nextAlarmAt await this.persistState() - if (this.stateSnapshot.autoStart) { - const nextAlarmAt = new Date().toISOString() - await this.ctx.storage.setAlarm(new Date(nextAlarmAt)) - this.stateSnapshot.nextAlarmAt = nextAlarmAt - await this.persistState() - } } + return loaded } private async scheduleAlarm(input: { runAt: Date | string }) { @@ -263,8 +262,13 @@ class PackageServiceInstanceBase extends DurableObject { input.binding.packageId, input.binding.serviceName, ) - const result = await this.runService(input.binding, { - getStatus: async () => this.buildServiceStatusResponse(input.binding), + this.stateSnapshot.binding = loaded.resolvedBinding + this.stateSnapshot.autoStart = loaded.serviceDefinition?.autoStart ?? false + this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null + await this.persistState() + const result = await this.runService(loaded.resolvedBinding, { + getStatus: async () => + this.buildServiceStatusResponse(loaded.resolvedBinding), shouldStop: async () => this.stateSnapshot.stopRequested, setAlarm: async (runAt) => (await this.scheduleAlarm({ @@ -277,7 +281,7 @@ class PackageServiceInstanceBase extends DurableObject { kodyId: loaded.savedPackage.kodyId, }, loaded, - executorTimeoutMs: this.stateSnapshot.timeoutMs ?? 300_000, + executorTimeoutMs: loaded.serviceDefinition?.timeoutMs ?? 300_000, storageId, }) if (this.stateSnapshot.currentRunId !== input.runId) return @@ -412,7 +416,7 @@ class PackageServiceInstanceBase extends DurableObject { private async handleStartRequest(input: { binding: PackageServiceBindingState }) { - await this.initializeBinding(input.binding) + const loaded = await this.initializeBinding(input.binding) if (this.stateSnapshot.currentRunId) { return Response.json({ ok: true, @@ -431,7 +435,7 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.lastError = null await this.persistState() const task = this.runServiceInBackground({ - binding: input.binding, + binding: loaded.resolvedBinding, runId, }) this.activeRunPromise = task @@ -447,19 +451,22 @@ class PackageServiceInstanceBase extends DurableObject { private async handleStatusRequest(input: { binding: PackageServiceBindingState }) { - await this.initializeBinding(input.binding) - return Response.json(this.buildServiceStatusResponse(input.binding)) + const loaded = await this.initializeBinding(input.binding) + return Response.json(this.buildServiceStatusResponse(loaded.resolvedBinding)) } private async handleStopRequest(input: { binding: PackageServiceBindingState }) { await this.initializeBinding(input.binding) - this.stateSnapshot.stopRequested = true - this.stateSnapshot.status = this.stateSnapshot.currentRunId - ? 'stopping' - : 'stopped' - this.stateSnapshot.lastStoppedAt = new Date().toISOString() + if (this.stateSnapshot.currentRunId) { + this.stateSnapshot.stopRequested = true + this.stateSnapshot.status = 'stopping' + this.stateSnapshot.lastStoppedAt = new Date().toISOString() + } else { + this.stateSnapshot.stopRequested = false + this.stateSnapshot.status = 'stopped' + } await this.clearAlarm() return Response.json({ ok: true, @@ -493,16 +500,24 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.nextAlarmAt = null await this.persistState() if (this.stateSnapshot.currentRunId) return - const loaded = await loadSavedPackageService({ - env: this.env, - binding, - }) - if ( - loaded.packageSource.manifest.kody.services?.[binding.serviceName] - ?.autoStart && - !this.stateSnapshot.stopRequested - ) { - await this.handleStartRequest({ binding }) + try { + const loaded = await loadSavedPackageService({ + env: this.env, + binding, + }) + this.stateSnapshot.binding = loaded.resolvedBinding + this.stateSnapshot.autoStart = loaded.serviceDefinition?.autoStart ?? false + this.stateSnapshot.timeoutMs = + loaded.serviceDefinition?.timeoutMs ?? null + await this.persistState() + if (!this.stateSnapshot.stopRequested) { + await this.handleStartRequest({ binding: loaded.resolvedBinding }) + } + } catch (error) { + this.stateSnapshot.lastError = + error instanceof Error ? error.message : String(error) + this.stateSnapshot.status = 'error' + await this.persistState() } } } From f9c49d46faed69ca2df2a252d1282c199c7b3d84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 01:58:42 +0000 Subject: [PATCH 17/39] Polish package service RPC handling Co-authored-by: Kent C. Dodds --- .../package-service.node.test.ts | 12 +++++++++ .../src/package-runtime/package-service.ts | 27 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 41dbae013d..ad4808a53e 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -56,3 +56,15 @@ test('package service runtime refreshes manifest-backed service settings for ala 'if (!this.stateSnapshot.stopRequested) {\n\t\t\t\tawait this.handleStartRequest({ binding: loaded.resolvedBinding })', ) }) + +test('package service runtime persists restored state and surfaces RPC errors', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain('await this.persistState()') + expect(fileText).toContain('export async function readPackageServiceRpcResponse(') + expect(fileText).toContain('if (!response.ok) {') + expect(fileText).toContain( + 'text || `Package service request failed with status ${response.status}.`', + ) +}) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index d76b096955..5ed2fe1b54 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -141,6 +141,22 @@ async function loadSavedPackageService(input: { } } +export async function readPackageServiceRpcResponse( + response: Response, +): Promise { + const text = await response.text() + if (!response.ok) { + throw new Error( + text || `Package service request failed with status ${response.status}.`, + ) + } + try { + return JSON.parse(text) as T + } catch { + throw new Error('Package service returned an invalid JSON response.') + } +} + class PackageServiceInstanceBase extends DurableObject { private stateSnapshot: PackageServiceState = createInitialPackageServiceState() private activeRunPromise: Promise | null = null @@ -169,6 +185,7 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.stopRequested = false this.stateSnapshot.status = 'stopped' this.stateSnapshot.lastStoppedAt = new Date().toISOString() + await this.persistState() } } @@ -556,7 +573,9 @@ export function packageServiceRpc(input: { body: JSON.stringify({ binding }), }), ) - return await response.json() + return await readPackageServiceRpcResponse( + response, + ) }, async status() { const response = await stub.fetch( @@ -568,7 +587,9 @@ export function packageServiceRpc(input: { body: JSON.stringify({ binding }), }), ) - return await response.json() + return await readPackageServiceRpcResponse< + ReturnType + >(response) }, async stop() { const response = await stub.fetch( @@ -580,7 +601,7 @@ export function packageServiceRpc(input: { body: JSON.stringify({ binding }), }), ) - return await response.json() + return await readPackageServiceRpcResponse<{ ok: true }>(response) }, } } From 54641bc0a51bc06bbd1513510f7bc8984bae8a9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 02:10:34 +0000 Subject: [PATCH 18/39] Generalize package service examples Co-authored-by: Kent C. Dodds --- docs/guides/README.md | 2 +- ...-package.md => package-service-pattern.md} | 87 +++++++++---------- .../coding/kody-official-guide.node.test.ts | 6 +- .../coding/kody-official-guide.ts | 20 ++--- .../services/services-domain.node.test.ts | 22 ++--- .../mcp/run-codemode-registry.node.test.ts | 4 +- .../package-registry/manifest.node.test.ts | 24 ++--- .../package-service.node.test.ts | 4 +- 8 files changed, 84 insertions(+), 85 deletions(-) rename docs/guides/{discord-gateway-package.md => package-service-pattern.md} (61%) diff --git a/docs/guides/README.md b/docs/guides/README.md index e46e4b3634..52ada56626 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -10,7 +10,7 @@ available `guide` ids). | [integration-bootstrap.md](./integration-bootstrap.md) | **Start here** for third-party integrations that must work before saving a dependent package or package app | | [secret-backed-integration.md](./secret-backed-integration.md) | Default recipe for non-OAuth integrations that use one or more saved secrets | | [integration-backed-app-happy-path.md](./integration-backed-app-happy-path.md) | Default package app pattern after integration smoke test passes | -| [discord-gateway-package.md](./discord-gateway-package.md) | Package-service pattern for building a native Discord gateway inside Kody | +| [package-service-pattern.md](./package-service-pattern.md) | General package-service pattern for native long-lived runtimes inside Kody | | [oauth.md](./oauth.md) | **Start here** for third-party OAuth (`/connect/oauth`, redirect URI, params) | | [generated-ui-oauth.md](./generated-ui-oauth.md) | Edge case: OAuth in a hosted package app (`open_generated_ui` on a saved package) | | [connect-secret.md](./connect-secret.md) | `/connect/secret` URL parameters and policies | diff --git a/docs/guides/discord-gateway-package.md b/docs/guides/package-service-pattern.md similarity index 61% rename from docs/guides/discord-gateway-package.md rename to docs/guides/package-service-pattern.md index 7b52c0de7f..740d51e7ae 100644 --- a/docs/guides/discord-gateway-package.md +++ b/docs/guides/package-service-pattern.md @@ -1,42 +1,43 @@ -# Discord gateway package pattern +# Package service pattern -Use this guide when you want a **native Kody package** to own a Discord gateway -connection instead of relying on a separate external service. +Use this guide when you want a **native Kody package** to own a long-lived +runtime instead of relying on a separate external service. This guide assumes: - the package runtime already supports `package.json#kody.services` - the package will likely also expose a package app for operator UI -- Discord credentials and downstream delivery details are handled separately +- protocol credentials and downstream delivery details are handled separately ## Recommended package shape Prefer one saved package with: - `package.json#kody.app.entry` for setup UI, dashboards, and callback endpoints -- `package.json#kody.services.discord-gateway.entry` for the gateway runtime +- `package.json#kody.services.realtime-supervisor.entry` for the long-lived + runtime - package exports for reusable helpers and formatting logic Example manifest shape: ```json { - "name": "@scope/discord-gateway", + "name": "@scope/realtime-supervisor", "exports": { ".": "./src/index.ts", - "./format-dispatch": "./src/format-dispatch.ts" + "./format-event": "./src/format-event.ts" }, "kody": { - "id": "discord-gateway", - "description": "Native Discord gateway package", + "id": "realtime-supervisor", + "description": "Native long-lived service package", "app": { "entry": "./src/app.ts" }, "services": { - "discord-gateway": { - "entry": "./src/services/discord-gateway.ts", + "realtime-supervisor": { + "entry": "./src/services/realtime-supervisor.ts", "autoStart": true, - "timeoutMs": 600000 + "timeoutMs": 300000 } } } @@ -45,11 +46,11 @@ Example manifest shape: ## Runtime model -Package services now run as **background-managed** service instances: +Package services run as **background-managed** service instances: - `service_start` returns immediately with a running state -- the service Durable Object keeps lifecycle state such as - `status`, `active_run_id`, `stop_requested`, and `next_alarm_at` +- the service Durable Object keeps lifecycle state such as `status`, + `active_run_id`, `stop_requested`, and `next_alarm_at` - the service module runs with: - `packageContext` - `serviceContext` @@ -69,27 +70,26 @@ The `service` helper exposes: - `await service.setAlarm(runAt)` - `await service.clearAlarm()` -## Recommended Discord gateway loop +## Recommended service loop -Treat the service entry as the **gateway supervisor**, not just a one-shot job. +Treat the service entry as a **runtime supervisor**, not just a one-shot job. Recommended loop: 1. Load persisted session state from `storage` - - token metadata / config references - - Discord session id - - last sequence number - - resume URL - - shard id / shard count -2. Open the outbound Discord WebSocket -3. Identify or resume + - configuration references + - last checkpoint / cursor / offset + - reconnect endpoint or transport metadata + - topology metadata if applicable +2. Open the outbound connection or initialize the runtime session +3. Authenticate, subscribe, or resume 4. Enter a loop that: - - reads gateway events - - persists sequence/session updates before acting on them + - reads remote events or messages + - persists resumable state before acting on them - publishes normalized events to the package app or package exports - periodically checks `await service.shouldStop()` 5. On clean shutdown: - - close the socket + - close the connection - clear alarms when no reconnect is desired 6. On transient disconnect: - persist the latest resumable state @@ -102,11 +102,11 @@ Pseudo-code shape: import { service, storage } from 'kody:runtime' export default async function run() { - const session = (await storage.get('gateway-session')) ?? null - const socket = new WebSocket('wss://gateway.discord.gg/?v=10&encoding=json') + const session = (await storage.get('session-state')) ?? null + const socket = new WebSocket('wss://example.com/stream') try { - // Identify or resume here. + // Authenticate, subscribe, or resume here. while (socket.readyState === WebSocket.OPEN) { if (await service.shouldStop()) { @@ -115,9 +115,9 @@ export default async function run() { return { stopped: true } } - const event = await readNextDiscordEvent(socket) - await persistGatewayState(storage, event) - await handleGatewayEvent(event) + const event = await readNextEvent(socket) + await persistRuntimeState(storage, event) + await handleRuntimeEvent(event) } } catch (error) { await persistFailure(storage, error) @@ -129,14 +129,14 @@ export default async function run() { ## Use the package app as the operator plane -Do not push all gateway interaction into the service itself. +Do not push all operational interaction into the service itself. Use the package app for: -- guild / shard health dashboards +- health dashboards - reconnect / pause / resume controls - logs -- live dispatch inspection +- live event inspection - setup flows The package app can call the package service lifecycle surface and consume @@ -152,18 +152,17 @@ do **not** hibernate. Favor a design where the service can: - persist resumable state early - stop cooperatively -Use `timeoutMs` to give the gateway enough room to run, but do not rely on +Use `timeoutMs` to give the service enough room to run, but do not rely on indefinite execution as the only lifecycle mechanism. ## What to persist At minimum, persist: -- `session_id` -- latest sequence / `s` -- shard id / count -- resume URL -- guild routing metadata if needed +- reconnect/session identifiers +- latest offset / sequence / cursor +- topology metadata if needed +- reconnect URL or endpoint metadata - last error / last disconnect reason Persist before any expensive or lossy downstream work so reconnects can resume @@ -171,10 +170,10 @@ from durable state. ## Downstream delivery -A Discord gateway package usually needs a second delivery plane: +A long-lived package service usually needs a second delivery plane: - package app realtime sessions for human/operator UIs - package exports or jobs for reusable downstream logic -Keep gateway ingest, downstream formatting, and UI concerns separate so the +Keep connection ingest, downstream formatting, and UI concerns separate so the service can stay focused on connection lifecycle and event normalization. diff --git a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts index bc21821bf8..29baee25db 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.node.test.ts @@ -32,9 +32,9 @@ test('kody_official_guide returns markdown when fetch succeeds', async () => { } }) -test('kody_official_guide exposes the discord gateway package guide', () => { - const url = buildKodyOfficialGuideUrlForTest('discord_gateway_package') - expect(url).toMatch(/\/discord-gateway-package\.md$/) +test('kody_official_guide exposes the package service pattern guide', () => { + const url = buildKodyOfficialGuideUrlForTest('package_service_pattern') + expect(url).toMatch(/\/package-service-pattern\.md$/) }) test('kody_official_guide surfaces fetch failures', async () => { diff --git a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts index e8c8044eab..538c294575 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts @@ -52,11 +52,11 @@ export const kodyOfficialGuideCatalog = { summary: 'Hosted /connect/secret URL shape, query params, and approval policy for API keys and PATs.', }, - discord_gateway_package: { - file: 'discord-gateway-package.md', - title: 'Discord gateway package guide', + package_service_pattern: { + file: 'package-service-pattern.md', + title: 'Package service pattern guide', summary: - 'Build a package-native Discord gateway using kody.services, package app realtime fanout, service-owned storage, background lifecycle helpers, and reconnect scheduling.', + 'Build a package-native long-lived service using kody.services, package app realtime fanout, service-owned storage, background lifecycle helpers, and scheduled wake-ups.', }, } as const @@ -67,7 +67,7 @@ const guideIds = Object.keys(kodyOfficialGuideCatalog) as [ 'oauth', 'generated_ui_oauth', 'connect_secret', - 'discord_gateway_package', + 'package_service_pattern', ] function buildRawGithubUrl(file: string): string { @@ -117,7 +117,7 @@ function buildCapabilityDescription(): string { return `- \`${id}\`: ${g.summary}` }) return [ - 'Load an official Kody guide from the kody GitHub repository (markdown). **For third-party integrations that will power a package, package app, or workflow, use `guide: "integration_bootstrap"` first.** For non-OAuth APIs backed by saved secrets, then use `guide: "secret_backed_integration"` as the default recipe. After the smoke test passes and you are ready to build a package app, use `guide: "integration_backed_app"` for the default package-app pattern. For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom package-app OAuth. For API keys/PATs, use `connect_secret` for secret collection. For package-native Discord gateway work built on `kody.services`, use `discord_gateway_package`. If you are unsure, **call this capability** with the right `guide` instead of guessing.', + 'Load an official Kody guide from the kody GitHub repository (markdown). **For third-party integrations that will power a package, package app, or workflow, use `guide: "integration_bootstrap"` first.** For non-OAuth APIs backed by saved secrets, then use `guide: "secret_backed_integration"` as the default recipe. After the smoke test passes and you are ready to build a package app, use `guide: "integration_backed_app"` for the default package-app pattern. For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom package-app OAuth. For API keys/PATs, use `connect_secret` for secret collection. For package-native long-lived service work built on `kody.services`, use `package_service_pattern`. If you are unsure, **call this capability** with the right `guide` instead of guessing.', '', 'Available guides (order matters—start with `integration_bootstrap` for integration-dependent work):', ...lines, @@ -137,7 +137,7 @@ const guideFieldSchema = z '`oauth`: standard third-party OAuth via /connect/oauth (read this first for OAuth).', '`generated_ui_oauth`: edge case—OAuth in a hosted package app.', '`connect_secret`: /connect/secret for API keys, PATs, and other secret collection steps.', - '`discord_gateway_package`: package-native Discord gateway architecture built on package services and package app realtime.', + '`package_service_pattern`: package-native long-lived service architecture built on package services and package app realtime.', ].join(' '), ) @@ -183,11 +183,11 @@ const allKeywords = [ 'api key', 'personal access token', 'connect secret', - 'discord', - 'discord gateway', - 'gateway package', 'package service', + 'service package', 'background service', + 'long-lived service', + 'outbound websocket', 'heartbeat', 'reconnect', 'resume', diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index 3b024c9e5c..ebf4679cee 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -80,8 +80,8 @@ test('service_list returns declared package services with live status', async () }, services: [ { - name: 'discord-gateway', - entry: 'services/discord-gateway.ts', + name: 'realtime-supervisor', + entry: 'services/realtime-supervisor.ts', autoStart: true, timeoutMs: 300000, }, @@ -90,7 +90,7 @@ test('service_list returns declared package services with live status', async () status: async () => ({ package_id: 'package-123', kody_id: 'example', - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', status: 'stopped', auto_start: true, timeout_ms: 300000, @@ -109,7 +109,7 @@ test('service_list returns declared package services with live status', async () status: async () => ({ package_id: 'package-123', kody_id: 'example', - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', status: 'stopped', auto_start: true, timeout_ms: 300000, @@ -138,8 +138,8 @@ test('service_list returns declared package services with live status', async () kody_id: 'example', services: [ { - name: 'discord-gateway', - entry: 'services/discord-gateway.ts', + name: 'realtime-supervisor', + entry: 'services/realtime-supervisor.ts', auto_start: true, status: 'stopped', timeout_ms: 300000, @@ -167,7 +167,7 @@ test('service_get, service_start, and service_stop delegate to package service R status: async () => ({ package_id: 'package-123', kody_id: 'example', - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', status: 'running', auto_start: false, timeout_ms: 300000, @@ -199,7 +199,7 @@ test('service_get, service_start, and service_stop delegate to package service R await expect( serviceGetCapability.handler( { - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', }, { env, @@ -207,7 +207,7 @@ test('service_get, service_start, and service_stop delegate to package service R }, ), ).resolves.toMatchObject({ - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', status: 'running', active_run_id: 'run-123', }) @@ -215,7 +215,7 @@ test('service_get, service_start, and service_stop delegate to package service R await expect( serviceStartCapability.handler( { - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', }, { env, @@ -232,7 +232,7 @@ test('service_get, service_start, and service_stop delegate to package service R await expect( serviceStopCapability.handler( { - service_name: 'discord-gateway', + service_name: 'realtime-supervisor', }, { env, 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 613d30e8e5..ebfed18afb 100644 --- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts +++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts @@ -951,7 +951,7 @@ test('runBundledModuleWithRegistry injects service helpers and custom timeout', undefined, { serviceContext: { - serviceName: 'discord-gateway', + serviceName: 'realtime-supervisor', }, serviceTools: { getStatus: async () => ({ status: 'running' }), @@ -969,7 +969,7 @@ test('runBundledModuleWithRegistry injects service helpers and custom timeout', expect(wrapped).toContain("service_should_stop") expect(wrapped).toContain("service_set_alarm") expect(wrapped).toContain("service_clear_alarm") - expect(wrapped).toContain('"serviceName":"discord-gateway"') + expect(wrapped).toContain('"serviceName":"realtime-supervisor"') } finally { createExecuteExecutorSpy.mockRestore() getRegistrySpy.mockRestore() diff --git a/packages/worker/src/package-registry/manifest.node.test.ts b/packages/worker/src/package-registry/manifest.node.test.ts index 76d25b1f1d..4bab2feef4 100644 --- a/packages/worker/src/package-registry/manifest.node.test.ts +++ b/packages/worker/src/package-registry/manifest.node.test.ts @@ -63,16 +63,16 @@ test('parseAuthoredPackageJson rejects unscoped package names', () => { test('parseAuthoredPackageJson accepts package service definitions', () => { const manifest = parseAuthoredPackageJson({ content: JSON.stringify({ - name: '@kentcdodds/discord-gateway', + name: '@kentcdodds/realtime-supervisor', exports: { '.': './index.ts', }, kody: { - id: 'discord-gateway', - description: 'Discord gateway package', + id: 'realtime-supervisor', + description: 'Realtime supervisor package', services: { - 'discord-gateway': { - entry: './services/discord-gateway.ts', + 'realtime-supervisor': { + entry: './services/realtime-supervisor.ts', autoStart: true, timeoutMs: 300000, }, @@ -83,8 +83,8 @@ test('parseAuthoredPackageJson accepts package service definitions', () => { }) expect(manifest.kody.services).toEqual({ - 'discord-gateway': { - entry: './services/discord-gateway.ts', + 'realtime-supervisor': { + entry: './services/realtime-supervisor.ts', autoStart: true, timeoutMs: 300000, }, @@ -95,16 +95,16 @@ test('parseAuthoredPackageJson rejects service timeoutMs values above the suppor expect(() => parseAuthoredPackageJson({ content: JSON.stringify({ - name: '@kentcdodds/discord-gateway', + name: '@kentcdodds/realtime-supervisor', exports: { '.': './index.ts', }, kody: { - id: 'discord-gateway', - description: 'Discord gateway package', + id: 'realtime-supervisor', + description: 'Realtime supervisor package', services: { - 'discord-gateway': { - entry: './services/discord-gateway.ts', + 'realtime-supervisor': { + entry: './services/realtime-supervisor.ts', timeoutMs: 300001, }, }, diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index ad4808a53e..e84673d018 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest' import { buildPackageServiceStorageId } from './package-service.ts' test('buildPackageServiceStorageId creates stable per-service storage ids', () => { - expect(buildPackageServiceStorageId('package-1', 'discord-gateway')).toBe( - 'service:package-1:discord-gateway', + expect(buildPackageServiceStorageId('package-1', 'realtime-supervisor')).toBe( + 'service:package-1:realtime-supervisor', ) expect(buildPackageServiceStorageId('package-1', 'guild sync')).toBe( 'service:package-1:guild%20sync', From 47900028d2de28f753bcd29bc0135fc3bea6b12b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 02:22:05 +0000 Subject: [PATCH 19/39] Tighten package service helper semantics Co-authored-by: Kent C. Dodds --- .../coding/kody-official-guide.ts | 19 +++--- .../mcp/capabilities/services/service-list.ts | 3 +- .../services/services-domain.node.test.ts | 60 +++++++++++++++++++ .../src/mcp/capabilities/services/shared.ts | 4 +- .../worker/src/mcp/run-codemode-registry.ts | 11 ++-- .../worker/src/mcp/tools/search.node.test.ts | 6 ++ .../worker/src/package-registry/manifest.ts | 4 +- .../worker/src/package-runtime/package-app.ts | 4 +- .../src/package-runtime/package-service.ts | 49 +++++---------- 9 files changed, 105 insertions(+), 55 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts index 538c294575..c6529001e2 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts @@ -60,15 +60,9 @@ export const kodyOfficialGuideCatalog = { }, } as const -const guideIds = Object.keys(kodyOfficialGuideCatalog) as [ - 'integration_bootstrap', - 'secret_backed_integration', - 'integration_backed_app', - 'oauth', - 'generated_ui_oauth', - 'connect_secret', - 'package_service_pattern', -] +const guideIds = Object.keys( + kodyOfficialGuideCatalog, +) as Array function buildRawGithubUrl(file: string): string { const { owner, repo, ref, basePath } = KODY_GUIDES_REPO @@ -127,7 +121,12 @@ function buildCapabilityDescription(): string { } const guideFieldSchema = z - .enum(guideIds) + .enum( + guideIds as [ + KodyOfficialGuideId, + ...Array, + ], + ) .describe( [ 'Which guide to load.', diff --git a/packages/worker/src/mcp/capabilities/services/service-list.ts b/packages/worker/src/mcp/capabilities/services/service-list.ts index cf2d3a21d0..e6868cfbb5 100644 --- a/packages/worker/src/mcp/capabilities/services/service-list.ts +++ b/packages/worker/src/mcp/capabilities/services/service-list.ts @@ -37,7 +37,8 @@ export const serviceListCapability = defineDomainCapability( }) const services = await Promise.all( listed.services.map(async (service) => { - let status: z.infer['status'] = 'error' + let status: z.infer['status'] = + 'unknown' try { status = normalizePackageServiceStatus( await listed.rpc(service.name).status(), diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index ebf4679cee..aacc07611f 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -148,6 +148,66 @@ test('service_list returns declared package services with live status', async () }) }) +test('service_list marks status as unknown when a service status lookup fails', async () => { + resetMocks() + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-123', + userId: 'user-123', + name: '@scope/example', + kodyId: 'example', + description: 'Example package', + tags: [], + searchText: null, + sourceId: 'source-123', + hasApp: true, + createdAt: '2026-04-24T00:00:00.000Z', + updatedAt: '2026-04-24T00:00:00.000Z', + }) + mockModule.listSavedPackageServices.mockResolvedValue({ + savedPackage: { + id: 'package-123', + kodyId: 'example', + }, + services: [ + { + name: 'realtime-supervisor', + entry: 'services/realtime-supervisor.ts', + autoStart: false, + timeoutMs: null, + }, + ], + rpc: () => ({ + status: async () => { + throw new Error('worker unavailable') + }, + }), + }) + + const result = await serviceListCapability.handler( + {}, + { + env: { + APP_DB: {} as D1Database, + } as Env, + callerContext: createCallerContext(), + }, + ) + + expect(result).toEqual({ + package_id: 'package-123', + kody_id: 'example', + services: [ + { + name: 'realtime-supervisor', + entry: 'services/realtime-supervisor.ts', + auto_start: false, + status: 'unknown', + timeout_ms: null, + }, + ], + }) +}) + test('service_get, service_start, and service_stop delegate to package service RPC', async () => { resetMocks() mockModule.getSavedPackageById.mockResolvedValue({ diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index 6735f5ca1e..9cfcc0b187 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -11,11 +11,11 @@ export const packageServiceRecordSchema = z.object({ name: z.string(), entry: z.string(), auto_start: z.boolean(), - timeout_ms: z.number().int().positive().nullable().optional(), + timeout_ms: z.number().int().positive().nullable(), }) export const packageServiceSummarySchema = packageServiceRecordSchema.extend({ - status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), + status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error', 'unknown']), }) export const packageServiceStatusSchema = z.object({ diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index 660539f2d1..20d5e71efa 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -132,12 +132,13 @@ export async function buildCodemodeFns( }) : {} assertNoCapabilityCollisions(capabilityMap, storageCodemodeTools) - const serviceCodemodeTools: AdditionalCodemodeTools = options?.serviceTools + const serviceTools = options?.serviceTools + const serviceCodemodeTools: AdditionalCodemodeTools = serviceTools ? { service_get_status: async () => - await options.serviceTools?.getStatus(), + await serviceTools.getStatus(), service_should_stop: async () => ({ - shouldStop: await options.serviceTools?.shouldStop(), + shouldStop: await serviceTools.shouldStop(), }), service_set_alarm: async (args: unknown) => { const payload = @@ -154,10 +155,10 @@ export async function buildCodemodeFns( if (Number.isNaN(runAt.getTime())) { throw new Error('service.setAlarm requires a valid runAt ISO string.') } - return await options.serviceTools?.setAlarm(runAt) + return await serviceTools.setAlarm(runAt) }, service_clear_alarm: async () => - await options.serviceTools?.clearAlarm(), + await serviceTools.clearAlarm(), } : {} assertNoCapabilityCollisions(capabilityMap, serviceCodemodeTools) diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 9bf08fa616..677656219b 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -59,6 +59,7 @@ test('searchUnified ranks mixed search rows through one shared pipeline', () => appEntry: null, exports: [], jobs: [], + services: [], }, }, ] @@ -227,6 +228,7 @@ test('optional search rows include saved packages when lookup succeeds', async ( appEntry: 'src/app.ts', exports: ['.'], jobs: [], + services: [], }, }, ], @@ -269,6 +271,7 @@ test('optional search rows preserve package fallback warnings', async () => { appEntry: null, exports: [], jobs: [], + services: [], }, }, ], @@ -318,6 +321,7 @@ test('searchUnified uses package exports and connector aliases for operate queri './playback-controller', ], jobs: [], + services: [], }, }, ], @@ -399,6 +403,7 @@ test('buildSavedPackageSearchRows falls back when package source resolution fail appEntry: null, exports: [], jobs: [], + services: [], }), }), ]) @@ -438,6 +443,7 @@ test('search guidance does not pair unrelated package and connector matches', () appEntry: 'src/app.ts', exports: ['./play'], jobs: [], + services: [], }, }, ], diff --git a/packages/worker/src/package-registry/manifest.ts b/packages/worker/src/package-registry/manifest.ts index 5646682cd5..8d0e704bb0 100644 --- a/packages/worker/src/package-registry/manifest.ts +++ b/packages/worker/src/package-registry/manifest.ts @@ -196,12 +196,12 @@ export function buildPackageSearchProjection( } export function buildPackageSearchDocument(projection: PackageSearchProjection) { - const jobLines = (projection.jobs ?? []).map((job) => + const jobLines = projection.jobs.map((job) => [job.name, job.entry, job.schedule, job.enabled ? 'enabled' : 'disabled'] .filter((value) => value.length > 0) .join(' '), ) - const serviceLines = (projection.services ?? []).map((service) => + const serviceLines = projection.services.map((service) => [ service.name, service.entry, diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index bef9cd21a0..fa2f725942 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -710,7 +710,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< }) const services = await Promise.all( result.services.map(async (service) => { - let status: string = 'error' + let status = 'unknown' try { const serviceStatus = await this.getPackageServiceRpc( service.name, @@ -721,7 +721,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< 'status' in serviceStatus && typeof serviceStatus.status === 'string' ? serviceStatus.status - : 'error' + : 'unknown' } catch { // Keep the rest of the service list usable if one status lookup fails. } diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 5ed2fe1b54..2503d2b339 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -562,46 +562,29 @@ export function packageServiceRpc(input: { serviceName: input.serviceName, } const stub = getPackageServiceStub(input) + async function callService(path: string): Promise { + const response = await stub.fetch( + new Request(`https://package-service.invalid${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ binding }), + }), + ) + return await readPackageServiceRpcResponse(response) + } return { async start() { - const response = await stub.fetch( - new Request('https://package-service.invalid/service/start', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ binding }), - }), - ) - return await readPackageServiceRpcResponse( - response, - ) + return await callService('/service/start') }, async status() { - const response = await stub.fetch( - new Request('https://package-service.invalid/service/status', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ binding }), - }), - ) - return await readPackageServiceRpcResponse< + return await callService< ReturnType - >(response) + >('/service/status') }, async stop() { - const response = await stub.fetch( - new Request('https://package-service.invalid/service/stop', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ binding }), - }), - ) - return await readPackageServiceRpcResponse<{ ok: true }>(response) + return await callService<{ ok: true }>('/service/stop') }, } } From 5428196efa7541504ba64be30985f7f3a1446a64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 02:27:19 +0000 Subject: [PATCH 20/39] Retry MCP test server port binding Co-authored-by: Kent C. Dodds --- tools/mcp-test-support.ts | 198 +++++++++++++++++++++----------------- 1 file changed, 108 insertions(+), 90 deletions(-) diff --git a/tools/mcp-test-support.ts b/tools/mcp-test-support.ts index 2d9bf0cc2b..47b6ccafaa 100644 --- a/tools/mcp-test-support.ts +++ b/tools/mcp-test-support.ts @@ -18,6 +18,7 @@ const primaryUserEmail = 'me@kentcdodds.com' const testUserPassword = 'secret' const localhost = '127.0.0.1' const defaultWaitTimeoutMs = process.env.CI ? 60_000 : 45_000 +const maxPortBindRetries = 5 type TestUser = { email: string @@ -57,99 +58,116 @@ export async function createTestDatabase() { export async function startDevServer(persistDir: string) { await applyMigrations(persistDir) - const mockCloudflarePort = await getPort({ host: localhost }) - const mockCloudflareOrigin = `http://${localhost}:${mockCloudflarePort}` - const mockCloudflareToken = `mock-cloudflare-${crypto.randomUUID()}` - const mockCloudflareProc = spawnProcess({ - cmd: [ - nodeBin, - '--env-file=packages/worker/.env', - './wrangler-env.ts', - 'dev', - '--local', - '--config', - 'packages/mock-servers/cloudflare/wrangler.jsonc', - '--port', - String(mockCloudflarePort), - '--ip', - localhost, - '--show-interactive-dev-session=false', - '--log-level', - 'error', - '--var', - `MOCK_API_TOKEN:${mockCloudflareToken}`, - ], - cwd: projectRoot, - env: { - ...process.env, - CLOUDFLARE_ENV: 'test', - }, - }) - const getMockCloudflareStdout = captureOutput(mockCloudflareProc.stdout) - const getMockCloudflareStderr = captureOutput(mockCloudflareProc.stderr) - - try { - await waitForUrlReady( - new URL('/__mocks/meta', mockCloudflareOrigin), - mockCloudflareProc.exited, - getMockCloudflareStdout, - getMockCloudflareStderr, - ) - } catch (error) { - await stopProcess(mockCloudflareProc).catch(() => undefined) - throw error - } - const port = await getPort({ host: localhost }) - const origin = `http://${localhost}:${port}` - const proc = spawnProcess({ - cmd: [ - nodeBin, - '--env-file=packages/worker/.env', - './wrangler-env.ts', - 'dev', - '--local', - '--persist-to', - persistDir, - '--port', - String(port), - '--ip', - localhost, - '--show-interactive-dev-session=false', - '--log-level', - 'error', - '--var', - `CLOUDFLARE_API_BASE_URL:${mockCloudflareOrigin}`, - '--var', - `CLOUDFLARE_API_TOKEN:${mockCloudflareToken}`, - '--var', - 'CLOUDFLARE_ACCOUNT_ID:cf_account_mock_123', - ], - cwd: projectRoot, - env: { - ...process.env, - CLOUDFLARE_ENV: 'test', - }, - }) - const getStdout = captureOutput(proc.stdout) - const getStderr = captureOutput(proc.stderr) + for (let attempt = 1; attempt <= maxPortBindRetries; attempt++) { + const mockCloudflarePort = await getPort({ host: localhost }) + const mockCloudflareOrigin = `http://${localhost}:${mockCloudflarePort}` + const mockCloudflareToken = `mock-cloudflare-${crypto.randomUUID()}` + const mockCloudflareProc = spawnProcess({ + cmd: [ + nodeBin, + '--env-file=packages/worker/.env', + './wrangler-env.ts', + 'dev', + '--local', + '--config', + 'packages/mock-servers/cloudflare/wrangler.jsonc', + '--port', + String(mockCloudflarePort), + '--ip', + localhost, + '--show-interactive-dev-session=false', + '--log-level', + 'error', + '--var', + `MOCK_API_TOKEN:${mockCloudflareToken}`, + ], + cwd: projectRoot, + env: { + ...process.env, + CLOUDFLARE_ENV: 'test', + }, + }) + const getMockCloudflareStdout = captureOutput(mockCloudflareProc.stdout) + const getMockCloudflareStderr = captureOutput(mockCloudflareProc.stderr) - try { - await waitForServerReady(origin, proc.exited, getStdout, getStderr) - } catch (error) { - await stopProcess(mockCloudflareProc).catch(() => undefined) - await stopProcess(proc).catch(() => undefined) - throw error - } + try { + await waitForUrlReady( + new URL('/__mocks/meta', mockCloudflareOrigin), + mockCloudflareProc.exited, + getMockCloudflareStdout, + getMockCloudflareStderr, + ) + } catch (error) { + await stopProcess(mockCloudflareProc).catch(() => undefined) + if (isPortAlreadyInUseError(error) && attempt < maxPortBindRetries) { + continue + } + throw error + } - return { - origin, - async [Symbol.asyncDispose]() { - await Promise.allSettled([ - stopProcess(proc), - stopProcess(mockCloudflareProc), - ]) - }, + const port = await getPort({ host: localhost }) + const origin = `http://${localhost}:${port}` + const proc = spawnProcess({ + cmd: [ + nodeBin, + '--env-file=packages/worker/.env', + './wrangler-env.ts', + 'dev', + '--local', + '--persist-to', + persistDir, + '--port', + String(port), + '--ip', + localhost, + '--show-interactive-dev-session=false', + '--log-level', + 'error', + '--var', + `CLOUDFLARE_API_BASE_URL:${mockCloudflareOrigin}`, + '--var', + `CLOUDFLARE_API_TOKEN:${mockCloudflareToken}`, + '--var', + 'CLOUDFLARE_ACCOUNT_ID:cf_account_mock_123', + ], + cwd: projectRoot, + env: { + ...process.env, + CLOUDFLARE_ENV: 'test', + }, + }) + const getStdout = captureOutput(proc.stdout) + const getStderr = captureOutput(proc.stderr) + + try { + await waitForServerReady(origin, proc.exited, getStdout, getStderr) + return { + origin, + async [Symbol.asyncDispose]() { + await Promise.allSettled([ + stopProcess(proc), + stopProcess(mockCloudflareProc), + ]) + }, + } + } catch (error) { + await stopProcess(mockCloudflareProc).catch(() => undefined) + await stopProcess(proc).catch(() => undefined) + if (isPortAlreadyInUseError(error) && attempt < maxPortBindRetries) { + continue + } + throw error + } } + + throw new Error('Failed to start MCP test dev servers after multiple retries.') +} + +function isPortAlreadyInUseError(error: unknown) { + return ( + error instanceof Error && + error.message.includes('Address already in use') + ) } export async function loginToApp(origin: string, user: TestUser) { From fdf5f67f66f434485a67f937b39d85c27afb4913 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 02:39:28 +0000 Subject: [PATCH 21/39] Clear stale package service stop requests Co-authored-by: Kent C. Dodds --- .../worker/src/package-runtime/package-service.node.test.ts | 3 +++ packages/worker/src/package-runtime/package-service.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index e84673d018..05c9302fc5 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -33,6 +33,9 @@ test('package service runtime preserves explicit stop requests after a run ends' expect(fileText).toContain( 'const stopRequested = this.stateSnapshot.stopRequested', ) + expect(fileText).toContain( + 'this.stateSnapshot.stopRequested = false', + ) expect(fileText).toContain( "this.stateSnapshot.status = this.stateSnapshot.stopRequested\n\t\t\t\t? 'stopped'\n\t\t\t\t: 'error'", ) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 2503d2b339..170c419cfe 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -305,6 +305,7 @@ class PackageServiceInstanceBase extends DurableObject { const stopRequested = this.stateSnapshot.stopRequested this.stateSnapshot.status = 'stopped' this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false this.stateSnapshot.lastResult = result this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt @@ -321,6 +322,7 @@ class PackageServiceInstanceBase extends DurableObject { ? 'stopped' : 'error' this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false this.stateSnapshot.lastError = errorMessage this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt From 55301dfd00291e95e686f0667eb0197701ce7c7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:15:26 +0000 Subject: [PATCH 22/39] Refine package service lifecycle edges Co-authored-by: Kent C. Dodds --- docs/guides/package-service-pattern.md | 18 ++- .../src/package-registry/service.node.test.ts | 22 +++ .../worker/src/package-registry/service.ts | 16 +++ .../package-service.node.test.ts | 20 ++- .../src/package-runtime/package-service.ts | 134 ++++++++++++++---- tools/mcp-test-support.ts | 12 +- 6 files changed, 192 insertions(+), 30 deletions(-) diff --git a/docs/guides/package-service-pattern.md b/docs/guides/package-service-pattern.md index 740d51e7ae..87adfa9428 100644 --- a/docs/guides/package-service-pattern.md +++ b/docs/guides/package-service-pattern.md @@ -101,14 +101,28 @@ Pseudo-code shape: ```ts import { service, storage } from 'kody:runtime' +async function openSocket(session: unknown) { + void session + return await new Promise((resolve, reject) => { + const socket = new WebSocket('wss://example.com/stream') + socket.addEventListener('open', () => resolve(socket), { once: true }) + socket.addEventListener('error', () => reject(new Error('Failed to open stream')), { + once: true, + }) + }) +} + export default async function run() { const session = (await storage.get('session-state')) ?? null - const socket = new WebSocket('wss://example.com/stream') + const socket = await openSocket(session) try { // Authenticate, subscribe, or resume here. - while (socket.readyState === WebSocket.OPEN) { + while ( + socket.readyState !== WebSocket.CLOSING && + socket.readyState !== WebSocket.CLOSED + ) { if (await service.shouldStop()) { socket.close(1000, 'service stop requested') await service.clearAlarm() diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 704fb73abc..68d809d3c5 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -1,5 +1,19 @@ import { beforeEach, expect, test, vi } from 'vitest' +function mockPackageServiceNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId + }, + get(id: DurableObjectId) { + return { + fetch: vi.fn(async () => new Response(JSON.stringify({ ok: true }))), + id, + } as unknown as DurableObjectStub + }, + } as DurableObjectNamespace +} + const mockModule = vi.hoisted(() => ({ buildPackageSearchProjection: vi.fn(), buildSavedPackageEmbedText: vi.fn(), @@ -82,6 +96,7 @@ const { deleteSavedPackageProjection, refreshSavedPackageProjection } = function createEnv() { return { APP_DB: {}, + PACKAGE_SERVICE_INSTANCE: mockPackageServiceNamespace(), } as Env } @@ -122,6 +137,12 @@ test('refreshSavedPackageProjection resyncs the job manager after syncing packag description: 'Shade automation package', tags: ['home', 'shades'], searchText: 'shade automation', + services: { + 'realtime-supervisor': { + entry: './src/services/realtime-supervisor.ts', + autoStart: true, + }, + }, jobs: { 'event-runner': { entry: './src/jobs/event-runner.ts', @@ -197,6 +218,7 @@ test('refreshSavedPackageProjection resyncs the job manager after syncing packag env, userId: 'user-1', }) + expect(mockModule.getSavedPackageById).toHaveBeenCalledTimes(1) expect( mockModule.syncJobManagerAlarm.mock.invocationCallOrder[0], ).toBeGreaterThan( diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index 4529516958..d7e67296d8 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -21,6 +21,7 @@ import { syncJobManagerAlarm } from '#worker/jobs/manager-client.ts' import { rebuildPublishedPackageArtifacts, } from '#worker/package-runtime/published-bundle-artifacts.ts' +import { packageServiceRpc } from '#worker/package-runtime/package-service.ts' function serializeTags(tags: Array) { return JSON.stringify(tags) @@ -144,6 +145,21 @@ export async function refreshSavedPackageProjection(input: { sourceId: input.sourceId, manifest: loaded.manifest, }) + for (const service of loaded.manifest.kody.services + ? Object.keys(loaded.manifest.kody.services) + : []) { + const definition = loaded.manifest.kody.services?.[service] + if (!definition?.autoStart) continue + await packageServiceRpc({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + kodyId: row.kody_id, + sourceId: row.source_id, + baseUrl: input.baseUrl, + serviceName: service, + }).start() + } await syncJobManagerAlarm({ env: input.env, userId: input.userId, diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 05c9302fc5..08a6e03ea1 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -53,13 +53,31 @@ test('package service runtime refreshes manifest-backed service settings for ala 'this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null', ) expect(fileText).toContain( - 'return Response.json(this.buildServiceStatusResponse(loaded.resolvedBinding))', + 'const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding', ) expect(fileText).toContain( + 'loaded = await this.initializeBinding(input.binding, {', + ) + expect(fileText).toContain('this.buildServiceStatusResponse(') + expect(fileText).toContain('binding,') + expect(fileText).toContain('loaded?.serviceDefinition') + expect(fileText).not.toContain( 'if (!this.stateSnapshot.stopRequested) {\n\t\t\t\tawait this.handleStartRequest({ binding: loaded.resolvedBinding })', ) }) +test('package service runtime schedules auto-start on save path instead of read-only status calls', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain('options?: { armAutoStart?: boolean }') + expect(fileText).toContain('options?.armAutoStart') + expect(fileText).toContain('armAutoStart: true') + expect(fileText).toContain( + 'const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding', + ) +}) + test('package service runtime persists restored state and surfaces RPC errors', async () => { const fileText = await import('node:fs/promises').then((fs) => fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 170c419cfe..4d3fa7d771 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -157,6 +157,22 @@ export async function readPackageServiceRpcResponse( } } +export async function schedulePackageServiceAutoStart(input: { + env: Env + binding: PackageServiceBindingState +}) { + const rpc = packageServiceRpc({ + env: input.env, + userId: input.binding.userId, + packageId: input.binding.packageId, + kodyId: input.binding.kodyId, + sourceId: input.binding.sourceId, + baseUrl: input.binding.baseUrl, + serviceName: input.binding.serviceName, + }) + await rpc.start() +} + class PackageServiceInstanceBase extends DurableObject { private stateSnapshot: PackageServiceState = createInitialPackageServiceState() private activeRunPromise: Promise | null = null @@ -181,11 +197,19 @@ class PackageServiceInstanceBase extends DurableObject { // Background execution does not survive Durable Object eviction, so a // restored in-flight run must be downgraded to a recoverable stopped // state. + const wasExplicitlyStopping = this.stateSnapshot.status === 'stopping' this.stateSnapshot.currentRunId = null - this.stateSnapshot.stopRequested = false + this.stateSnapshot.stopRequested = wasExplicitlyStopping this.stateSnapshot.status = 'stopped' this.stateSnapshot.lastStoppedAt = new Date().toISOString() await this.persistState() + if ( + this.stateSnapshot.autoStart && + !this.stateSnapshot.stopRequested && + this.stateSnapshot.binding + ) { + await this.scheduleAlarm({ runAt: new Date() }) + } } } @@ -193,7 +217,10 @@ class PackageServiceInstanceBase extends DurableObject { await this.ctx.storage.put(serviceStateStorageKey, this.stateSnapshot) } - private async initializeBinding(binding: PackageServiceBindingState) { + private async initializeBinding( + binding: PackageServiceBindingState, + options?: { armAutoStart?: boolean }, + ) { const existing = this.stateSnapshot.binding if ( existing && @@ -211,11 +238,13 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.autoStart = loaded.serviceDefinition?.autoStart ?? false this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null await this.persistState() - if (!existing && this.stateSnapshot.autoStart) { - const nextAlarmAt = new Date().toISOString() - await this.ctx.storage.setAlarm(new Date(nextAlarmAt)) - this.stateSnapshot.nextAlarmAt = nextAlarmAt - await this.persistState() + if ( + options?.armAutoStart && + !existing && + this.stateSnapshot.autoStart && + !this.stateSnapshot.nextAlarmAt + ) { + await this.scheduleAlarm({ runAt: new Date() }) } return loaded } @@ -247,14 +276,20 @@ class PackageServiceInstanceBase extends DurableObject { } } - private buildServiceStatusResponse(binding: PackageServiceBindingState) { + private buildServiceStatusResponse( + binding: PackageServiceBindingState, + overrides?: { + autoStart?: boolean + timeoutMs?: number | null + }, + ) { return { package_id: binding.packageId, kody_id: binding.kodyId, service_name: binding.serviceName, status: this.stateSnapshot.status, - auto_start: this.stateSnapshot.autoStart, - timeout_ms: this.stateSnapshot.timeoutMs, + auto_start: overrides?.autoStart ?? this.stateSnapshot.autoStart, + timeout_ms: overrides?.timeoutMs ?? this.stateSnapshot.timeoutMs, stop_requested: this.stateSnapshot.stopRequested, active_run_id: this.stateSnapshot.currentRunId, next_alarm_at: this.stateSnapshot.nextAlarmAt, @@ -269,12 +304,15 @@ class PackageServiceInstanceBase extends DurableObject { private async runServiceInBackground(input: { binding: PackageServiceBindingState runId: string + loaded?: Awaited> }) { try { - const loaded = await loadSavedPackageService({ - env: this.env, - binding: input.binding, - }) + const loaded = + input.loaded ?? + (await loadSavedPackageService({ + env: this.env, + binding: input.binding, + })) const storageId = buildPackageServiceStorageId( input.binding.packageId, input.binding.serviceName, @@ -285,7 +323,10 @@ class PackageServiceInstanceBase extends DurableObject { await this.persistState() const result = await this.runService(loaded.resolvedBinding, { getStatus: async () => - this.buildServiceStatusResponse(loaded.resolvedBinding), + this.buildServiceStatusResponse(loaded.resolvedBinding, { + autoStart: loaded.serviceDefinition?.autoStart ?? false, + timeoutMs: loaded.serviceDefinition?.timeoutMs ?? null, + }), shouldStop: async () => this.stateSnapshot.stopRequested, setAlarm: async (runAt) => (await this.scheduleAlarm({ @@ -342,7 +383,7 @@ class PackageServiceInstanceBase extends DurableObject { runtime: { getStatus: () => Promise> shouldStop: () => Promise - setAlarm: (runAt: Date) => Promise<{ ok: true; scheduled_at: string }> + setAlarm: (runAt: Date | string) => Promise<{ ok: true; scheduled_at: string }> clearAlarm: () => Promise<{ ok: true }> packageContext: { packageId: string @@ -435,7 +476,9 @@ class PackageServiceInstanceBase extends DurableObject { private async handleStartRequest(input: { binding: PackageServiceBindingState }) { - const loaded = await this.initializeBinding(input.binding) + const loaded = await this.initializeBinding(input.binding, { + armAutoStart: true, + }) if (this.stateSnapshot.currentRunId) { return Response.json({ ok: true, @@ -456,6 +499,7 @@ class PackageServiceInstanceBase extends DurableObject { const task = this.runServiceInBackground({ binding: loaded.resolvedBinding, runId, + loaded, }) this.activeRunPromise = task this.ctx.waitUntil(task) @@ -470,14 +514,33 @@ class PackageServiceInstanceBase extends DurableObject { private async handleStatusRequest(input: { binding: PackageServiceBindingState }) { - const loaded = await this.initializeBinding(input.binding) - return Response.json(this.buildServiceStatusResponse(loaded.resolvedBinding)) + let loaded: Awaited> | undefined + try { + loaded = await loadSavedPackageService({ + env: this.env, + binding: input.binding, + }) + } catch { + loaded = undefined + } + const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding + return Response.json( + this.buildServiceStatusResponse(binding, { + autoStart: loaded?.serviceDefinition?.autoStart ?? this.stateSnapshot.autoStart, + timeoutMs: loaded?.serviceDefinition?.timeoutMs ?? this.stateSnapshot.timeoutMs, + }), + ) } private async handleStopRequest(input: { binding: PackageServiceBindingState }) { - await this.initializeBinding(input.binding) + try { + await this.initializeBinding(input.binding) + } catch { + // Allow an in-flight service to be stopped even if its package/source was removed. + this.stateSnapshot.binding ??= input.binding + } if (this.stateSnapshot.currentRunId) { this.stateSnapshot.stopRequested = true this.stateSnapshot.status = 'stopping' @@ -530,7 +593,21 @@ class PackageServiceInstanceBase extends DurableObject { loaded.serviceDefinition?.timeoutMs ?? null await this.persistState() if (!this.stateSnapshot.stopRequested) { - await this.handleStartRequest({ binding: loaded.resolvedBinding }) + const startedAt = new Date().toISOString() + const runId = crypto.randomUUID() + this.stateSnapshot.stopRequested = false + this.stateSnapshot.currentRunId = runId + this.stateSnapshot.status = 'running' + this.stateSnapshot.lastStartedAt = startedAt + this.stateSnapshot.lastError = null + await this.persistState() + const task = this.runServiceInBackground({ + binding: loaded.resolvedBinding, + runId, + loaded, + }) + this.activeRunPromise = task + this.ctx.waitUntil(task) } } catch (error) { this.stateSnapshot.lastError = @@ -596,11 +673,18 @@ export async function listSavedPackageServices(input: { userId: string baseUrl: string packageId: string + savedPackage?: { + id: string + sourceId: string + kodyId?: string + } }) { - const savedPackage = await getSavedPackageById(input.env.APP_DB, { - userId: input.userId, - packageId: input.packageId, - }) + const savedPackage = + input.savedPackage ?? + (await getSavedPackageById(input.env.APP_DB, { + userId: input.userId, + packageId: input.packageId, + })) if (!savedPackage) { throw new Error('Saved package was not found.') } diff --git a/tools/mcp-test-support.ts b/tools/mcp-test-support.ts index 47b6ccafaa..97cb61cefb 100644 --- a/tools/mcp-test-support.ts +++ b/tools/mcp-test-support.ts @@ -164,9 +164,17 @@ export async function startDevServer(persistDir: string) { } function isPortAlreadyInUseError(error: unknown) { + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + const codeValue = + typeof error === 'object' && error !== null + ? Reflect.get(error, 'code') + : undefined + const code = typeof codeValue === 'string' ? codeValue.toLowerCase() : '' return ( - error instanceof Error && - error.message.includes('Address already in use') + message.includes('address already in use') || + message.includes('eaddrinuse') || + code === 'eaddrinuse' ) } From e5ccfe3fe6757bb5309347e9113b10629273d4d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:25:01 +0000 Subject: [PATCH 23/39] Isolate package service auto-start failures Co-authored-by: Kent C. Dodds --- .../src/package-registry/service.node.test.ts | 71 +++++++++++++++++++ .../worker/src/package-registry/service.ts | 6 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 68d809d3c5..7529ac193e 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -25,6 +25,7 @@ const mockModule = vi.hoisted(() => ({ insertSavedPackage: vi.fn(), listJobRowsByUserId: vi.fn(), loadPackageSourceBySourceId: vi.fn(), + packageServiceRpc: vi.fn(), syncJobManagerAlarm: vi.fn(), syncPackageJobsForPackage: vi.fn(), updateSavedPackage: vi.fn(), @@ -51,6 +52,11 @@ vi.mock('#worker/package-runtime/module-graph.ts', () => ({ buildKodyModuleBundle: vi.fn(), })) +vi.mock('#worker/package-runtime/package-service.ts', () => ({ + packageServiceRpc: (...args: Array) => + mockModule.packageServiceRpc(...args), +})) + vi.mock('./repo.ts', () => ({ deleteSavedPackage: (...args: Array) => mockModule.deleteSavedPackage(...args), @@ -126,6 +132,9 @@ beforeEach(() => { mockModule.deleteSavedPackage.mockResolvedValue(undefined) mockModule.deleteSavedPackageVector.mockResolvedValue(undefined) mockModule.deleteJobRow.mockResolvedValue(undefined) + mockModule.packageServiceRpc.mockReturnValue({ + start: vi.fn().mockResolvedValue({ ok: true }), + }) }) test('refreshSavedPackageProjection resyncs the job manager after syncing package jobs', async () => { @@ -264,3 +273,65 @@ test('deleteSavedPackageProjection resyncs the job manager after removing packag mockModule.syncJobManagerAlarm.mock.invocationCallOrder[0], ).toBeGreaterThan(mockModule.deleteSavedPackage.mock.invocationCallOrder[0]) }) + +test('refreshSavedPackageProjection still syncs job manager when auto-start service startup fails', async () => { + const env = { + APP_DB: {}, + PACKAGE_SERVICE_INSTANCE: { + idFromName(name: string) { + return name as unknown as DurableObjectId + }, + get() { + return {} as DurableObjectStub + }, + }, + } as Env + const manifest = { + name: '@kentcdodds/shade-automation', + kody: { + id: 'shade-automation', + description: 'Shade automation package', + tags: ['home', 'shades'], + searchText: 'shade automation', + services: { + 'realtime-supervisor': { + entry: './src/services/realtime-supervisor.ts', + autoStart: true, + }, + }, + }, + } + mockModule.loadPackageSourceBySourceId.mockResolvedValue({ + manifest, + files: { 'package.json': '{}' }, + }) + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-1', + userId: 'user-1', + name: '@kentcdodds/shade-automation', + kodyId: 'shade-automation', + description: 'Old description', + tags: ['home'], + searchText: null, + sourceId: 'source-1', + hasApp: false, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }) + mockModule.packageServiceRpc.mockReturnValue({ + start: vi.fn().mockRejectedValue(new Error('service start failed')), + }) + + await refreshSavedPackageProjection({ + env, + baseUrl: 'https://heykody.dev', + userId: 'user-1', + packageId: 'package-1', + sourceId: 'source-1', + }) + + expect(mockModule.syncJobManagerAlarm).toHaveBeenCalledWith({ + env, + userId: 'user-1', + }) +}) diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index d7e67296d8..4ef52af6b0 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -158,7 +158,11 @@ export async function refreshSavedPackageProjection(input: { sourceId: row.source_id, baseUrl: input.baseUrl, serviceName: service, - }).start() + }) + .start() + .catch(() => { + // Auto-start failures should not block package job sync/alarm refresh. + }) } await syncJobManagerAlarm({ env: input.env, From aa62f270a0a4aef9ec0d25d87d6cab37551e3228 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:35:28 +0000 Subject: [PATCH 24/39] Polish package service runtime lifecycle Co-authored-by: Kent C. Dodds --- .../src/package-registry/service.node.test.ts | 35 +++++++++++++++++++ .../worker/src/package-registry/service.ts | 27 +++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 7529ac193e..2da763f62f 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -23,6 +23,7 @@ const mockModule = vi.hoisted(() => ({ deleteSavedPackageVector: vi.fn(), getSavedPackageById: vi.fn(), insertSavedPackage: vi.fn(), + listSavedPackageServices: vi.fn(), listJobRowsByUserId: vi.fn(), loadPackageSourceBySourceId: vi.fn(), packageServiceRpc: vi.fn(), @@ -53,6 +54,8 @@ vi.mock('#worker/package-runtime/module-graph.ts', () => ({ })) vi.mock('#worker/package-runtime/package-service.ts', () => ({ + listSavedPackageServices: (...args: Array) => + mockModule.listSavedPackageServices(...args), packageServiceRpc: (...args: Array) => mockModule.packageServiceRpc(...args), })) @@ -132,8 +135,16 @@ beforeEach(() => { mockModule.deleteSavedPackage.mockResolvedValue(undefined) mockModule.deleteSavedPackageVector.mockResolvedValue(undefined) mockModule.deleteJobRow.mockResolvedValue(undefined) + mockModule.listSavedPackageServices.mockResolvedValue({ + savedPackage: { + id: 'package-1', + kodyId: 'shade-automation', + }, + services: [], + }) mockModule.packageServiceRpc.mockReturnValue({ start: vi.fn().mockResolvedValue({ ok: true }), + stop: vi.fn().mockResolvedValue({ ok: true }), }) }) @@ -239,8 +250,23 @@ test('deleteSavedPackageProjection resyncs the job manager after removing packag const env = createEnv() mockModule.getSavedPackageById.mockResolvedValue({ id: 'package-1', + kodyId: 'shade-automation', sourceId: 'source-1', }) + mockModule.listSavedPackageServices.mockResolvedValue({ + savedPackage: { + id: 'package-1', + kodyId: 'shade-automation', + }, + services: [ + { + name: 'realtime-supervisor', + entry: './src/services/realtime-supervisor.ts', + autoStart: true, + timeoutMs: null, + }, + ], + }) mockModule.listJobRowsByUserId.mockResolvedValue([ { id: 'job-1', source_id: 'source-1' }, { id: 'job-2', source_id: 'source-other' }, @@ -252,6 +278,15 @@ test('deleteSavedPackageProjection resyncs the job manager after removing packag packageId: 'package-1', }) + expect(mockModule.packageServiceRpc).toHaveBeenCalledWith({ + env, + userId: 'user-1', + packageId: 'package-1', + kodyId: 'shade-automation', + sourceId: 'source-1', + baseUrl: 'https://package-service.invalid', + serviceName: 'realtime-supervisor', + }) expect(mockModule.deleteJobRow).toHaveBeenCalledTimes(1) expect(mockModule.deleteJobRow).toHaveBeenCalledWith({}, 'user-1', 'job-1') expect(mockModule.deleteSavedPackage).toHaveBeenCalledWith( diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index 4ef52af6b0..faef77b840 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -21,7 +21,10 @@ import { syncJobManagerAlarm } from '#worker/jobs/manager-client.ts' import { rebuildPublishedPackageArtifacts, } from '#worker/package-runtime/published-bundle-artifacts.ts' -import { packageServiceRpc } from '#worker/package-runtime/package-service.ts' +import { + listSavedPackageServices, + packageServiceRpc, +} from '#worker/package-runtime/package-service.ts' function serializeTags(tags: Array) { return JSON.stringify(tags) @@ -199,6 +202,28 @@ export async function deleteSavedPackageProjection(input: { packageId: input.packageId, }) if (savedPackage) { + const listedServices = await listSavedPackageServices({ + env: input.env, + userId: input.userId, + baseUrl: 'https://package-service.invalid', + packageId: input.packageId, + savedPackage, + }).catch(() => null) + if (listedServices) { + for (const service of listedServices.services) { + await packageServiceRpc({ + env: input.env, + userId: input.userId, + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + sourceId: savedPackage.sourceId, + baseUrl: 'https://package-service.invalid', + serviceName: service.name, + }) + .stop() + .catch(() => undefined) + } + } const existingRows = await listJobRowsByUserId( input.env.APP_DB, input.userId, From 2cf25f4f2dbbd5959e40c4260f51c080f18ec4b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:41:12 +0000 Subject: [PATCH 25/39] Preserve null service timeout status Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.node.test.ts | 1 + .../worker/src/package-runtime/package-service.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 08a6e03ea1..72d3aaa75e 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -52,6 +52,7 @@ test('package service runtime refreshes manifest-backed service settings for ala expect(fileText).toContain( 'this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null', ) + expect(fileText).toContain("'timeoutMs' in overrides") expect(fileText).toContain( 'const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding', ) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 4d3fa7d771..d644717f44 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -283,13 +283,21 @@ class PackageServiceInstanceBase extends DurableObject { timeoutMs?: number | null }, ) { + const autoStart = + overrides && 'autoStart' in overrides + ? overrides.autoStart + : this.stateSnapshot.autoStart + const timeoutMs = + overrides && 'timeoutMs' in overrides + ? overrides.timeoutMs + : this.stateSnapshot.timeoutMs return { package_id: binding.packageId, kody_id: binding.kodyId, service_name: binding.serviceName, status: this.stateSnapshot.status, - auto_start: overrides?.autoStart ?? this.stateSnapshot.autoStart, - timeout_ms: overrides?.timeoutMs ?? this.stateSnapshot.timeoutMs, + auto_start: autoStart, + timeout_ms: timeoutMs, stop_requested: this.stateSnapshot.stopRequested, active_run_id: this.stateSnapshot.currentRunId, next_alarm_at: this.stateSnapshot.nextAlarmAt, From 7d0a93e6acb42a0023e89aee8e24a28f60067808 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:47:53 +0000 Subject: [PATCH 26/39] Reuse saved package in service listing Co-authored-by: Kent C. Dodds --- packages/worker/src/mcp/capabilities/services/shared.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index 9cfcc0b187..ef69eea2e8 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -105,6 +105,7 @@ export async function listPackageServicesForContext(input: { userId: context.user.userId, baseUrl: input.callerContext.baseUrl, packageId: context.savedPackage.id, + savedPackage: context.savedPackage, }) return { savedPackage: context.savedPackage, From e4e06f4212fc4ca9d12173515a150cd4f929fd7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 03:57:10 +0000 Subject: [PATCH 27/39] Drop unused package service auto-start helper Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index d644717f44..75d3265eeb 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -157,22 +157,6 @@ export async function readPackageServiceRpcResponse( } } -export async function schedulePackageServiceAutoStart(input: { - env: Env - binding: PackageServiceBindingState -}) { - const rpc = packageServiceRpc({ - env: input.env, - userId: input.binding.userId, - packageId: input.binding.packageId, - kodyId: input.binding.kodyId, - sourceId: input.binding.sourceId, - baseUrl: input.binding.baseUrl, - serviceName: input.binding.serviceName, - }) - await rpc.start() -} - class PackageServiceInstanceBase extends DurableObject { private stateSnapshot: PackageServiceState = createInitialPackageServiceState() private activeRunPromise: Promise | null = null From 2489d4441433941112120187dc0f63df4015e421 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:08:49 +0000 Subject: [PATCH 28/39] Rearm auto-start services after unexpected exits Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.node.test.ts | 10 ++++++++++ .../worker/src/package-runtime/package-service.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 72d3aaa75e..dc6bcffd16 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -43,6 +43,15 @@ test('package service runtime preserves explicit stop requests after a run ends' expect(fileText).toContain('!this.stateSnapshot.stopRequested') }) +test('package service runtime re-arms auto-start after unplanned exit', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain('loaded.serviceDefinition?.autoStart') + expect(fileText).toContain('!this.stateSnapshot.nextAlarmAt') + expect(fileText).toContain('await this.scheduleAlarm({ runAt: new Date() })') +}) + test('package service runtime refreshes manifest-backed service settings for alarms and status', async () => { const fileText = await import('node:fs/promises').then((fs) => fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), @@ -77,6 +86,7 @@ test('package service runtime schedules auto-start on save path instead of read- expect(fileText).toContain( 'const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding', ) + expect(fileText).not.toContain('this.stateSnapshot.stopRequested = false\n\t\t\t\tthis.stateSnapshot.currentRunId = runId') }) test('package service runtime persists restored state and surfaces RPC errors', async () => { diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 75d3265eeb..298763a4ef 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -345,6 +345,11 @@ class PackageServiceInstanceBase extends DurableObject { await this.persistState() if (stopRequested) { await this.clearAlarm() + } else if ( + this.stateSnapshot.autoStart && + !this.stateSnapshot.nextAlarmAt + ) { + await this.scheduleAlarm({ runAt: new Date() }) } } catch (error) { if (this.stateSnapshot.currentRunId !== input.runId) return @@ -362,6 +367,11 @@ class PackageServiceInstanceBase extends DurableObject { await this.persistState() if (stopRequested) { await this.clearAlarm() + } else if ( + this.stateSnapshot.autoStart && + !this.stateSnapshot.nextAlarmAt + ) { + await this.scheduleAlarm({ runAt: new Date() }) } } finally { if (this.activeRunPromise) { @@ -587,7 +597,6 @@ class PackageServiceInstanceBase extends DurableObject { if (!this.stateSnapshot.stopRequested) { const startedAt = new Date().toISOString() const runId = crypto.randomUUID() - this.stateSnapshot.stopRequested = false this.stateSnapshot.currentRunId = runId this.stateSnapshot.status = 'running' this.stateSnapshot.lastStartedAt = startedAt From 921731703674172b3d5ca42988cd178308b53c5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:18:02 +0000 Subject: [PATCH 29/39] Share package service status normalization Co-authored-by: Kent C. Dodds --- packages/worker/src/package-runtime/package-app.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index fa2f725942..5200a14b3a 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -22,6 +22,7 @@ import { import { storageRunnerRpc } from '#worker/storage-runner.ts' import { packageRealtimeSessionRpc } from './realtime-session.ts' import { listSavedPackageServices, packageServiceRpc } from './package-service.ts' +import { normalizePackageServiceStatus } from '#mcp/capabilities/services/shared.ts' const packageAppEntrypointName = 'PackageAppWorker' const packageAppRuntimeBindingName = 'KODY_RUNTIME' @@ -712,16 +713,9 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< result.services.map(async (service) => { let status = 'unknown' try { - const serviceStatus = await this.getPackageServiceRpc( - service.name, - ).status() - status = - serviceStatus && - typeof serviceStatus === 'object' && - 'status' in serviceStatus && - typeof serviceStatus.status === 'string' - ? serviceStatus.status - : 'unknown' + status = normalizePackageServiceStatus( + await this.getPackageServiceRpc(service.name).status(), + ).status } catch { // Keep the rest of the service list usable if one status lookup fails. } From 17cf643b868db07c9682fa20ff7e45d0968dfe77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:25:39 +0000 Subject: [PATCH 30/39] Treat service_start as restart intent Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.node.test.ts | 9 +++++++++ packages/worker/src/package-runtime/package-service.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index dc6bcffd16..45b4ca8a1f 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -43,6 +43,15 @@ test('package service runtime preserves explicit stop requests after a run ends' expect(fileText).toContain('!this.stateSnapshot.stopRequested') }) +test('package service runtime restart clears a pending stop request', async () => { + const fileText = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), + ) + expect(fileText).toContain('if (this.stateSnapshot.currentRunId) {') + expect(fileText).toContain('this.stateSnapshot.stopRequested = false') + expect(fileText).toContain('already_running: true') +}) + test('package service runtime re-arms auto-start after unplanned exit', async () => { const fileText = await import('node:fs/promises').then((fs) => fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 298763a4ef..d3f7489829 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -482,6 +482,9 @@ class PackageServiceInstanceBase extends DurableObject { armAutoStart: true, }) if (this.stateSnapshot.currentRunId) { + this.stateSnapshot.stopRequested = false + this.stateSnapshot.status = 'running' + await this.persistState() return Response.json({ ok: true, run_id: this.stateSnapshot.currentRunId, From 4678d678ccd60e174b34f81800df2eec5002c4d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:32:32 +0000 Subject: [PATCH 31/39] Retry auto-start services after alarm load errors Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.node.test.ts | 6 ++++++ packages/worker/src/package-runtime/package-service.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 45b4ca8a1f..23ef0e4023 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -59,6 +59,12 @@ test('package service runtime re-arms auto-start after unplanned exit', async () expect(fileText).toContain('loaded.serviceDefinition?.autoStart') expect(fileText).toContain('!this.stateSnapshot.nextAlarmAt') expect(fileText).toContain('await this.scheduleAlarm({ runAt: new Date() })') + expect(fileText).toContain( + "this.stateSnapshot.status = 'error'", + ) + expect(fileText).toContain( + 'this.stateSnapshot.autoStart &&\n\t\t\t\t!this.stateSnapshot.stopRequested &&\n\t\t\t\t!this.stateSnapshot.nextAlarmAt', + ) }) test('package service runtime refreshes manifest-backed service settings for alarms and status', async () => { diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index d3f7489829..86b7e3295b 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -618,6 +618,13 @@ class PackageServiceInstanceBase extends DurableObject { error instanceof Error ? error.message : String(error) this.stateSnapshot.status = 'error' await this.persistState() + if ( + this.stateSnapshot.autoStart && + !this.stateSnapshot.stopRequested && + !this.stateSnapshot.nextAlarmAt + ) { + await this.scheduleAlarm({ runAt: new Date() }) + } } } } From 93d5de5755b90d8f277a84dd847ef6af06091cf0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:45:04 +0000 Subject: [PATCH 32/39] Back off auto-start alarm retries Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-service.node.test.ts | 6 +++++- packages/worker/src/package-runtime/package-service.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 23ef0e4023..c7a94693dd 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -58,7 +58,11 @@ test('package service runtime re-arms auto-start after unplanned exit', async () ) expect(fileText).toContain('loaded.serviceDefinition?.autoStart') expect(fileText).toContain('!this.stateSnapshot.nextAlarmAt') - expect(fileText).toContain('await this.scheduleAlarm({ runAt: new Date() })') + expect(fileText).toContain('const packageServiceRetryDelayMs = 5_000') + expect(fileText).toContain('buildPackageServiceRetryTime()') + expect(fileText).toContain( + 'await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() })', + ) expect(fileText).toContain( "this.stateSnapshot.status = 'error'", ) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 86b7e3295b..3a8f8fcfd6 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -10,6 +10,7 @@ import { loadPackageSourceBySourceId } from '#worker/package-registry/source.ts' import { buildSentryOptions } from '#worker/sentry-options.ts' const serviceStateStorageKey = 'package-service-state' +const packageServiceRetryDelayMs = 5_000 export type PackageServiceBindingState = { userId: string @@ -157,6 +158,10 @@ export async function readPackageServiceRpcResponse( } } +function buildPackageServiceRetryTime() { + return new Date(Date.now() + packageServiceRetryDelayMs) +} + class PackageServiceInstanceBase extends DurableObject { private stateSnapshot: PackageServiceState = createInitialPackageServiceState() private activeRunPromise: Promise | null = null @@ -192,7 +197,7 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.stopRequested && this.stateSnapshot.binding ) { - await this.scheduleAlarm({ runAt: new Date() }) + await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() }) } } } @@ -623,7 +628,7 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.stopRequested && !this.stateSnapshot.nextAlarmAt ) { - await this.scheduleAlarm({ runAt: new Date() }) + await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() }) } } } From cf53f5beac62e6e071751b9344308a463b265502 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 04:56:00 +0000 Subject: [PATCH 33/39] Keep service status validation in runtime Co-authored-by: Kent C. Dodds --- .../services/services-domain.node.test.ts | 14 ++++++++ .../src/mcp/capabilities/services/shared.ts | 31 +++--------------- .../worker/src/package-runtime/package-app.ts | 7 ++-- .../src/package-runtime/package-service.ts | 32 +++++++++++++++++++ 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index aacc07611f..7937663f29 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -5,6 +5,14 @@ const mockModule = vi.hoisted(() => ({ listSavedPackageServices: vi.fn(), packageServiceRpc: vi.fn(), getSavedPackageById: vi.fn(), + normalizePackageServiceStatus: vi.fn((input: unknown) => input), + packageServiceStatusSchema: { + parse: vi.fn((input: unknown) => input), + safeParse: vi.fn((input: unknown) => ({ + success: true, + data: input, + })), + }, })) vi.mock('#worker/package-runtime/package-service.ts', () => ({ @@ -12,6 +20,9 @@ vi.mock('#worker/package-runtime/package-service.ts', () => ({ mockModule.listSavedPackageServices(...args), packageServiceRpc: (...args: Array) => mockModule.packageServiceRpc(...args), + normalizePackageServiceStatus: (...args: Array) => + mockModule.normalizePackageServiceStatus(...args), + packageServiceStatusSchema: mockModule.packageServiceStatusSchema, })) vi.mock('#worker/package-registry/repo.ts', () => ({ @@ -29,6 +40,9 @@ function resetMocks() { mockModule.listSavedPackageServices.mockReset() mockModule.packageServiceRpc.mockReset() mockModule.getSavedPackageById.mockReset() + mockModule.normalizePackageServiceStatus.mockReset() + mockModule.packageServiceStatusSchema.parse.mockClear() + mockModule.packageServiceStatusSchema.safeParse.mockClear() } function createCallerContext() { diff --git a/packages/worker/src/mcp/capabilities/services/shared.ts b/packages/worker/src/mcp/capabilities/services/shared.ts index ef69eea2e8..ddacc5117f 100644 --- a/packages/worker/src/mcp/capabilities/services/shared.ts +++ b/packages/worker/src/mcp/capabilities/services/shared.ts @@ -4,9 +4,13 @@ import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { getSavedPackageById } from '#worker/package-registry/repo.ts' import { listSavedPackageServices, + normalizePackageServiceStatus, packageServiceRpc, + packageServiceStatusSchema, } from '#worker/package-runtime/package-service.ts' +export { normalizePackageServiceStatus, packageServiceStatusSchema } + export const packageServiceRecordSchema = z.object({ name: z.string(), entry: z.string(), @@ -18,23 +22,6 @@ export const packageServiceSummarySchema = packageServiceRecordSchema.extend({ status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error', 'unknown']), }) -export const packageServiceStatusSchema = z.object({ - package_id: z.string(), - kody_id: z.string(), - service_name: z.string(), - status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), - auto_start: z.boolean(), - timeout_ms: z.number().int().positive().nullable(), - stop_requested: z.boolean(), - active_run_id: z.string().nullable(), - next_alarm_at: z.string().nullable(), - last_error: z.string().nullable(), - last_started_at: z.string().nullable(), - last_stopped_at: z.string().nullable(), - last_run_finished_at: z.string().nullable(), - last_result: z.unknown(), -}) - function resolvePackageId( callerContext: McpCallerContext, explicitPackageId?: string | null, @@ -133,13 +120,3 @@ export type PackageServiceStatusRecord = z.infer< typeof packageServiceStatusSchema > -export function normalizePackageServiceStatus( - input: unknown, -): PackageServiceStatusRecord { - const result = packageServiceStatusSchema.safeParse(input) - if (!result.success) { - throw new Error(z.prettifyError(result.error)) - } - return result.data -} - diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 5200a14b3a..3f71aca857 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -21,8 +21,11 @@ import { } from './published-runtime-artifacts.ts' import { storageRunnerRpc } from '#worker/storage-runner.ts' import { packageRealtimeSessionRpc } from './realtime-session.ts' -import { listSavedPackageServices, packageServiceRpc } from './package-service.ts' -import { normalizePackageServiceStatus } from '#mcp/capabilities/services/shared.ts' +import { + listSavedPackageServices, + normalizePackageServiceStatus, + packageServiceRpc, +} from './package-service.ts' const packageAppEntrypointName = 'PackageAppWorker' const packageAppRuntimeBindingName = 'KODY_RUNTIME' diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 3a8f8fcfd6..86462c4158 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/cloudflare' import { DurableObject } from 'cloudflare:workers' +import { z } from 'zod' import { createMcpCallerContext } from '#mcp/context.ts' import { getPackageServiceEntryPath, @@ -44,6 +45,37 @@ type PackageServiceRunResult = { already_running?: boolean } +export const packageServiceStatusSchema = z.object({ + package_id: z.string(), + kody_id: z.string(), + service_name: z.string(), + status: z.enum(['idle', 'running', 'stopping', 'stopped', 'error']), + auto_start: z.boolean(), + timeout_ms: z.number().int().positive().nullable(), + stop_requested: z.boolean(), + active_run_id: z.string().nullable(), + next_alarm_at: z.string().nullable(), + last_error: z.string().nullable(), + last_started_at: z.string().nullable(), + last_stopped_at: z.string().nullable(), + last_run_finished_at: z.string().nullable(), + last_result: z.unknown(), +}) + +export type PackageServiceStatusRecord = z.infer< + typeof packageServiceStatusSchema +> + +export function normalizePackageServiceStatus( + input: unknown, +): PackageServiceStatusRecord { + const result = packageServiceStatusSchema.safeParse(input) + if (!result.success) { + throw new Error(z.prettifyError(result.error)) + } + return result.data +} + function createInitialPackageServiceState(): PackageServiceState { return { binding: null, From ad49d08d879b80b9d31ac75773997dc305274d19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 05:04:50 +0000 Subject: [PATCH 34/39] Honor autoStart when alarm retries fire Co-authored-by: Kent C. Dodds --- .../package-service.node.test.ts | 10 +++-- .../src/package-runtime/package-service.ts | 42 +++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index c7a94693dd..38dbaaf700 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -60,9 +60,8 @@ test('package service runtime re-arms auto-start after unplanned exit', async () expect(fileText).toContain('!this.stateSnapshot.nextAlarmAt') expect(fileText).toContain('const packageServiceRetryDelayMs = 5_000') expect(fileText).toContain('buildPackageServiceRetryTime()') - expect(fileText).toContain( - 'await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() })', - ) + expect(fileText).toContain('runAt: buildPackageServiceRetryTime()') + expect(fileText).toContain("source: 'auto-start'") expect(fileText).toContain( "this.stateSnapshot.status = 'error'", ) @@ -102,10 +101,13 @@ test('package service runtime schedules auto-start on save path instead of read- expect(fileText).toContain('options?: { armAutoStart?: boolean }') expect(fileText).toContain('options?.armAutoStart') expect(fileText).toContain('armAutoStart: true') + expect(fileText).toContain("source: 'auto-start'") expect(fileText).toContain( 'const binding = loaded?.resolvedBinding ?? this.stateSnapshot.binding ?? input.binding', ) - expect(fileText).not.toContain('this.stateSnapshot.stopRequested = false\n\t\t\t\tthis.stateSnapshot.currentRunId = runId') + expect(fileText).toContain( + 'options?.armAutoStart &&', + ) }) test('package service runtime persists restored state and surfaces RPC errors', async () => { diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 86462c4158..0d3899cf64 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -29,6 +29,7 @@ type PackageServiceState = { stopRequested: boolean currentRunId: string | null nextAlarmAt: string | null + nextAlarmSource: 'service' | 'auto-start' | null lastStartedAt: string | null lastStoppedAt: string | null status: 'idle' | 'running' | 'stopping' | 'stopped' | 'error' @@ -84,6 +85,7 @@ function createInitialPackageServiceState(): PackageServiceState { stopRequested: false, currentRunId: null, nextAlarmAt: null, + nextAlarmSource: null, lastStartedAt: null, lastStoppedAt: null, status: 'idle', @@ -229,7 +231,10 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.stopRequested && this.stateSnapshot.binding ) { - await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() }) + await this.scheduleAlarm({ + runAt: buildPackageServiceRetryTime(), + source: 'auto-start', + }) } } } @@ -265,12 +270,18 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.autoStart && !this.stateSnapshot.nextAlarmAt ) { - await this.scheduleAlarm({ runAt: new Date() }) + await this.scheduleAlarm({ + runAt: new Date(), + source: 'auto-start', + }) } return loaded } - private async scheduleAlarm(input: { runAt: Date | string }) { + private async scheduleAlarm(input: { + runAt: Date | string + source?: 'service' | 'auto-start' + }) { const runAtDate = typeof input.runAt === 'string' ? new Date(input.runAt) : input.runAt if (Number.isNaN(runAtDate.getTime())) { @@ -279,6 +290,7 @@ class PackageServiceInstanceBase extends DurableObject { const scheduledAt = runAtDate.toISOString() await this.ctx.storage.setAlarm(runAtDate) this.stateSnapshot.nextAlarmAt = scheduledAt + this.stateSnapshot.nextAlarmSource = input.source ?? 'service' await this.persistState() return { ok: true, @@ -291,6 +303,7 @@ class PackageServiceInstanceBase extends DurableObject { // Best effort cleanup. }) this.stateSnapshot.nextAlarmAt = null + this.stateSnapshot.nextAlarmSource = null await this.persistState() return { ok: true, @@ -360,6 +373,7 @@ class PackageServiceInstanceBase extends DurableObject { setAlarm: async (runAt) => (await this.scheduleAlarm({ runAt, + source: 'service', })) as { ok: true; scheduled_at: string }, clearAlarm: async () => (await this.clearAlarm()) as { ok: true }, @@ -386,7 +400,10 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.autoStart && !this.stateSnapshot.nextAlarmAt ) { - await this.scheduleAlarm({ runAt: new Date() }) + await this.scheduleAlarm({ + runAt: new Date(), + source: 'auto-start', + }) } } catch (error) { if (this.stateSnapshot.currentRunId !== input.runId) return @@ -408,7 +425,10 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.autoStart && !this.stateSnapshot.nextAlarmAt ) { - await this.scheduleAlarm({ runAt: new Date() }) + await this.scheduleAlarm({ + runAt: new Date(), + source: 'auto-start', + }) } } finally { if (this.activeRunPromise) { @@ -621,7 +641,9 @@ class PackageServiceInstanceBase extends DurableObject { async alarm() { const binding = this.stateSnapshot.binding if (!binding) return + const alarmSource = this.stateSnapshot.nextAlarmSource ?? 'service' this.stateSnapshot.nextAlarmAt = null + this.stateSnapshot.nextAlarmSource = null await this.persistState() if (this.stateSnapshot.currentRunId) return try { @@ -634,7 +656,10 @@ class PackageServiceInstanceBase extends DurableObject { this.stateSnapshot.timeoutMs = loaded.serviceDefinition?.timeoutMs ?? null await this.persistState() - if (!this.stateSnapshot.stopRequested) { + if ( + !this.stateSnapshot.stopRequested && + (alarmSource === 'service' || this.stateSnapshot.autoStart) + ) { const startedAt = new Date().toISOString() const runId = crypto.randomUUID() this.stateSnapshot.currentRunId = runId @@ -660,7 +685,10 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.stopRequested && !this.stateSnapshot.nextAlarmAt ) { - await this.scheduleAlarm({ runAt: buildPackageServiceRetryTime() }) + await this.scheduleAlarm({ + runAt: buildPackageServiceRetryTime(), + source: alarmSource, + }) } } } From cfe34b4024e3287a7380f256caad38c84cca6f51 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 05:10:31 +0000 Subject: [PATCH 35/39] Back off runtime auto-start restarts Co-authored-by: Kent C. Dodds --- .../worker/src/package-runtime/package-service.node.test.ts | 2 +- packages/worker/src/package-runtime/package-service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 38dbaaf700..fd06cc8f52 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -60,7 +60,7 @@ test('package service runtime re-arms auto-start after unplanned exit', async () expect(fileText).toContain('!this.stateSnapshot.nextAlarmAt') expect(fileText).toContain('const packageServiceRetryDelayMs = 5_000') expect(fileText).toContain('buildPackageServiceRetryTime()') - expect(fileText).toContain('runAt: buildPackageServiceRetryTime()') + expect(fileText).toContain('runAt: buildPackageServiceRetryTime(),') expect(fileText).toContain("source: 'auto-start'") expect(fileText).toContain( "this.stateSnapshot.status = 'error'", diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 0d3899cf64..69cd484f03 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -401,7 +401,7 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.nextAlarmAt ) { await this.scheduleAlarm({ - runAt: new Date(), + runAt: buildPackageServiceRetryTime(), source: 'auto-start', }) } @@ -426,7 +426,7 @@ class PackageServiceInstanceBase extends DurableObject { !this.stateSnapshot.nextAlarmAt ) { await this.scheduleAlarm({ - runAt: new Date(), + runAt: buildPackageServiceRetryTime(), source: 'auto-start', }) } From 31d327160e5b200212491996b321520bd4bda71e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 05:25:05 +0000 Subject: [PATCH 36/39] Polish package service review follow-ups Co-authored-by: Kent C. Dodds --- .../services/services-domain.node.test.ts | 2 +- .../package-service.node.test.ts | 4 +- .../src/package-runtime/package-service.ts | 85 ++++++++++--------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index 7937663f29..6128b5caeb 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -40,7 +40,7 @@ function resetMocks() { mockModule.listSavedPackageServices.mockReset() mockModule.packageServiceRpc.mockReset() mockModule.getSavedPackageById.mockReset() - mockModule.normalizePackageServiceStatus.mockReset() + mockModule.normalizePackageServiceStatus.mockClear() mockModule.packageServiceStatusSchema.parse.mockClear() mockModule.packageServiceStatusSchema.safeParse.mockClear() } diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index fd06cc8f52..5d8202bcc2 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -37,9 +37,9 @@ test('package service runtime preserves explicit stop requests after a run ends' 'this.stateSnapshot.stopRequested = false', ) expect(fileText).toContain( - "this.stateSnapshot.status = this.stateSnapshot.stopRequested\n\t\t\t\t? 'stopped'\n\t\t\t\t: 'error'", + 'await this.finalizeServiceRun({', ) - expect(fileText).toContain("if (stopRequested) {\n\t\t\t\tawait this.clearAlarm()") + expect(fileText).toContain("if (stopRequested) {\n\t\t\tawait this.clearAlarm()") expect(fileText).toContain('!this.stateSnapshot.stopRequested') }) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 69cd484f03..fe0e4349d4 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -343,6 +343,39 @@ class PackageServiceInstanceBase extends DurableObject { } } + private async finalizeServiceRun(input: { + runId: string + nextStatus: PackageServiceState['status'] + lastResult?: unknown + lastError?: string | null + }) { + if (this.stateSnapshot.currentRunId !== input.runId) return + const stopRequested = this.stateSnapshot.stopRequested + this.stateSnapshot.status = input.nextStatus + this.stateSnapshot.currentRunId = null + this.stateSnapshot.stopRequested = false + if ('lastResult' in input) { + this.stateSnapshot.lastResult = input.lastResult ?? null + } + if ('lastError' in input) { + this.stateSnapshot.lastError = input.lastError ?? null + } + this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() + this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt + await this.persistState() + if (stopRequested) { + await this.clearAlarm() + } else if ( + this.stateSnapshot.autoStart && + !this.stateSnapshot.nextAlarmAt + ) { + await this.scheduleAlarm({ + runAt: buildPackageServiceRetryTime(), + source: 'auto-start', + }) + } + } + private async runServiceInBackground(input: { binding: PackageServiceBindingState runId: string @@ -385,51 +418,19 @@ class PackageServiceInstanceBase extends DurableObject { executorTimeoutMs: loaded.serviceDefinition?.timeoutMs ?? 300_000, storageId, }) - if (this.stateSnapshot.currentRunId !== input.runId) return - const stopRequested = this.stateSnapshot.stopRequested - this.stateSnapshot.status = 'stopped' - this.stateSnapshot.currentRunId = null - this.stateSnapshot.stopRequested = false - this.stateSnapshot.lastResult = result - this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() - this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt - await this.persistState() - if (stopRequested) { - await this.clearAlarm() - } else if ( - this.stateSnapshot.autoStart && - !this.stateSnapshot.nextAlarmAt - ) { - await this.scheduleAlarm({ - runAt: buildPackageServiceRetryTime(), - source: 'auto-start', - }) - } + await this.finalizeServiceRun({ + runId: input.runId, + nextStatus: 'stopped', + lastResult: result, + }) } catch (error) { - if (this.stateSnapshot.currentRunId !== input.runId) return const errorMessage = error instanceof Error ? error.message : String(error) - const stopRequested = this.stateSnapshot.stopRequested - this.stateSnapshot.status = this.stateSnapshot.stopRequested - ? 'stopped' - : 'error' - this.stateSnapshot.currentRunId = null - this.stateSnapshot.stopRequested = false - this.stateSnapshot.lastError = errorMessage - this.stateSnapshot.lastRunFinishedAt = new Date().toISOString() - this.stateSnapshot.lastStoppedAt = this.stateSnapshot.lastRunFinishedAt - await this.persistState() - if (stopRequested) { - await this.clearAlarm() - } else if ( - this.stateSnapshot.autoStart && - !this.stateSnapshot.nextAlarmAt - ) { - await this.scheduleAlarm({ - runAt: buildPackageServiceRetryTime(), - source: 'auto-start', - }) - } + await this.finalizeServiceRun({ + runId: input.runId, + nextStatus: this.stateSnapshot.stopRequested ? 'stopped' : 'error', + lastError: errorMessage, + }) } finally { if (this.activeRunPromise) { this.activeRunPromise = null From 7b069503a5c3e7d32588540330fdc9b20a1d5486 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 05:40:44 +0000 Subject: [PATCH 37/39] Trim package service runtime surface Co-authored-by: Kent C. Dodds --- .../worker/src/package-runtime/package-service.node.test.ts | 2 +- packages/worker/src/package-runtime/package-service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/package-runtime/package-service.node.test.ts b/packages/worker/src/package-runtime/package-service.node.test.ts index 5d8202bcc2..29be519fd6 100644 --- a/packages/worker/src/package-runtime/package-service.node.test.ts +++ b/packages/worker/src/package-runtime/package-service.node.test.ts @@ -115,7 +115,7 @@ test('package service runtime persists restored state and surfaces RPC errors', fs.readFile(new URL('./package-service.ts', import.meta.url), 'utf8'), ) expect(fileText).toContain('await this.persistState()') - expect(fileText).toContain('export async function readPackageServiceRpcResponse(') + expect(fileText).toContain('async function readPackageServiceRpcResponse(') expect(fileText).toContain('if (!response.ok) {') expect(fileText).toContain( 'text || `Package service request failed with status ${response.status}.`', diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index fe0e4349d4..82a87a41a5 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -176,7 +176,7 @@ async function loadSavedPackageService(input: { } } -export async function readPackageServiceRpcResponse( +async function readPackageServiceRpcResponse( response: Response, ): Promise { const text = await response.text() From 597effc5374b3bf02ad1b6d92a5c6136002051e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 05:52:18 +0000 Subject: [PATCH 38/39] Harden package service registry calls Co-authored-by: Kent C. Dodds --- .../src/package-registry/service.node.test.ts | 49 ++++++++++++++++++ .../worker/src/package-registry/service.ts | 50 ++++++++++--------- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 2da763f62f..8aedd9d064 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -370,3 +370,52 @@ test('refreshSavedPackageProjection still syncs job manager when auto-start serv userId: 'user-1', }) }) + +test('deleteSavedPackageProjection still completes cleanup when service stop throws synchronously', async () => { + const env = createEnv() + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-1', + kodyId: 'shade-automation', + sourceId: 'source-1', + }) + mockModule.listSavedPackageServices.mockResolvedValue({ + savedPackage: { + id: 'package-1', + kodyId: 'shade-automation', + }, + services: [ + { + name: 'realtime-supervisor', + entry: 'services/realtime-supervisor.ts', + autoStart: true, + timeoutMs: null, + }, + ], + }) + mockModule.listJobRowsByUserId.mockResolvedValue([ + { id: 'job-1', source_id: 'source-1' }, + ]) + mockModule.packageServiceRpc.mockImplementation(() => { + throw new Error('stub unavailable') + }) + + await deleteSavedPackageProjection({ + env, + userId: 'user-1', + packageId: 'package-1', + }) + + expect(mockModule.deleteJobRow).toHaveBeenCalledWith({}, 'user-1', 'job-1') + expect(mockModule.deleteSavedPackage).toHaveBeenCalledWith({}, { + userId: 'user-1', + packageId: 'package-1', + }) + expect(mockModule.deleteSavedPackageVector).toHaveBeenCalledWith( + env, + 'package-1', + ) + expect(mockModule.syncJobManagerAlarm).toHaveBeenCalledWith({ + env, + userId: 'user-1', + }) +}) diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index faef77b840..1ee61d3932 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -153,19 +153,19 @@ export async function refreshSavedPackageProjection(input: { : []) { const definition = loaded.manifest.kody.services?.[service] if (!definition?.autoStart) continue - await packageServiceRpc({ - env: input.env, - userId: input.userId, - packageId: input.packageId, - kodyId: row.kody_id, - sourceId: row.source_id, - baseUrl: input.baseUrl, - serviceName: service, - }) - .start() - .catch(() => { - // Auto-start failures should not block package job sync/alarm refresh. - }) + try { + await packageServiceRpc({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + kodyId: row.kody_id, + sourceId: row.source_id, + baseUrl: input.baseUrl, + serviceName: service, + }).start() + } catch { + // Auto-start failures should not block package job sync/alarm refresh. + } } await syncJobManagerAlarm({ env: input.env, @@ -211,17 +211,19 @@ export async function deleteSavedPackageProjection(input: { }).catch(() => null) if (listedServices) { for (const service of listedServices.services) { - await packageServiceRpc({ - env: input.env, - userId: input.userId, - packageId: savedPackage.id, - kodyId: savedPackage.kodyId, - sourceId: savedPackage.sourceId, - baseUrl: 'https://package-service.invalid', - serviceName: service.name, - }) - .stop() - .catch(() => undefined) + try { + await packageServiceRpc({ + env: input.env, + userId: input.userId, + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + sourceId: savedPackage.sourceId, + baseUrl: 'https://package-service.invalid', + serviceName: service.name, + }).stop() + } catch { + // Best-effort shutdown of orphaned services during package deletion. + } } } const existingRows = await listJobRowsByUserId( From 07aba12e092db6d8f98e6871520780517a2c7230 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Apr 2026 06:05:23 +0000 Subject: [PATCH 39/39] Align package app service list shape Co-authored-by: Kent C. Dodds --- packages/worker/src/package-runtime/package-app.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 3f71aca857..b2b8d6a2ff 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -723,7 +723,10 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< // Keep the rest of the service list usable if one status lookup fails. } return { - ...service, + name: service.name, + entry: service.entry, + auto_start: service.autoStart, + timeout_ms: service.timeoutMs ?? null, status, } }),