From 19aef9adc7a0587d8574547310116962e1c671cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 18:17:06 +0000 Subject: [PATCH 01/15] add scheduled job facets runtime Co-authored-by: Kent C. Dodds --- docs/use/examples/job-feed-checker.md | 30 + docs/use/execute.md | 44 +- docs/use/index.md | 1 + docs/use/jobs.md | 142 +++++ packages/worker/migrations/0018-jobs.sql | 15 + packages/worker/src/index.ts | 3 + packages/worker/src/jobs/job-runner.ts | 579 ++++++++++++++++++ .../jobs/jobs-capabilities.workers.test.ts | 241 ++++++++ packages/worker/src/jobs/repo.ts | 173 ++++++ .../worker/src/jobs/schedule.node.test.ts | 67 ++ packages/worker/src/jobs/schedule.ts | 118 ++++ packages/worker/src/jobs/service.ts | 169 +++++ packages/worker/src/jobs/types.ts | 88 +++ .../build-capability-registry.workers.test.ts | 32 +- .../src/mcp/capabilities/builtin-domains.ts | 4 +- .../src/mcp/capabilities/domain-metadata.ts | 1 + .../src/mcp/capabilities/jobs/domain.ts | 406 ++++++++++++ .../src/mcp/capabilities/jobs/shared.ts | 140 +++++ packages/worker/src/mcp/facet-runtime.ts | 417 +++++++++++++ .../worker/src/mcp/server-instructions.ts | 2 +- packages/worker/src/mcp/tools/execute.ts | 7 +- packages/worker/src/mcp/tools/search.ts | 6 +- packages/worker/worker-configuration.d.ts | 107 +++- packages/worker/wrangler.jsonc | 22 +- 24 files changed, 2769 insertions(+), 45 deletions(-) create mode 100644 docs/use/examples/job-feed-checker.md create mode 100644 docs/use/jobs.md create mode 100644 packages/worker/migrations/0018-jobs.sql create mode 100644 packages/worker/src/jobs/job-runner.ts create mode 100644 packages/worker/src/jobs/jobs-capabilities.workers.test.ts create mode 100644 packages/worker/src/jobs/repo.ts create mode 100644 packages/worker/src/jobs/schedule.node.test.ts create mode 100644 packages/worker/src/jobs/schedule.ts create mode 100644 packages/worker/src/jobs/service.ts create mode 100644 packages/worker/src/jobs/types.ts create mode 100644 packages/worker/src/mcp/capabilities/jobs/domain.ts create mode 100644 packages/worker/src/mcp/capabilities/jobs/shared.ts create mode 100644 packages/worker/src/mcp/facet-runtime.ts diff --git a/docs/use/examples/job-feed-checker.md b/docs/use/examples/job-feed-checker.md new file mode 100644 index 0000000000..a1ed2521a0 --- /dev/null +++ b/docs/use/examples/job-feed-checker.md @@ -0,0 +1,30 @@ +# Example job: feed checker with incremental state + +This example shows a scheduled job that remembers the latest seen item id in its +facet SQLite storage and only emits new items on later runs. + +## Save it + +Create the job with `job_create`: + +```json +{ + "name": "Feed checker", + "schedule": { + "intervalMs": 3600000 + }, + "timezone": "America/Denver", + "enabled": true, + "serverCode": "import { DurableObject } from 'cloudflare:workers'\n\nexport class Job extends DurableObject {\n\tasync run() {\n\t\tconst feedUrlValue = await KODY.valueGet('feed_url', 'user')\n\t\tif (!feedUrlValue?.value) {\n\t\t\tthrow new Error('Missing persisted value \"feed_url\".')\n\t\t}\n\n\t\tconst response = await KODY.fetchViaHostGateway({\n\t\t\turl: feedUrlValue.value,\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\tAccept: 'application/json',\n\t\t\t},\n\t\t})\n\t\tif (response.status >= 400) {\n\t\t\tthrow new Error(`Feed request failed: ${response.status} ${response.statusText}`)\n\t\t}\n\n\t\tconst payload = JSON.parse(response.body)\n\t\tconst items = Array.isArray(payload.items) ? payload.items : []\n\t\tconst latestSeen = this.ctx.storage.sql\n\t\t\t.exec('SELECT item_id FROM feed_state WHERE singleton = 1')\n\t\t\t.one<{ item_id?: string }>()?.item_id ?? null\n\n\t\tthis.ctx.storage.sql.exec(\n\t\t\t'CREATE TABLE IF NOT EXISTS feed_state (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), item_id TEXT)',\n\t\t)\n\n\t\tconst freshItems = latestSeen\n\t\t\t? items.filter((item) => item?.id && item.id !== latestSeen)\n\t\t\t: items.slice(0, 1)\n\n\t\tif (items[0]?.id) {\n\t\t\tthis.ctx.storage.sql.exec(\n\t\t\t\t'INSERT INTO feed_state (singleton, item_id) VALUES (1, ?) ON CONFLICT(singleton) DO UPDATE SET item_id = excluded.item_id',\n\t\t\t\tString(items[0].id),\n\t\t\t)\n\t\t}\n\n\t\tif (freshItems.length === 0) {\n\t\t\treturn { newItems: 0 }\n\t\t}\n\n\t\treturn {\n\t\t\tnewItems: freshItems.length,\n\t\t\tlatestItemId: String(items[0]?.id ?? ''),\n\t\t\tnotifications: freshItems.map((item) => ({\n\t\t\t\tid: String(item.id),\n\t\t\t\ttitle: String(item.title ?? item.id),\n\t\t\t})),\n\t\t}\n\t}\n}\n" +} +``` + +## Notes + +- The job uses `KODY.valueGet('feed_url', 'user')` so the feed URL can be + changed without editing code. +- `KODY.fetchViaHostGateway()` uses the same secret-aware host approval path as + execute-time fetch. +- The `feed_state` table is local to the job facet, so every job keeps its own + checkpoint independently. +- Inspect the latest outcomes with `job_history({ job_id })`. diff --git a/docs/use/execute.md b/docs/use/execute.md index 96eb2c9f3a..c9c7d4d896 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -34,20 +34,36 @@ inline saved skill code into **execute** when that fits the workflow. ## Scheduled jobs -Kody can persist scheduled codemode jobs per user: - -- **`scheduler_upsert`** — create a new one-shot or recurring cron job, or - update an existing one when you pass **`id`** -- **`scheduler_list`** — list jobs with next run time, last run status, and a - human-readable schedule summary -- **`scheduler_get`** — inspect one job -- **`scheduler_delete`** — remove a job -- **`scheduler_run_now`** — trigger a job immediately without changing its - normal schedule - -Cron schedules use standard **5-field cron syntax** and may include an IANA -timezone such as **`America/New_York`**. One-shot schedules use an ISO 8601 UTC -timestamp such as **`2026-04-17T15:00:00Z`**. +Kody can persist alarm-driven scheduled jobs for the signed-in user: + +- **`job_create`** — create a new scheduled Durable Object facet job +- **`job_update`** — update a saved job in place; changing `serverCode` rotates + `serverCodeId` +- **`job_list`** — list jobs with schedule summary, next run time, counters, and + last error +- **`job_get`** — inspect one job +- **`job_delete`** — remove the saved job and delete its facet storage +- **`job_run_now`** — trigger a run immediately without changing the normal next + scheduled fire time +- **`job_enable`** / **`job_disable`** — toggle alarm scheduling +- **`job_history`** — inspect recent supervisor-tracked run history +- **`job_storage_reset`** — clear the facet SQLite database while keeping the + job +- **`job_server_exec`** — run one-off JavaScript inside the facet for debugging + or migrations + +Jobs define **`serverCode`** that exports **`class Job extends DurableObject`** +with an async **`run()`** method. The supervisor Durable Object loads that code +as a facet through Cloudflare's Dynamic Worker loader, keeps its own separate +SQLite status/history tables, and re-arms alarms after every run. + +Schedules support either: + +- cron: `{ cron: "0 8 * * *" }` +- interval: `{ intervalMs: 3600000 }` + +Cron schedules use standard **5-field cron syntax** and an IANA timezone such as +**`America/Denver`**. ## Long-term memory diff --git a/docs/use/index.md b/docs/use/index.md index b6ea3a80e6..be75fdf18c 100644 --- a/docs/use/index.md +++ b/docs/use/index.md @@ -12,6 +12,7 @@ Read in order for a full tour, or jump to a topic. - [Search](./search.md) - [Execute and workflows](./execute.md) — includes per-user MCP instruction overlays +- [Scheduled jobs](./jobs.md) - [Saved app backends](./saved-app-backends.md) - [Raw MCP content blocks](./raw-content-blocks.md) - [Secrets, values, and host approval](./secrets-and-values.md) diff --git a/docs/use/jobs.md b/docs/use/jobs.md new file mode 100644 index 0000000000..197a55d0a8 --- /dev/null +++ b/docs/use/jobs.md @@ -0,0 +1,142 @@ +# Scheduled jobs + +Kody scheduled jobs persist agent-authored Durable Object code and run it on an +alarm-based schedule. + +Each saved job has: + +- **`serverCode`** — Durable Object code that exports + `class Job extends DurableObject` +- **`serverCodeId`** — a UUID that rotates whenever the server code changes so + the Dynamic Worker loader does not reuse stale code +- **`schedule`** — either a cron expression or an interval in milliseconds +- **`timezone`** — IANA timezone for cron evaluation, defaulting to + **`America/Denver`** +- **`enabled`** — whether the alarm should keep firing + +Jobs are managed with these capabilities: + +- `job_create` +- `job_update` +- `job_delete` +- `job_list` +- `job_get` +- `job_run_now` +- `job_enable` +- `job_disable` +- `job_history` +- `job_storage_reset` +- `job_server_exec` + +## Execution model + +Each job gets its own **`JobRunner`** supervisor Durable Object keyed by +`job_id`. The supervisor: + +- stores observability metadata in its own SQLite database +- loads the job server code as a **Durable Object Facet** +- invokes the job facet by calling **`await job.run()`** +- computes the next alarm time and re-arms itself + +The facet gets its own isolated Durable Object SQLite storage, so a job can +store incremental state with `this.ctx.storage.sql` or `this.ctx.storage.kv` +without mixing that state into supervisor metadata. + +## Schedule semantics + +Jobs support two schedule shapes: + +```json +{ "cron": "0 8 * * *" } +``` + +or + +```json +{ "intervalMs": 3600000 } +``` + +Cron uses standard **5-field** syntax and is interpreted in the configured IANA +timezone. + +Interval schedules are measured from the **completion time of the last run**. +This avoids overlapping back-to-back alarm executions when a run takes a long +time. + +### Alarm drift and overruns + +- Kody re-arms the next alarm **after** each run finishes. +- If a run takes longer than its nominal interval, the next run is scheduled + from the completion timestamp rather than trying to "catch up" missed + intervals. +- `job_run_now` does **not** replace the normal schedule. It records a manual + run and preserves the existing next scheduled alarm. + +## Authoring `serverCode` + +Job code must export: + +```ts +import { DurableObject } from 'cloudflare:workers' + +export class Job extends DurableObject { + async run() { + return { ok: true } + } +} +``` + +There is no HTTP `fetch()` surface for jobs. The entrypoint is `run()`. + +## `KODY` bridge inside jobs + +Job facets do not get unrestricted outbound networking. Instead they receive a +`KODY` binding with a controlled bridge back into Kody: + +- `fetchWithResolvedSecrets({ url, method, headers, body })` +- `fetchViaHostGateway({ url, method, headers, body })` +- `valueGet(name, scope?)` +- `valueSet({ name, value, description?, scope? })` +- `connectorGet(args)` +- `connectorList()` +- `metaRunSkill(name, params?)` +- `secretPlaceholder(name, scope?)` + +`fetchViaHostGateway()` performs the outbound request through Kody's host +gateway and honors the same secret placeholder and host approval rules as +execute-time fetch. + +The default bridge scope is job-local, but it maps onto Kody's existing +app-scoped storage buckets using a stable internal binding key for each job. +That means job-owned values and secrets stay isolated from saved apps and from +other jobs. + +## Kill switch and observability + +Each `JobRunner` keeps supervisor-owned metadata: + +- run count +- success count +- failure count +- last run started/finished timestamps +- last run duration +- last error message and stack +- bounded run history (default last 50) +- kill switch flag + +When the kill switch is on, alarm executions are skipped and the next alarm is +cleared until the job is re-enabled. + +Use `job_history({ job_id, limit? })` to inspect recent runs. + +## Storage reset and server exec + +- `job_storage_reset({ job_id })` deletes the facet storage but keeps the saved + job record and supervisor history. +- `job_server_exec({ job_id, code, params? })` executes one-off JavaScript + against the live facet instance for debugging or data migrations. + +## Example + +See [Feed watcher example](./examples/job-feed-watcher.md) for a job that keeps +track of the last-seen item id in SQLite and only emits new items on later runs. diff --git a/packages/worker/migrations/0018-jobs.sql b/packages/worker/migrations/0018-jobs.sql new file mode 100644 index 0000000000..e25f649b14 --- /dev/null +++ b/packages/worker/migrations/0018-jobs.sql @@ -0,0 +1,15 @@ +CREATE TABLE jobs ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + server_code TEXT NOT NULL, + server_code_id TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); + +CREATE INDEX idx_jobs_user_id ON jobs(user_id); +CREATE INDEX idx_jobs_user_updated ON jobs(user_id, updated_at DESC); diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 321d0f61f7..172534c000 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -6,6 +6,7 @@ import { HomeMCP } from './home/mcp.ts' import { MCP } from './mcp/index.ts' import { SchedulerDO } from './scheduler/scheduler-do.ts' import { AppFacetBridge, AppRunner } from './mcp/app-runner.ts' +import { JobFacetBridge, JobRunner } from './jobs/job-runner.ts' import { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts' import { getWorkerSentryOptions } from './sentry-options.ts' import { handleRequest } from '#app/handler.ts' @@ -50,6 +51,8 @@ export { SchedulerDO, AppFacetBridge, AppRunner, + JobFacetBridge, + JobRunner, } const claudeWidgetDomainSuffix = '.claudemcpcontent.com' diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts new file mode 100644 index 0000000000..fb63f55157 --- /dev/null +++ b/packages/worker/src/jobs/job-runner.ts @@ -0,0 +1,579 @@ +import * as Sentry from '@sentry/cloudflare' +import { DurableObject } from 'cloudflare:workers' +import { buildSentryOptions } from '#worker/sentry-options.ts' +import { + type FacetStorageExport, + FacetKodyBridge, + callFacetExec, + callFacetStorageExport, + callFacetStorageReset, + createFacetStartup, + jsonResponse, + toFacetErrorDetails, +} from '#mcp/facet-runtime.ts' +import { getJobById } from './repo.ts' +import { computeNextJobRunAt, formatJobScheduleSummary } from './schedule.ts' +import { buildJobStorageBindingId } from './service.ts' +import { + type JobDetails, + type JobRecord, + type JobRunHistoryEntry, + type JobRunResult, + type JobRunTrigger, + type JobRunnerStatus, +} from './types.ts' + +const jobFacetClassName = 'Job' +const jobFacetName = 'job' +const jobFacetIdPrefix = 'job-facet' +const configStorageKey = 'config' +const defaultHistoryLimit = 50 + +type JobRunnerConfig = { + jobId: string + userId: string + baseUrl: string + serverCode: string + serverCodeId: string + enabled: boolean + timezone: string + schedule: JobRecord['schedule'] + historyLimit: number + killSwitchEnabled: boolean +} + +function defaultConfig(jobId: string): JobRunnerConfig { + return { + jobId, + userId: '', + baseUrl: '', + serverCode: '', + serverCodeId: crypto.randomUUID(), + enabled: false, + timezone: 'America/Denver', + schedule: { intervalMs: 60_000 }, + historyLimit: defaultHistoryLimit, + killSwitchEnabled: false, + } +} + +export class JobFacetBridge extends FacetKodyBridge {} + +class JobRunnerBase extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + this.ctx.blockConcurrencyWhile(async () => { + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS runner_status ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + next_run_at TEXT, + run_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + failure_count INTEGER NOT NULL DEFAULT 0, + last_run_started_at TEXT, + last_run_finished_at TEXT, + last_run_duration_ms INTEGER, + last_error_message TEXT, + last_error_stack TEXT, + kill_switch_enabled INTEGER NOT NULL DEFAULT 0, + history_limit INTEGER NOT NULL DEFAULT ${defaultHistoryLimit} + ) + `) + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS run_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT NOT NULL, + status TEXT NOT NULL, + scheduled_for TEXT, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + error_message TEXT, + error_stack TEXT + ) + `) + this.ctx.storage.sql.exec( + `INSERT OR IGNORE INTO runner_status (singleton) VALUES (1)`, + ) + }) + } + + async configure(input: { + job: JobRecord + baseUrl: string + historyLimit?: number + killSwitchEnabled?: boolean + recomputeNextRunAt?: boolean + }) { + const existing = await this.readConfig(input.job.id) + const existingStatus = await this.readRunnerStatus() + const nextConfig: JobRunnerConfig = { + ...existing, + jobId: input.job.id, + userId: input.job.userId, + baseUrl: input.baseUrl, + serverCode: input.job.serverCode, + serverCodeId: input.job.serverCodeId, + enabled: input.job.enabled, + timezone: input.job.timezone, + schedule: input.job.schedule, + historyLimit: input.historyLimit ?? existing.historyLimit, + killSwitchEnabled: input.killSwitchEnabled ?? existing.killSwitchEnabled, + } + await this.writeConfig(nextConfig) + await this.writeRunnerStatus({ + nextRunAt: nextConfig.enabled + ? input.recomputeNextRunAt || existing.userId === '' + ? computeNextJobRunAt({ + schedule: nextConfig.schedule, + timezone: nextConfig.timezone, + }) + : existingStatus.nextRunAt + : null, + killSwitchEnabled: nextConfig.killSwitchEnabled, + historyLimit: nextConfig.historyLimit, + }) + if ( + existing.serverCodeId !== nextConfig.serverCodeId || + existing.serverCode !== nextConfig.serverCode + ) { + this.ctx.facets.abort(jobFacetName, new Error('Job server code updated.')) + } + await this.syncAlarm() + return await this.getDetails() + } + + async getDetails(): Promise { + const config = await this.readConfig(this.ctx.id.toString()) + const status = await this.readRunnerStatus() + return { + id: config.jobId, + userId: config.userId, + name: (await this.requireJob()).name, + serverCode: config.serverCode, + serverCodeId: config.serverCodeId, + schedule: config.schedule, + timezone: config.timezone, + enabled: config.enabled, + createdAt: (await this.requireJob()).createdAt, + updatedAt: (await this.requireJob()).updatedAt, + ...status, + scheduleSummary: await this.getScheduleSummary(), + } + } + + async getHistory(limit?: number) { + return this.readRunHistory(limit) + } + + async setEnabled(input: { enabled: boolean }) { + const config = await this.readConfig(this.ctx.id.toString()) + config.enabled = input.enabled + await this.writeConfig(config) + await this.writeRunnerStatus({ + nextRunAt: input.enabled + ? computeNextJobRunAt({ + schedule: config.schedule, + timezone: config.timezone, + }) + : null, + }) + await this.syncAlarm() + return await this.getDetails() + } + + async setKillSwitch(input: { killSwitchEnabled: boolean }) { + const config = await this.readConfig(this.ctx.id.toString()) + config.killSwitchEnabled = input.killSwitchEnabled + await this.writeConfig(config) + await this.writeRunnerStatus({ + killSwitchEnabled: input.killSwitchEnabled, + }) + await this.syncAlarm() + return await this.getDetails() + } + + async resetStorage() { + const facet = await this.getFacetStub() + await callFacetStorageReset(facet) + this.ctx.facets.abort(jobFacetName, new Error('Facet storage reset.')) + return { ok: true as const } + } + + async exportStorage() { + const facet = await this.getFacetStub() + return await callFacetStorageExport(facet) + } + + async execServer(input: { code: string; params?: Record }) { + const facet = await this.getFacetStub() + return await callFacetExec(facet, input.code, input.params) + } + + async deleteRunner() { + this.ctx.facets.delete(jobFacetName) + await this.ctx.storage.deleteAll() + return { ok: true as const } + } + + async runNow() { + return await this.executeRun({ + trigger: 'run_now', + scheduledFor: null, + advanceSchedule: false, + }) + } + + async alarm() { + await this.executeRun({ + trigger: 'alarm', + scheduledFor: (await this.readRunnerStatus()).nextRunAt, + advanceSchedule: true, + }) + } + + private async executeRun(input: { + trigger: JobRunTrigger + scheduledFor: string | null + advanceSchedule: boolean + }) { + const config = await this.readConfig(this.ctx.id.toString()) + if (!config.enabled || config.killSwitchEnabled) { + await this.syncAlarm() + return { + ok: false as const, + skipped: true as const, + } + } + const startedAt = new Date() + const startedMs = startedAt.getTime() + const result = await this.runFacet().catch((error) => ({ + ok: false as const, + error: toFacetErrorDetails(error), + })) + const finishedAt = new Date() + const durationMs = Math.max(0, finishedAt.getTime() - startedMs) + const status = await this.readRunnerStatus() + const nextRunAt = input.advanceSchedule + ? computeNextJobRunAt({ + schedule: config.schedule, + timezone: config.timezone, + from: finishedAt, + }) + : status.nextRunAt + await this.writeRunnerStatus({ + nextRunAt, + runCount: status.runCount + 1, + successCount: status.successCount + (result.ok ? 1 : 0), + failureCount: status.failureCount + (result.ok ? 0 : 1), + lastRunStartedAt: startedAt.toISOString(), + lastRunFinishedAt: finishedAt.toISOString(), + lastRunDurationMs: durationMs, + lastError: result.ok ? null : result.error, + }) + await this.insertRunHistory({ + trigger: input.trigger, + status: result.ok ? 'success' : 'failure', + scheduledFor: input.scheduledFor, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs, + error: result.ok ? null : result.error, + }) + await this.pruneRunHistory() + await this.syncAlarm() + return result + } + + private async runFacet(): Promise { + const facet = await this.getFacetStub() + try { + const result = await ( + facet as unknown as { + run: () => Promise + } + ).run() + return { ok: true, result } + } catch (error) { + return { + ok: false, + error: toFacetErrorDetails(error), + } + } + } + + private async getFacetStub() { + const config = await this.readConfig(this.ctx.id.toString()) + if (config.killSwitchEnabled) { + throw jsonResponse({ ok: false, error: 'Job runner is disabled.' }, 503) + } + if (!config.serverCode) { + throw jsonResponse( + { ok: false, error: 'Job server code is missing.' }, + 404, + ) + } + return this.ctx.facets.get(jobFacetName, async () => + createFacetStartup({ + loader: this.env.APP_LOADER, + cacheKey: `${config.jobId}:${config.serverCodeId}`, + serverCode: config.serverCode, + facetName: jobFacetName, + baseClassName: jobFacetClassName, + expectedExportDescription: + 'Job server code must export class Job extends DurableObject.', + facetIdPrefix: jobFacetIdPrefix, + bridgeBindingName: 'KODY', + bridgeBinding: this.ctx.exports.JobFacetBridge({ + props: { + userId: config.userId, + baseUrl: config.baseUrl || 'http://internal.invalid', + storageBindingId: buildJobStorageBindingId(config.jobId), + scopeLabel: 'job', + displayName: 'scheduled-job', + facetName: jobFacetName, + }, + }), + }), + ) + } + + private async requireJob() { + const config = await this.readConfig(this.ctx.id.toString()) + const job = await getJobById(this.env.APP_DB, config.userId, config.jobId) + if (!job) { + throw new Error(`Job "${config.jobId}" was not found.`) + } + return job + } + + private async getScheduleSummary() { + const config = await this.readConfig(this.ctx.id.toString()) + return formatJobScheduleSummary({ + schedule: config.schedule, + timezone: config.timezone, + }) + } + + private async readConfig(jobId: string) { + const config = await this.ctx.storage.get(configStorageKey) + return config ?? defaultConfig(jobId) + } + + private async writeConfig(config: JobRunnerConfig) { + await this.ctx.storage.put(configStorageKey, config) + } + + private async readRunnerStatus(): Promise { + const row = this.ctx.storage.sql + .exec<{ + next_run_at: string | null + run_count: number + success_count: number + failure_count: number + last_run_started_at: string | null + last_run_finished_at: string | null + last_run_duration_ms: number | null + last_error_message: string | null + last_error_stack: string | null + kill_switch_enabled: number + history_limit: number + }>( + `SELECT next_run_at, run_count, success_count, failure_count, + last_run_started_at, last_run_finished_at, last_run_duration_ms, + last_error_message, last_error_stack, kill_switch_enabled, history_limit + FROM runner_status + WHERE singleton = 1`, + ) + .one() + return { + nextRunAt: row?.next_run_at ?? null, + runCount: Number(row?.run_count ?? 0), + successCount: Number(row?.success_count ?? 0), + failureCount: Number(row?.failure_count ?? 0), + lastRunStartedAt: row?.last_run_started_at ?? null, + lastRunFinishedAt: row?.last_run_finished_at ?? null, + lastRunDurationMs: + row?.last_run_duration_ms == null + ? null + : Number(row.last_run_duration_ms), + lastError: + row?.last_error_message == null + ? null + : { + message: row.last_error_message, + stack: row.last_error_stack ?? null, + }, + killSwitchEnabled: Number(row?.kill_switch_enabled ?? 0) === 1, + historyLimit: Number(row?.history_limit ?? defaultHistoryLimit), + } + } + + private async writeRunnerStatus(input: Partial) { + const current = await this.readRunnerStatus() + const next = { + ...current, + ...input, + } + this.ctx.storage.sql.exec( + `UPDATE runner_status SET + next_run_at = ?, + run_count = ?, + success_count = ?, + failure_count = ?, + last_run_started_at = ?, + last_run_finished_at = ?, + last_run_duration_ms = ?, + last_error_message = ?, + last_error_stack = ?, + kill_switch_enabled = ?, + history_limit = ? + WHERE singleton = 1`, + next.nextRunAt, + next.runCount, + next.successCount, + next.failureCount, + next.lastRunStartedAt, + next.lastRunFinishedAt, + next.lastRunDurationMs, + next.lastError?.message ?? null, + next.lastError?.stack ?? null, + next.killSwitchEnabled ? 1 : 0, + next.historyLimit, + ) + } + + private async insertRunHistory(entry: Omit) { + this.ctx.storage.sql.exec( + `INSERT INTO run_history ( + trigger, status, scheduled_for, started_at, finished_at, duration_ms, + error_message, error_stack + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + entry.trigger, + entry.status, + entry.scheduledFor, + entry.startedAt, + entry.finishedAt, + entry.durationMs, + entry.error?.message ?? null, + entry.error?.stack ?? null, + ) + } + + private async readRunHistory(limit = defaultHistoryLimit) { + const rows = this.ctx.storage.sql + .exec<{ + id: number + trigger: JobRunTrigger + status: JobRunHistoryEntry['status'] + scheduled_for: string | null + started_at: string + finished_at: string + duration_ms: number + error_message: string | null + error_stack: string | null + }>( + `SELECT id, trigger, status, scheduled_for, started_at, finished_at, + duration_ms, error_message, error_stack + FROM run_history + ORDER BY id DESC + LIMIT ?`, + limit, + ) + .toArray() + return rows.map((row) => ({ + id: Number(row.id), + trigger: row.trigger, + status: row.status, + scheduledFor: row.scheduled_for, + startedAt: row.started_at, + finishedAt: row.finished_at, + durationMs: Number(row.duration_ms), + error: + row.error_message == null + ? null + : { + message: row.error_message, + stack: row.error_stack ?? null, + }, + })) + } + + private async pruneRunHistory() { + const status = await this.readRunnerStatus() + this.ctx.storage.sql.exec( + `DELETE FROM run_history + WHERE id NOT IN ( + SELECT id FROM run_history ORDER BY id DESC LIMIT ? + )`, + status.historyLimit, + ) + } + + private async syncAlarm() { + const config = await this.readConfig(this.ctx.id.toString()) + const status = await this.readRunnerStatus() + if (!config.enabled || config.killSwitchEnabled || !status.nextRunAt) { + await this.ctx.storage.deleteAlarm() + return + } + await this.ctx.storage.setAlarm(new Date(status.nextRunAt)) + } +} + +export const JobRunner = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => buildSentryOptions(env), + JobRunnerBase, +) + +export function jobRunnerRpc(env: Env, jobId: string) { + return env.JOB_RUNNER.get(env.JOB_RUNNER.idFromName(jobId)) as unknown as { + configure: (payload: { + job: JobRecord + baseUrl: string + historyLimit?: number + killSwitchEnabled?: boolean + recomputeNextRunAt?: boolean + }) => Promise + getDetails: () => Promise + getHistory: (limit?: number) => Promise> + setEnabled: (payload: { enabled: boolean }) => Promise + setKillSwitch: (payload: { + killSwitchEnabled: boolean + }) => Promise + resetStorage: () => Promise<{ ok: true }> + exportStorage: () => Promise + execServer: (payload: { + code: string + params?: Record + }) => Promise + deleteRunner: () => Promise<{ ok: true }> + runNow: () => Promise< + | { ok: true; result: unknown } + | { ok: false; error: { message: string; stack: string | null } } + | { ok: false; skipped: true } + > + } +} + +export async function syncJobRunnerFromDb(input: { + env: Env + userId: string + jobId: string + baseUrl: string + historyLimit?: number + killSwitchEnabled?: boolean + recomputeNextRunAt?: boolean +}) { + const job = await getJobById(input.env.APP_DB, input.userId, input.jobId) + if (!job) { + return null + } + return await jobRunnerRpc(input.env, input.jobId).configure({ + job, + baseUrl: input.baseUrl, + historyLimit: input.historyLimit, + killSwitchEnabled: input.killSwitchEnabled, + recomputeNextRunAt: input.recomputeNextRunAt, + }) +} diff --git a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts new file mode 100644 index 0000000000..6003465b3c --- /dev/null +++ b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts @@ -0,0 +1,241 @@ +import { env } from 'cloudflare:workers' +import { runDurableObjectAlarm } from 'cloudflare:test' +import { expect, test } from 'vitest' +import { capabilityMap } from '#mcp/capabilities/registry.ts' +import { createMcpCallerContext } from '#mcp/context.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' + +async function ensureJobsTable() { + await env.APP_DB.prepare( + `CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + server_code TEXT NOT NULL, + server_code_id TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'America/Denver', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + )`, + ).run() + await env.APP_DB.prepare( + `CREATE INDEX IF NOT EXISTS idx_jobs_user_id ON jobs(user_id)`, + ).run() +} + +function createCapabilityContext(): CapabilityContext { + return { + env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-123', + email: 'user@example.com', + displayName: 'Jobs Tester', + }, + }), + } +} + +function createJobCode(flavor: string) { + return ` + import { DurableObject } from 'cloudflare:workers' + + export class Job extends DurableObject { + async run() { + const count = Number(this.ctx.storage.kv.get('count') ?? 0) + 1 + this.ctx.storage.kv.put('count', count) + return { count, flavor: '${flavor}' } + } + } + `.trim() +} + +const failingJobCode = ` + import { DurableObject } from 'cloudflare:workers' + + export class Job extends DurableObject { + async run() { + throw new Error('boom from job') + } + } +`.trim() + +test('job capabilities create, update, run, and delete cron jobs end-to-end', async () => { + await ensureJobsTable() + const ctx = createCapabilityContext() + const createJob = capabilityMap['job_create']!.handler + const jobList = capabilityMap['job_list']!.handler + const jobGet = capabilityMap['job_get']!.handler + const jobUpdate = capabilityMap['job_update']!.handler + const jobRunNow = capabilityMap['job_run_now']!.handler + const jobHistory = capabilityMap['job_history']!.handler + const jobDelete = capabilityMap['job_delete']!.handler + const created = await createJob( + { + name: 'Morning sync', + serverCode: createJobCode('alpha'), + schedule: { cron: '0 8 * * *' }, + timezone: 'America/Denver', + enabled: true, + }, + ctx, + ) + + expect(created.name).toBe('Morning sync') + expect(created.scheduleSummary).toContain('cron') + expect(typeof created.nextRunAt).toBe('string') + + const listed = await jobList({}, ctx) + expect(listed.map((job: { id: string }) => job.id)).toContain(created.id) + + const fetched = await jobGet({ job_id: created.id }, ctx) + expect(fetched.id).toBe(created.id) + + const alarmRan = await runDurableObjectAlarm( + env.JOB_RUNNER.get(env.JOB_RUNNER.idFromName(created.id)), + ) + expect(alarmRan).toBe(true) + + const historyAfterAlarm = await jobHistory( + { job_id: created.id, limit: 10 }, + ctx, + ) + expect(historyAfterAlarm).toHaveLength(1) + expect(historyAfterAlarm[0]).toMatchObject({ + trigger: 'alarm', + status: 'success', + error: null, + }) + + const updated = await jobUpdate( + { + job_id: created.id, + patch: { + name: 'Morning sync beta', + serverCode: createJobCode('beta'), + }, + }, + ctx, + ) + expect(updated.name).toBe('Morning sync beta') + expect(updated.serverCodeId).not.toBe(created.serverCodeId) + + const runNow = await jobRunNow({ job_id: created.id }, ctx) + expect(runNow.execution).toMatchObject({ + ok: true, + result: { + count: 2, + flavor: 'beta', + }, + }) + expect(runNow.job.runCount).toBe(2) + expect(runNow.job.successCount).toBe(2) + + const deleted = await jobDelete({ job_id: created.id }, ctx) + expect(deleted).toEqual({ + job_id: created.id, + deleted: true, + }) + + const listedAfterDelete = await jobList({}, ctx) + expect(listedAfterDelete).toEqual([]) +}) + +test('job capabilities handle interval jobs, enable/disable, kill switch, and error history', async () => { + await ensureJobsTable() + const ctx = createCapabilityContext() + const createJob = capabilityMap['job_create']!.handler + const jobEnable = capabilityMap['job_enable']!.handler + const jobDisable = capabilityMap['job_disable']!.handler + const jobUpdate = capabilityMap['job_update']!.handler + const jobRunNow = capabilityMap['job_run_now']!.handler + const jobHistory = capabilityMap['job_history']!.handler + const jobGet = capabilityMap['job_get']!.handler + const created = await createJob( + { + name: 'Interval watcher', + serverCode: createJobCode('interval'), + schedule: { intervalMs: 60_000 }, + enabled: false, + }, + ctx, + ) + + expect(created.enabled).toBe(false) + expect(created.nextRunAt).toBeNull() + + const skippedWhileDisabled = await jobRunNow({ job_id: created.id }, ctx) + expect(skippedWhileDisabled.execution).toEqual({ + ok: false, + skipped: true, + }) + + const enabled = await jobEnable({ job_id: created.id }, ctx) + expect(enabled.enabled).toBe(true) + expect(enabled.nextRunAt).toEqual(expect.any(String)) + expect(enabled.scheduleSummary).toContain('Runs every') + + const killSwitched = await jobUpdate( + { + job_id: created.id, + patch: { + kill_switch_enabled: true, + }, + }, + ctx, + ) + expect(killSwitched.killSwitchEnabled).toBe(true) + + const skippedWhileKillSwitched = await jobRunNow({ job_id: created.id }, ctx) + expect(skippedWhileKillSwitched.execution).toEqual({ + ok: false, + skipped: true, + }) + + const failing = await jobUpdate( + { + job_id: created.id, + patch: { + kill_switch_enabled: false, + serverCode: failingJobCode, + }, + }, + ctx, + ) + expect(failing.killSwitchEnabled).toBe(false) + + const failedRun = await jobRunNow({ job_id: created.id }, ctx) + expect(failedRun.execution).toMatchObject({ + ok: false, + error: { + message: 'boom from job', + }, + }) + + const history = await jobHistory({ job_id: created.id, limit: 10 }, ctx) + expect(history[0]).toMatchObject({ + trigger: 'run_now', + status: 'failure', + error: { + message: 'boom from job', + }, + }) + + const fetched = await jobGet({ job_id: created.id }, ctx) + expect(fetched.lastError).toMatchObject({ + message: 'boom from job', + }) + expect(fetched.failureCount).toBe(1) + + const disabled = await jobDisable({ job_id: created.id }, ctx) + expect(disabled.enabled).toBe(false) + expect(disabled.nextRunAt).toBeNull() + + const alarmRan = await runDurableObjectAlarm( + env.JOB_RUNNER.get(env.JOB_RUNNER.idFromName(created.id)), + ) + expect(alarmRan).toBe(false) +}) diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts new file mode 100644 index 0000000000..d6b3412a64 --- /dev/null +++ b/packages/worker/src/jobs/repo.ts @@ -0,0 +1,173 @@ +import { type JobRecord, type JobSchedule } from './types.ts' + +export async function insertJob( + db: D1Database, + row: { + id: string + user_id: string + name: string + serverCode: string + serverCodeId: string + schedule: JobSchedule + timezone: string + enabled: boolean + created_at?: string + updated_at?: string + }, +): Promise { + const now = new Date().toISOString() + await db + .prepare( + `INSERT INTO jobs ( + id, user_id, name, server_code, server_code_id, schedule_json, + timezone, enabled, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + row.id, + row.user_id, + row.name, + row.serverCode, + row.serverCodeId, + JSON.stringify(row.schedule), + row.timezone, + row.enabled ? 1 : 0, + row.created_at ?? now, + row.updated_at ?? now, + ) + .run() +} + +export async function getJobById( + db: D1Database, + userId: string, + jobId: string, +): Promise { + const row = await db + .prepare( + `SELECT id, user_id, name, server_code, server_code_id, schedule_json, + timezone, enabled, created_at, updated_at + FROM jobs + WHERE id = ? AND user_id = ? + LIMIT 1`, + ) + .bind(jobId, userId) + .first>() + if (!row) return null + return mapJobRow(row) +} + +export async function listJobsByUserId( + db: D1Database, + userId: string, +): Promise> { + const { results } = await db + .prepare( + `SELECT id, user_id, name, server_code, server_code_id, schedule_json, + timezone, enabled, created_at, updated_at + FROM jobs + WHERE user_id = ? + ORDER BY updated_at DESC, name ASC`, + ) + .bind(userId) + .all>() + return (results ?? []).map(mapJobRow) +} + +export async function updateJob( + db: D1Database, + userId: string, + jobId: string, + updates: Partial<{ + name: string + serverCode: string + serverCodeId: string + schedule: JobSchedule + timezone: string + enabled: boolean + }>, +): Promise { + const assignments: Array = [] + const values: Array = [] + function addAssignment(column: string, value: unknown) { + assignments.push(`${column} = ?`) + values.push(value) + } + + if (updates.name !== undefined) { + addAssignment('name', updates.name) + } + if (updates.serverCode !== undefined) { + addAssignment('server_code', updates.serverCode) + } + if (updates.serverCodeId !== undefined) { + addAssignment('server_code_id', updates.serverCodeId) + } + if (updates.schedule !== undefined) { + addAssignment('schedule_json', JSON.stringify(updates.schedule)) + } + if (updates.timezone !== undefined) { + addAssignment('timezone', updates.timezone) + } + if (updates.enabled !== undefined) { + addAssignment('enabled', updates.enabled ? 1 : 0) + } + addAssignment('updated_at', new Date().toISOString()) + + const result = await db + .prepare( + `UPDATE jobs SET ${assignments.join(', ')} WHERE id = ? AND user_id = ?`, + ) + .bind(...values, jobId, userId) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function deleteJob( + db: D1Database, + userId: string, + jobId: string, +): Promise { + const result = await db + .prepare(`DELETE FROM jobs WHERE id = ? AND user_id = ?`) + .bind(jobId, userId) + .run() + return (result.meta.changes ?? 0) > 0 +} + +function mapJobRow(row: Record): JobRecord { + return { + id: String(row['id']), + userId: String(row['user_id']), + name: String(row['name']), + serverCode: String(row['server_code']), + serverCodeId: String(row['server_code_id']), + schedule: parseJobSchedule(row['schedule_json']), + timezone: String(row['timezone']), + enabled: + row['enabled'] === 1 || row['enabled'] === '1' || row['enabled'] === true, + createdAt: String(row['created_at']), + updatedAt: String(row['updated_at']), + } +} + +function parseJobSchedule(raw: unknown): JobSchedule { + if (typeof raw !== 'string' || !raw.trim()) { + throw new Error('Stored job schedule is missing.') + } + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Stored job schedule must be an object.') + } + if ('cron' in parsed && typeof parsed['cron'] === 'string') { + return { cron: parsed['cron'] } + } + if ( + 'intervalMs' in parsed && + typeof parsed['intervalMs'] === 'number' && + Number.isFinite(parsed['intervalMs']) + ) { + return { intervalMs: parsed['intervalMs'] } + } + throw new Error('Stored job schedule has an unsupported shape.') +} diff --git a/packages/worker/src/jobs/schedule.node.test.ts b/packages/worker/src/jobs/schedule.node.test.ts new file mode 100644 index 0000000000..e5bc289b1e --- /dev/null +++ b/packages/worker/src/jobs/schedule.node.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'vitest' +import { + computeNextJobRunAt, + formatJobScheduleSummary, + normalizeJobSchedule, + normalizeJobTimezone, +} from './schedule.ts' + +test('computeNextJobRunAt handles timezone-aware cron schedules', () => { + const nextRunAt = computeNextJobRunAt({ + schedule: { + cron: '0 8 * * *', + }, + timezone: 'America/Denver', + from: '2026-04-13T12:30:00.000Z', + }) + + expect(nextRunAt).toBe('2026-04-13T14:00:00.000Z') +}) + +test('computeNextJobRunAt handles fixed intervals', () => { + const nextRunAt = computeNextJobRunAt({ + schedule: { + intervalMs: 3_600_000, + }, + from: '2026-04-13T12:30:00.000Z', + }) + + expect(nextRunAt).toBe('2026-04-13T13:30:00.000Z') +}) + +test('normalizeJobSchedule trims cron expressions and validates interval', () => { + expect( + normalizeJobSchedule({ + cron: ' */15 8 * * 1-5 ', + }), + ).toEqual({ + cron: '*/15 8 * * 1-5', + }) + + expect( + normalizeJobSchedule({ + intervalMs: 90_500.6, + }), + ).toEqual({ + intervalMs: 90_500, + }) +}) + +test('formatJobScheduleSummary includes timezone or interval', () => { + expect( + formatJobScheduleSummary({ + schedule: { cron: '0 8 * * *' }, + timezone: 'America/Los_Angeles', + }), + ).toBe('Runs on cron "0 8 * * *" in America/Los_Angeles') + + expect( + formatJobScheduleSummary({ + schedule: { intervalMs: 5_000 }, + }), + ).toBe('Runs every 5,000ms') +}) + +test('normalizeJobTimezone defaults to Kent timezone', () => { + expect(normalizeJobTimezone(null)).toBe('America/Denver') +}) diff --git a/packages/worker/src/jobs/schedule.ts b/packages/worker/src/jobs/schedule.ts new file mode 100644 index 0000000000..96cbbca1e8 --- /dev/null +++ b/packages/worker/src/jobs/schedule.ts @@ -0,0 +1,118 @@ +import { Cron } from 'croner' +import { type JobRecord, type JobSchedule, type JobSummary } from './types.ts' + +export const defaultJobTimezone = 'America/Denver' +const cronFieldPattern = /\s+/ +const minimumIntervalMs = 1_000 + +export function normalizeJobTimezone(timezone?: string | null) { + return timezone?.trim() || defaultJobTimezone +} + +export function assertValidJobTimezone(timezone: string) { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()) + } catch { + throw new Error(`Invalid IANA timezone "${timezone}".`) + } +} + +export function assertValidJobCronExpression(expression: string) { + const trimmed = expression.trim() + const fields = trimmed.split(cronFieldPattern).filter(Boolean) + if (fields.length !== 5) { + throw new Error( + 'Cron schedules must use standard 5-field syntax: minute hour day-of-month month day-of-week.', + ) + } + return fields.join(' ') +} + +export function normalizeJobIntervalMs(intervalMs: number) { + if (!Number.isFinite(intervalMs) || intervalMs < minimumIntervalMs) { + throw new Error( + `Interval schedules require intervalMs >= ${minimumIntervalMs}.`, + ) + } + return Math.trunc(intervalMs) +} + +export function normalizeJobSchedule(schedule: JobSchedule): JobSchedule { + if ('cron' in schedule) { + return { + cron: assertValidJobCronExpression(schedule.cron), + } + } + return { + intervalMs: normalizeJobIntervalMs(schedule.intervalMs), + } +} + +export function formatJobScheduleSummary(input: { + schedule: JobSchedule + timezone?: string | null +}) { + const timezone = normalizeJobTimezone(input.timezone) + if ('cron' in input.schedule) { + return `Runs on cron "${assertValidJobCronExpression(input.schedule.cron)}" in ${timezone}` + } + return `Runs every ${normalizeJobIntervalMs(input.schedule.intervalMs).toLocaleString()}ms` +} + +export function computeNextJobRunAt(input: { + schedule: JobSchedule + timezone?: string | null + from?: Date | string | null +}) { + const normalizedSchedule = normalizeJobSchedule(input.schedule) + const timezone = normalizeJobTimezone(input.timezone) + const from = + input.from instanceof Date + ? input.from + : input.from + ? new Date(input.from) + : new Date() + if (Number.isNaN(from.valueOf())) { + throw new Error('Cannot compute the next run time from an invalid date.') + } + if ('intervalMs' in normalizedSchedule) { + return new Date( + from.getTime() + normalizeJobIntervalMs(normalizedSchedule.intervalMs), + ).toISOString() + } + assertValidJobTimezone(timezone) + const cron = new Cron(assertValidJobCronExpression(normalizedSchedule.cron), { + paused: true, + timezone, + }) + const nextRun = cron.nextRun(from) + if (!nextRun) { + throw new Error('Cron expression does not produce a future run time.') + } + return nextRun.toISOString() +} + +export function toJobSummary(input: { + job: JobRecord + status: { + nextRunAt: string | null + runCount: number + successCount: number + failureCount: number + lastRunStartedAt: string | null + lastRunFinishedAt: string | null + lastRunDurationMs: number | null + lastError: JobSummary['lastError'] + killSwitchEnabled: boolean + historyLimit: number + } +}): JobSummary { + return { + ...input.job, + ...input.status, + scheduleSummary: formatJobScheduleSummary({ + schedule: input.job.schedule, + timezone: input.job.timezone, + }), + } +} diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts new file mode 100644 index 0000000000..93eab8410d --- /dev/null +++ b/packages/worker/src/jobs/service.ts @@ -0,0 +1,169 @@ +import { + deleteJob, + getJobById, + insertJob, + listJobsByUserId, + updateJob, +} from './repo.ts' +import { + computeNextJobRunAt, + normalizeJobSchedule, + normalizeJobTimezone, +} from './schedule.ts' +import { + type JobCreateInput, + type JobRecord, + type JobUpdatePatch, +} from './types.ts' + +export const jobStorageBindingPrefix = 'job:' + +export function buildJobStorageBindingId(jobId: string) { + return `${jobStorageBindingPrefix}${jobId}` +} + +export function normalizeJobName(name: string) { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Jobs require a non-empty name.') + } + return trimmed +} + +export function normalizeJobServerCode(serverCode: string) { + if (!serverCode.trim()) { + throw new Error('Jobs require non-empty server code.') + } + return serverCode +} + +export async function createJobRecord(input: { + db: D1Database + userId: string + data: JobCreateInput +}) { + const id = crypto.randomUUID() + const serverCodeId = crypto.randomUUID() + const now = new Date().toISOString() + const record: JobRecord = { + id, + userId: input.userId, + name: normalizeJobName(input.data.name), + serverCode: normalizeJobServerCode(input.data.serverCode), + serverCodeId, + schedule: normalizeJobSchedule(input.data.schedule), + timezone: normalizeJobTimezone(input.data.timezone), + enabled: input.data.enabled ?? true, + createdAt: now, + updatedAt: now, + } + await insertJob(input.db, { + id: record.id, + user_id: record.userId, + name: record.name, + serverCode: record.serverCode, + serverCodeId: record.serverCodeId, + schedule: record.schedule, + timezone: record.timezone, + enabled: record.enabled, + created_at: record.createdAt, + updated_at: record.updatedAt, + }) + return record +} + +export async function updateJobRecord(input: { + db: D1Database + userId: string + jobId: string + patch: JobUpdatePatch +}) { + const existing = await getJobById(input.db, input.userId, input.jobId) + if (!existing) { + throw new Error(`Job "${input.jobId}" was not found.`) + } + + const nextServerCode = + input.patch.serverCode === undefined + ? existing.serverCode + : normalizeJobServerCode(input.patch.serverCode) + const nextServerCodeId = + input.patch.serverCode === undefined + ? existing.serverCodeId + : crypto.randomUUID() + const nextSchedule = + input.patch.schedule === undefined + ? existing.schedule + : normalizeJobSchedule(input.patch.schedule) + const nextTimezone = + input.patch.timezone === undefined + ? existing.timezone + : normalizeJobTimezone(input.patch.timezone) + const nextEnabled = input.patch.enabled ?? existing.enabled + const nextName = + input.patch.name === undefined + ? existing.name + : normalizeJobName(input.patch.name) + const nextRecord: JobRecord = { + ...existing, + name: nextName, + serverCode: nextServerCode, + serverCodeId: nextServerCodeId, + schedule: nextSchedule, + timezone: nextTimezone, + enabled: nextEnabled, + updatedAt: new Date().toISOString(), + } + + await updateJob(input.db, input.userId, input.jobId, { + name: nextRecord.name, + serverCode: nextRecord.serverCode, + serverCodeId: nextRecord.serverCodeId, + schedule: nextRecord.schedule, + timezone: nextRecord.timezone, + enabled: nextRecord.enabled, + }) + return { + before: existing, + after: nextRecord, + serverCodeChanged: existing.serverCodeId !== nextRecord.serverCodeId, + scheduleChanged: + JSON.stringify(existing.schedule) !== + JSON.stringify(nextRecord.schedule) || + existing.timezone !== nextRecord.timezone || + existing.enabled !== nextRecord.enabled, + } +} + +export async function requireJobRecord( + db: D1Database, + userId: string, + jobId: string, +) { + const job = await getJobById(db, userId, jobId) + if (!job) { + throw new Error(`Job "${jobId}" was not found.`) + } + return job +} + +export async function removeJobRecord( + db: D1Database, + userId: string, + jobId: string, +) { + return await deleteJob(db, userId, jobId) +} + +export async function listJobRecords(db: D1Database, userId: string) { + return await listJobsByUserId(db, userId) +} + +export function computeInitialNextRunAt( + job: Pick, +) { + return computeNextJobRunAt({ + schedule: job.schedule, + timezone: job.timezone, + }) +} diff --git a/packages/worker/src/jobs/types.ts b/packages/worker/src/jobs/types.ts new file mode 100644 index 0000000000..59d966e4be --- /dev/null +++ b/packages/worker/src/jobs/types.ts @@ -0,0 +1,88 @@ +export type JobSchedule = + | { + cron: string + } + | { + intervalMs: number + } + +export type JobRecord = { + id: string + userId: string + name: string + serverCode: string + serverCodeId: string + schedule: JobSchedule + timezone: string + enabled: boolean + createdAt: string + updatedAt: string +} + +export type JobRunnerError = { + message: string + stack: string | null +} + +export type JobRunTrigger = 'alarm' | 'run_now' + +export type JobRunStatus = 'success' | 'failure' + +export type JobRunHistoryEntry = { + id: number + trigger: JobRunTrigger + status: JobRunStatus + scheduledFor: string | null + startedAt: string + finishedAt: string + durationMs: number + error: JobRunnerError | null +} + +export type JobRunnerStatus = { + nextRunAt: string | null + runCount: number + successCount: number + failureCount: number + lastRunStartedAt: string | null + lastRunFinishedAt: string | null + lastRunDurationMs: number | null + lastError: JobRunnerError | null + killSwitchEnabled: boolean + historyLimit: number +} + +export type JobSummary = JobRecord & + JobRunnerStatus & { + scheduleSummary: string + } + +export type JobDetails = JobSummary + +export type JobCreateInput = { + name: string + serverCode: string + schedule: JobSchedule + timezone?: string | null + enabled?: boolean +} + +export type JobUpdatePatch = { + name?: string + serverCode?: string + schedule?: JobSchedule + timezone?: string | null + enabled?: boolean + killSwitchEnabled?: boolean + historyLimit?: number +} + +export type JobRunResult = + | { + ok: true + result: unknown + } + | { + ok: false + error: JobRunnerError + } diff --git a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts index 2865b751c6..0289308002 100644 --- a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts @@ -74,23 +74,25 @@ test('buildCapabilityRegistry rejects duplicate domain registration', () => { ) }) -test('builtin capability domains include scheduler', async () => { +test('builtin capability domains include jobs', async () => { const { builtinDomains } = await import('./builtin-domains.ts') - expect(builtinDomains.some((domain) => domain.name === 'scheduler')).toBe( - true, - ) - const schedulerDomain = builtinDomains.find( - (domain) => domain.name === 'scheduler', + expect(builtinDomains.some((domain) => domain.name === 'jobs')).toBe(true) + const jobsDomain = builtinDomains.find((domain) => domain.name === 'jobs') + expect(jobsDomain?.capabilities.map((capability) => capability.name)).toEqual( + [ + 'job_create', + 'job_update', + 'job_delete', + 'job_list', + 'job_get', + 'job_run_now', + 'job_enable', + 'job_disable', + 'job_history', + 'job_storage_reset', + 'job_server_exec', + ], ) - expect( - schedulerDomain?.capabilities.map((capability) => capability.name), - ).toEqual([ - 'scheduler_upsert', - 'scheduler_list', - 'scheduler_get', - 'scheduler_delete', - 'scheduler_run_now', - ]) }) test('defineDomain rejects duplicate capability names within one domain', () => { diff --git a/packages/worker/src/mcp/capabilities/builtin-domains.ts b/packages/worker/src/mcp/capabilities/builtin-domains.ts index b6c062bf08..cb4f745c1f 100644 --- a/packages/worker/src/mcp/capabilities/builtin-domains.ts +++ b/packages/worker/src/mcp/capabilities/builtin-domains.ts @@ -1,7 +1,7 @@ import { appsDomain } from './apps/domain.ts' import { codingDomain } from './coding/domain.ts' +import { jobsDomain } from './jobs/domain.ts' import { metaDomain } from './meta/domain.ts' -import { schedulerDomain } from './scheduler/domain.ts' import { secretsDomain } from './secrets/domain.ts' import { valuesDomain } from './values/domain.ts' @@ -16,8 +16,8 @@ import { valuesDomain } from './values/domain.ts' export const builtinDomains = [ appsDomain, codingDomain, + jobsDomain, metaDomain, - schedulerDomain, secretsDomain, valuesDomain, ] as const diff --git a/packages/worker/src/mcp/capabilities/domain-metadata.ts b/packages/worker/src/mcp/capabilities/domain-metadata.ts index 96b05b30fd..b595837bf0 100644 --- a/packages/worker/src/mcp/capabilities/domain-metadata.ts +++ b/packages/worker/src/mcp/capabilities/domain-metadata.ts @@ -3,6 +3,7 @@ export const capabilityDomainNames = { apps: 'apps', coding: 'coding', home: 'home', + jobs: 'jobs', math: 'math', meta: 'meta', scheduler: 'scheduler', diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts new file mode 100644 index 0000000000..01b69d7d29 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -0,0 +1,406 @@ +import { z } from 'zod' +import { defineDomain } from '#mcp/capabilities/define-domain.ts' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { jobRunnerRpc, syncJobRunnerFromDb } from '#worker/jobs/job-runner.ts' +import { + createJobRecord, + listJobRecords, + removeJobRecord, + requireJobRecord, + updateJobRecord, +} from '#worker/jobs/service.ts' +import { + jobCapabilityKeywords, + jobCreateInputSchema, + jobDetailsSchema, + jobExecutionSchema, + jobHistoryEntrySchema, + jobHistoryInputSchema, + jobIdInputSchema, + jobServerExecInputSchema, + jobUpdateInputSchema, + requireJobsUser, +} from './shared.ts' + +const jobDeleteOutputSchema = z.object({ + job_id: z.string(), + deleted: z.literal(true), +}) + +const jobRunNowOutputSchema = z.object({ + job: jobDetailsSchema, + execution: jobExecutionSchema, +}) + +const jobServerExecOutputSchema = z.object({ + ok: z.literal(true), + job_id: z.string(), + result: z.unknown(), +}) + +const jobStorageResetOutputSchema = z.object({ + ok: z.literal(true), + job_id: z.string(), +}) + +const jobCreateCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_create', + description: + 'Create a scheduled Durable Object job for the signed-in user. Jobs run agent-authored `class Job extends DurableObject` code on either a cron schedule or fixed interval.', + keywords: [...jobCapabilityKeywords, 'create'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobCreateInputSchema, + outputSchema: jobDetailsSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const job = await createJobRecord({ + db: ctx.env.APP_DB, + userId: user.userId, + data: args, + }) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: job.id, + baseUrl: ctx.callerContext.baseUrl, + recomputeNextRunAt: true, + }) + if (!details) { + throw new Error('Unable to configure the created job runner.') + } + return details + }, +}) + +const jobUpdateCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_update', + description: + 'Patch one existing scheduled job. Rotates `serverCodeId` when serverCode changes and re-arms alarms when schedule-related fields change.', + keywords: [...jobCapabilityKeywords, 'update', 'patch'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobUpdateInputSchema, + outputSchema: jobDetailsSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const updateResult = await updateJobRecord({ + db: ctx.env.APP_DB, + userId: user.userId, + jobId: args.job_id, + patch: { + name: args.patch.name, + serverCode: args.patch.serverCode, + schedule: args.patch.schedule, + timezone: args.patch.timezone, + enabled: args.patch.enabled, + killSwitchEnabled: args.patch.kill_switch_enabled, + historyLimit: args.patch.history_limit, + }, + }) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + historyLimit: args.patch.history_limit, + killSwitchEnabled: args.patch.kill_switch_enabled, + recomputeNextRunAt: updateResult.scheduleChanged, + }) + if (!details) { + throw new Error('Unable to synchronize the updated job runner.') + } + return details + }, +}) + +const jobDeleteCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_delete', + description: + 'Delete a scheduled job record and remove its supervisor Durable Object facet state.', + keywords: [...jobCapabilityKeywords, 'delete', 'remove'], + readOnly: false, + idempotent: false, + destructive: true, + inputSchema: jobIdInputSchema, + outputSchema: jobDeleteOutputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + await requireJobRecord(ctx.env.APP_DB, user.userId, args.job_id) + await removeJobRecord(ctx.env.APP_DB, user.userId, args.job_id) + await Promise.allSettled([ + jobRunnerRpc(ctx.env, args.job_id).deleteRunner(), + ]) + return { + job_id: args.job_id, + deleted: true as const, + } + }, +}) + +const jobListCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_list', + description: + 'List scheduled jobs for the signed-in user, including next run time, observability counters, and kill-switch state.', + keywords: [...jobCapabilityKeywords, 'list'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: z.object({}), + outputSchema: z.array(jobDetailsSchema), + async handler(_args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const jobs = await listJobRecords(ctx.env.APP_DB, user.userId) + const details = await Promise.all( + jobs.map((job) => + syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: job.id, + baseUrl: ctx.callerContext.baseUrl, + }), + ), + ) + return details.filter((job): job is NonNullable => job != null) + }, +}) + +const jobGetCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_get', + description: + 'Get one scheduled job by id, including schedule, enablement, and supervisor-run metadata.', + keywords: [...jobCapabilityKeywords, 'get'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: jobIdInputSchema, + outputSchema: jobDetailsSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + return details + }, +}) + +const jobRunNowCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_run_now', + description: + 'Trigger a scheduled job immediately without waiting for its next alarm and return the execution outcome plus updated supervisor status.', + keywords: [...jobCapabilityKeywords, 'run now', 'trigger'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobIdInputSchema, + outputSchema: jobRunNowOutputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + const execution = await jobRunnerRpc(ctx.env, args.job_id).runNow() + return { + job: await jobRunnerRpc(ctx.env, args.job_id).getDetails(), + execution, + } + }, +}) + +const jobEnableCapability = defineDomainCapability(capabilityDomainNames.jobs, { + name: 'job_enable', + description: + 'Enable a scheduled job and re-arm its next alarm from the configured cron or interval schedule.', + keywords: [...jobCapabilityKeywords, 'enable'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobIdInputSchema, + outputSchema: jobDetailsSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + await updateJobRecord({ + db: ctx.env.APP_DB, + userId: user.userId, + jobId: args.job_id, + patch: { enabled: true }, + }) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + recomputeNextRunAt: true, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + return details + }, +}) + +const jobDisableCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_disable', + description: + 'Disable a scheduled job so alarms are cleared and future automatic runs are skipped until re-enabled.', + keywords: [...jobCapabilityKeywords, 'disable'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobIdInputSchema, + outputSchema: jobDetailsSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + await updateJobRecord({ + db: ctx.env.APP_DB, + userId: user.userId, + jobId: args.job_id, + patch: { enabled: false }, + }) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + return details + }, + }, +) + +const jobHistoryCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_history', + description: + 'Return recent run history from the job supervisor Durable Object ring buffer, including durations and captured errors.', + keywords: [...jobCapabilityKeywords, 'history', 'observability'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: jobHistoryInputSchema, + outputSchema: z.array(jobHistoryEntrySchema), + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + return await jobRunnerRpc(ctx.env, args.job_id).getHistory(args.limit) + }, + }, +) + +const jobStorageResetCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_storage_reset', + description: + 'Delete all facet SQLite storage for one scheduled job while keeping the job record and supervisor metadata.', + keywords: [...jobCapabilityKeywords, 'storage', 'reset'], + readOnly: false, + idempotent: false, + destructive: true, + inputSchema: jobIdInputSchema, + outputSchema: jobStorageResetOutputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + await jobRunnerRpc(ctx.env, args.job_id).resetStorage() + return { + ok: true as const, + job_id: args.job_id, + } + }, + }, +) + +const jobServerExecCapability = defineDomainCapability( + capabilityDomainNames.jobs, + { + name: 'job_server_exec', + description: + 'Execute one-off JavaScript inside a scheduled job facet for debugging, repair tasks, or data migrations scoped to that job.', + keywords: [...jobCapabilityKeywords, 'server', 'exec', 'debug'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: jobServerExecInputSchema, + outputSchema: jobServerExecOutputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireJobsUser(ctx) + const details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + if (!details) { + throw new Error(`Job "${args.job_id}" was not found.`) + } + const result = await jobRunnerRpc(ctx.env, args.job_id).execServer({ + code: args.code, + params: args.params, + }) + return { + ok: true as const, + job_id: args.job_id, + result, + } + }, + }, +) + +export const jobsDomain = defineDomain({ + name: capabilityDomainNames.jobs, + description: + 'Alarm-driven Durable Object jobs that run agent-authored server code on cron or interval schedules with isolated facet SQLite state and supervisor observability.', + keywords: [...jobCapabilityKeywords], + capabilities: [ + jobCreateCapability, + jobUpdateCapability, + jobDeleteCapability, + jobListCapability, + jobGetCapability, + jobRunNowCapability, + jobEnableCapability, + jobDisableCapability, + jobHistoryCapability, + jobStorageResetCapability, + jobServerExecCapability, + ], +}) diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts new file mode 100644 index 0000000000..2cbf15a140 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -0,0 +1,140 @@ +import { z } from 'zod' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { + type JobDetails, + type JobRunHistoryEntry, + type JobSchedule, +} from '#worker/jobs/types.ts' + +export const jobScheduleSchema = z.union([ + z.object({ + cron: z + .string() + .min(1) + .describe( + 'Standard 5-field cron expression: minute hour day-of-month month day-of-week.', + ), + }), + z.object({ + intervalMs: z + .number() + .int() + .positive() + .describe('Fixed run interval in milliseconds.'), + }), +]) satisfies z.ZodType + +export const jobErrorSchema = z.object({ + message: z.string(), + stack: z.string().nullable(), +}) + +export const jobDetailsSchema = z.object({ + id: z.string(), + userId: z.string(), + name: z.string(), + serverCode: z.string(), + serverCodeId: z.string(), + schedule: jobScheduleSchema, + timezone: z.string(), + enabled: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + nextRunAt: z.string().nullable(), + runCount: z.number(), + successCount: z.number(), + failureCount: z.number(), + lastRunStartedAt: z.string().nullable(), + lastRunFinishedAt: z.string().nullable(), + lastRunDurationMs: z.number().nullable(), + lastError: jobErrorSchema.nullable(), + killSwitchEnabled: z.boolean(), + historyLimit: z.number(), + scheduleSummary: z.string(), +}) satisfies z.ZodType + +export const jobHistoryEntrySchema = z.object({ + id: z.number(), + trigger: z.enum(['alarm', 'run_now']), + status: z.enum(['success', 'failure']), + scheduledFor: z.string().nullable(), + startedAt: z.string(), + finishedAt: z.string(), + durationMs: z.number(), + error: jobErrorSchema.nullable(), +}) satisfies z.ZodType + +export const jobExecutionSchema = z.union([ + z.object({ + ok: z.literal(true), + result: z.unknown(), + }), + z.object({ + ok: z.literal(false), + error: jobErrorSchema, + }), + z.object({ + ok: z.literal(false), + skipped: z.literal(true), + }), +]) + +export const jobCreateInputSchema = z.object({ + name: z.string().min(1), + serverCode: z + .string() + .min(1) + .describe( + 'Durable Object source that must export `class Job extends DurableObject` with an async `run()` method.', + ), + schedule: jobScheduleSchema, + timezone: z + .string() + .nullable() + .optional() + .describe('Optional IANA timezone. Defaults to America/Denver.'), + enabled: z.boolean().optional(), +}) + +export const jobUpdatePatchSchema = z.object({ + name: z.string().min(1).optional(), + serverCode: z.string().min(1).optional(), + schedule: jobScheduleSchema.optional(), + timezone: z.string().nullable().optional(), + enabled: z.boolean().optional(), + kill_switch_enabled: z.boolean().optional(), + history_limit: z.number().int().min(1).max(500).optional(), +}) + +export const jobIdInputSchema = z.object({ + job_id: z.string().min(1), +}) + +export const jobHistoryInputSchema = z.object({ + job_id: z.string().min(1), + limit: z.number().int().min(1).max(500).optional(), +}) + +export const jobUpdateInputSchema = z.object({ + job_id: z.string().min(1), + patch: jobUpdatePatchSchema, +}) + +export const jobServerExecInputSchema = z.object({ + job_id: z.string().min(1), + code: z.string().min(1), + params: z.record(z.string(), z.unknown()).optional(), +}) + +export const jobCapabilityKeywords = [ + 'job', + 'schedule', + 'cron', + 'interval', + 'durable object', +] as const + +export function requireJobsUser(ctx: CapabilityContext) { + return requireMcpUser(ctx.callerContext) +} diff --git a/packages/worker/src/mcp/facet-runtime.ts b/packages/worker/src/mcp/facet-runtime.ts new file mode 100644 index 0000000000..cc2cd376b3 --- /dev/null +++ b/packages/worker/src/mcp/facet-runtime.ts @@ -0,0 +1,417 @@ +import { WorkerEntrypoint } from 'cloudflare:workers' +import { expandSecretPlaceholders } from '#mcp/fetch-gateway.ts' +import { createMcpCallerContext } from '#mcp/context.ts' +import { resolveSecret } from '#mcp/secrets/service.ts' +import { getValue, listValues, saveValue } from '#mcp/values/service.ts' +import { + buildConnectorValueName, + parseConnectorConfig, + parseConnectorJson, +} from '#mcp/capabilities/values/connector-shared.ts' + +export type FacetBridgeScope = 'app' | 'job' | 'user' + +export type FacetBridgeProps = { + userId: string + baseUrl: string + storageBindingId: string + scopeLabel: 'app' | 'job' + displayName: string + facetName: string | null +} + +export type FacetStorageExport = { + entries: Array<{ + key: string + value: unknown + }> + estimatedBytes: number +} + +export type FacetErrorDetails = { + message: string + stack: string | null +} + +type CreateFacetStartupInput = { + loader: WorkerLoader + cacheKey: string + serverCode: string + facetName: string + baseClassName: string + expectedExportDescription: string + facetIdPrefix: string + bridgeBindingName: string + bridgeBinding: Fetcher +} + +const defaultCompatibilityDate = '2026-03-24' +const defaultCompatibilityFlags = [ + 'nodejs_compat', + 'global_fetch_strictly_public', +] +const defaultFacetModuleName = 'user-facet.js' + +function createScopedStorageContext(storageBindingId: string) { + return { + sessionId: null, + appId: storageBindingId, + } +} + +function normalizeBridgeScope( + scope: FacetBridgeScope | undefined, + defaultScope: FacetBridgeProps['scopeLabel'], +) { + const resolvedScope = scope ?? defaultScope + return resolvedScope === 'user' ? 'user' : 'app' +} + +export function normalizeFacetName( + rawFacetName: string | null | undefined, + fallback = 'main', +) { + const facetName = rawFacetName?.trim() || fallback + return facetName +} + +export function buildFacetClassExportName( + baseClassName: string, + facetName: string, +) { + return facetName === 'main' + ? baseClassName + : `${baseClassName}_${facetName.replaceAll(/[^a-zA-Z0-9_]/g, '_')}` +} + +export function readJson(request: Request): Promise { + return request.json().catch(() => null) as Promise +} + +export function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} + +export function toFacetErrorDetails(error: unknown): FacetErrorDetails { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack ?? null, + } + } + if (error instanceof Response) { + return { + message: `HTTP ${error.status}`, + stack: null, + } + } + return { + message: String(error), + stack: null, + } +} + +export function formatFacetErrorMessage(error: unknown) { + return toFacetErrorDetails(error).message +} + +function createFacetWrapperModule(input: { + baseClassName: string + expectedExportDescription: string + facetName: string + serverCode: string +}) { + const exportName = buildFacetClassExportName( + input.baseClassName, + input.facetName, + ) + return ` +import * as userModule from './${defaultFacetModuleName}' + +const BaseFacet = userModule.${input.baseClassName} + +if (typeof BaseFacet !== 'function') { + throw new Error('${input.expectedExportDescription}') +} + +export class ${exportName} extends BaseFacet { + async __kody_resetStorage() { + await this.ctx.storage.deleteAll() + return { ok: true } + } + + async __kody_exportStorage() { + const entries = [] + for (const [key, value] of await this.ctx.storage.list()) { + entries.push({ key, value }) + } + return { + entries, + estimatedBytes: this.ctx.storage.sql.databaseSize, + } + } + + async __kody_exec(code, params) { + if (typeof code !== 'string' || !code.trim()) { + throw new Error('Facet exec requires non-empty code.') + } + const runner = new Function('facet', 'params', code) + return await runner(this, params ?? {}) + } +} +`.trim() +} + +export async function createFacetStartup(input: CreateFacetStartupInput) { + const exportName = buildFacetClassExportName( + input.baseClassName, + input.facetName, + ) + const worker = input.loader.get(input.cacheKey, async () => ({ + compatibilityDate: defaultCompatibilityDate, + compatibilityFlags: defaultCompatibilityFlags, + mainModule: 'facet-entry.js', + modules: { + 'facet-entry.js': createFacetWrapperModule({ + baseClassName: input.baseClassName, + expectedExportDescription: input.expectedExportDescription, + facetName: input.facetName, + serverCode: input.serverCode, + }), + [defaultFacetModuleName]: input.serverCode, + }, + env: { + [input.bridgeBindingName]: input.bridgeBinding, + }, + globalOutbound: null, + })) + return { + id: `${input.facetIdPrefix}:${input.facetName}`, + class: worker.getDurableObjectClass(exportName), + } +} + +export async function callFacetStorageReset(facet: Fetcher) { + return await ( + facet as unknown as { + __kody_resetStorage: () => Promise + } + ).__kody_resetStorage() +} + +export async function callFacetStorageExport(facet: Fetcher) { + return await ( + facet as unknown as { + __kody_exportStorage: () => Promise + } + ).__kody_exportStorage() +} + +export async function callFacetExec( + facet: Fetcher, + code: string, + params?: Record, +) { + return await ( + facet as unknown as { + __kody_exec: ( + source: string, + execParams?: Record, + ) => Promise + } + ).__kody_exec(code, params) +} + +export class FacetKodyBridge extends WorkerEntrypoint { + async fetchWithResolvedSecrets(input: { + url: string + method?: string + headers?: Record + body?: string + }) { + const request = new Request(input.url, { + method: input.method ?? 'GET', + headers: input.headers, + body: input.body, + }) + const resolved = await expandSecretPlaceholders({ + request, + props: { + baseUrl: this.ctx.props.baseUrl, + userId: this.ctx.props.userId, + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }, + env: this.env, + }) + return { + url: resolved.url, + method: resolved.method, + headers: Object.fromEntries(resolved.headers.entries()), + body: + resolved.method === 'GET' || resolved.method === 'HEAD' + ? undefined + : await resolved.text(), + } + } + + async fetchViaHostGateway(input: { + url: string + method?: string + headers?: Record + body?: string + }) { + const gateway = this.ctx.exports.CodemodeFetchGateway({ + props: { + baseUrl: this.ctx.props.baseUrl, + userId: this.ctx.props.userId, + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }, + }) as { + fetch: (request: Request) => Promise + } + const request = new Request(input.url, { + method: input.method ?? 'GET', + headers: input.headers, + body: input.body, + }) + const response = await gateway.fetch(request) + return { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: await response.text(), + } + } + + async connectorGet(args: Record) { + const name = typeof args['name'] === 'string' ? args['name'].trim() : '' + if (!name) { + throw new Error('connector_get requires a connector name.') + } + const value = await getValue({ + env: this.env, + userId: this.ctx.props.userId, + name: buildConnectorValueName(name), + scope: 'user', + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }) + if (!value) { + return { connector: null } + } + return { + connector: parseConnectorConfig(parseConnectorJson(value.value), name), + } + } + + async connectorList() { + const values = await listValues({ + env: this.env, + userId: this.ctx.props.userId, + scope: 'user', + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }) + const connectors = values + .map((value) => { + const name = value.name.startsWith('_connector:') + ? value.name.slice('_connector:'.length).trim() + : null + if (!name) return null + return parseConnectorConfig(parseConnectorJson(value.value), name) + }) + .filter((value): value is NonNullable => value != null) + return { connectors } + } + + async valueGet( + name: string, + scope: FacetBridgeScope = this.ctx.props.scopeLabel, + ) { + return await getValue({ + env: this.env, + userId: this.ctx.props.userId, + name, + scope: normalizeBridgeScope(scope, this.ctx.props.scopeLabel), + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }) + } + + async valueSet(input: { + name: string + value: string + description?: string + scope?: FacetBridgeScope + }) { + return await saveValue({ + env: this.env, + userId: this.ctx.props.userId, + name: input.name, + value: input.value, + description: input.description ?? '', + scope: normalizeBridgeScope(input.scope, this.ctx.props.scopeLabel), + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }) + } + + async secretPlaceholder( + name: string, + scope: FacetBridgeScope = this.ctx.props.scopeLabel, + ) { + const resolvedScope = normalizeBridgeScope(scope, this.ctx.props.scopeLabel) + const resolved = await resolveSecret({ + env: this.env, + userId: this.ctx.props.userId, + name, + scope: resolvedScope, + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }) + if (!resolved.found) { + throw new Error(`Secret "${name}" was not found.`) + } + return `{{secret:${name}|scope=${resolvedScope}}}` + } + + async metaRunSkill( + name: string, + params: Record | undefined, + ) { + const { metaRunSkillCapability } = + await import('#mcp/capabilities/meta/meta-run-skill.ts') + return await metaRunSkillCapability.handler( + { name, params }, + { + env: this.env, + callerContext: createMcpCallerContext({ + baseUrl: this.ctx.props.baseUrl, + user: { + userId: this.ctx.props.userId, + email: '', + displayName: this.ctx.props.displayName, + }, + storageContext: createScopedStorageContext( + this.ctx.props.storageBindingId, + ), + }), + }, + ) + } +} diff --git a/packages/worker/src/mcp/server-instructions.ts b/packages/worker/src/mcp/server-instructions.ts index 9732d9e778..d861876c8c 100644 --- a/packages/worker/src/mcp/server-instructions.ts +++ b/packages/worker/src/mcp/server-instructions.ts @@ -22,7 +22,7 @@ Conventions - \`memoryContext\`: short and task-focused. Kody may use it to surface a few relevant long-term memories and suppress repeats within the same \`conversationId\`. - Do not ask the user to paste secrets in chat; use saved secrets or \`open_generated_ui\`. - \`meta_save_skill\`: repeatable workflows only; optional \`collection\`; same name replaces an existing skill. One-off work: \`execute\`. Skill params: pass via \`meta_run_skill\` → \`params\` in codemode. -- \`scheduler_upsert\`: create a new scheduled codemode job or update an existing one in place. Use \`scheduler_list\` / \`scheduler_get\` to inspect status and next run times, and \`scheduler_run_now\` to trigger the stored job immediately without changing its schedule. +- \`job_create\` / \`job_update\`: create and update scheduled Durable Object jobs. Use \`job_list\` / \`job_get\` to inspect status, \`job_run_now\` to trigger immediately, \`job_history\` for recent runs, and \`job_enable\` / \`job_disable\` to control alarm scheduling. - \`ui_save_app\` / \`app_id\`: persisted UI artifacts (hidden from search unless \`hidden: false\`). \`codemode.secret_list\` / \`secret_set\`: metadata-only list; set only for values already in trusted execution (see \`execute\` tool description). - 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/execute.ts b/packages/worker/src/mcp/tools/execute.ts index 0dc886c2a7..b140aa4000 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -38,9 +38,10 @@ builtin capability). Discover names with \`search\`; for one capability’s Saved skills: prefer \`meta_run_skill({ name, params })\`, or \`meta_get_skill\` then paste code here. -Scheduled jobs: use \`scheduler_upsert\`, \`scheduler_list\`, \`scheduler_get\`, -\`scheduler_delete\`, and \`scheduler_run_now\` to manage -one-shot or recurring codemode executions for the signed-in user. +Scheduled jobs: use \`job_create\`, \`job_update\`, \`job_list\`, \`job_get\`, +\`job_delete\`, \`job_run_now\`, \`job_enable\`, \`job_disable\`, +\`job_history\`, \`job_storage_reset\`, and \`job_server_exec\` to manage +alarm-driven Durable Object jobs for the signed-in user. Sandbox surface: - \`codemode\`: \`(args) => Promise\` per capability. diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index d1402e7bd6..5106f260ce 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -136,8 +136,10 @@ empty ranked list. capabilities. Types and fields: see response. Run a skill: \`meta_run_skill({ name, params })\`; source: \`meta_get_skill\`. -Manage schedules: \`scheduler_upsert\`, \`scheduler_list\`, \`scheduler_get\`, -\`scheduler_delete\`, \`scheduler_run_now\`. +Manage scheduled jobs: \`job_create\`, \`job_update\`, \`job_list\`, +\`job_get\`, \`job_delete\`, \`job_run_now\`, \`job_enable\`, +\`job_disable\`, \`job_history\`, \`job_storage_reset\`, and +\`job_server_exec\`. Apps: \`open_generated_ui({ app_id })\`. Secrets: never raw in results; use \`codemode.secret_list\` during execute and UI for missing values. Persisted values use \`codemode.value_get\` / \`codemode.value_list\`. Connectors diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index 71260cc316..10f9ab8c2b 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -1,10 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: 1b3dc0369ebd9b36e71d96c92a4ff005) -// Runtime types generated with workerd@1.20260410.1 2026-04-13 global_fetch_strictly_public,nodejs_compat +// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: 7f1f769d929f1cc3f01caf4a36a8cd3c) +// Runtime types generated with workerd@1.20260409.1 2026-04-13 experimental,global_fetch_strictly_public,nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "SchedulerDO" | "AppRunner"; + durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "SchedulerDO" | "AppRunner" | "JobRunner"; } interface Env { OAUTH_KV: KVNamespace; @@ -26,6 +26,7 @@ declare namespace Cloudflare { HOME_MCP_OBJECT: DurableObjectNamespace; SCHEDULER_DO: DurableObjectNamespace; APP_RUNNER: DurableObjectNamespace; + JOB_RUNNER: DurableObjectNamespace; } } interface Env extends Cloudflare.Env {} @@ -458,6 +459,7 @@ interface ExecutionContext { passThroughOnException(): void; readonly exports: Cloudflare.Exports; readonly props: Props; + abort(reason?: any): void; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -534,9 +536,15 @@ type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; routingMode?: DurableObjectRoutingMode; + version?: { + cohort?: string; + }; } interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { } +interface DurableObjectNamespaceGetDurableObjectOptionsVersionOptions { + cohort?: string; +} interface DurableObjectState { waitUntil(promise: Promise): void; readonly exports: Cloudflare.Exports; @@ -589,6 +597,8 @@ interface DurableObjectStorage { getCurrentBookmark(): Promise; getBookmarkForTime(timestamp: number | Date): Promise; onNextSessionRestoreBookmark(bookmark: string): Promise; + waitForBookmark(bookmark: string): Promise; + readonly primary?: DurableObjectStub; } interface DurableObjectListOptions { start?: string; @@ -1896,6 +1906,7 @@ interface KVNamespace { getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"text">): Promise>>; getWithMetadata(key: Array, options?: KVNamespaceGetOptions<"json">): Promise>>; delete(key: Key): Promise; + deleteBulk(keys: Key | Key[]): Promise; } interface KVNamespaceListOptions { limit?: number; @@ -1918,8 +1929,31 @@ interface KVNamespaceGetWithMetadataResult { } type QueueContentType = "text" | "bytes" | "json" | "v8"; interface Queue { - send(message: Body, options?: QueueSendOptions): Promise; - sendBatch(messages: Iterable>, options?: QueueSendBatchOptions): Promise; + send(message: Body, options?: QueueSendOptions): Promise; + sendBatch(messages: Iterable>, options?: QueueSendBatchOptions): Promise; + metrics(): Promise; +} +interface QueueSendMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendMetadata { + metrics: QueueSendMetrics; +} +interface QueueSendResponse { + metadata: QueueSendMetadata; +} +interface QueueSendBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface QueueSendBatchMetadata { + metrics: QueueSendBatchMetrics; +} +interface QueueSendBatchResponse { + metadata: QueueSendBatchMetadata; } interface QueueSendOptions { contentType?: QueueContentType; @@ -1933,6 +1967,19 @@ interface MessageSendRequest { contentType?: QueueContentType; delaySeconds?: number; } +interface QueueMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetrics { + backlogCount: number; + backlogBytes: number; + oldestMessageTimestamp?: Date; +} +interface MessageBatchMetadata { + metrics: MessageBatchMetrics; +} interface QueueRetryOptions { delaySeconds?: number; } @@ -1947,12 +1994,14 @@ interface Message { interface QueueEvent extends ExtendableEvent { readonly messages: readonly Message[]; readonly queue: string; + readonly metadata: MessageBatchMetadata; retryAll(options?: QueueRetryOptions): void; ackAll(): void; } interface MessageBatch { readonly messages: readonly Message[]; readonly queue: string; + readonly metadata: MessageBatchMetadata; retryAll(options?: QueueRetryOptions): void; ackAll(): void; } @@ -2095,6 +2144,16 @@ type R2Objects = { interface R2UploadPartOptions { ssecKey?: (ArrayBuffer | string); } +declare abstract class JsRpcPromise { + then(handler: Function, errorHandler?: Function): any; + catch(errorHandler: Function): any; + finally(onFinally: Function): any; +} +declare abstract class JsRpcProperty { + then(handler: Function, errorHandler?: Function): any; + catch(errorHandler: Function): any; + finally(onFinally: Function): any; +} declare abstract class ScheduledEvent extends ExtendableEvent { readonly scheduledTime: number; readonly cron: string; @@ -3161,6 +3220,9 @@ declare const WebSocketPair: { }; interface SqlStorage { exec>(query: string, ...bindings: any[]): SqlStorageCursor; + prepare(query: string): SqlStorageStatement; + ingest(query: string): SqlStorageIngestResult; + setMaxPageCountForTest(count: number): void; get databaseSize(): number; Cursor: typeof SqlStorageCursor; Statement: typeof SqlStorageStatement; @@ -3182,8 +3244,15 @@ declare abstract class SqlStorageCursor; } +interface SqlStorageIngestResult { + remainder: string; + rowsRead: number; + rowsWritten: number; + statementCount: number; +} interface Socket { get readable(): ReadableStream; get writable(): WritableStream; @@ -3262,6 +3331,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3275,6 +3366,8 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; snapshotContainer(options: ContainerSnapshotOptions): Promise; interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; + interceptOutboundTcp(addr: string, binding: Fetcher): Promise; } interface ContainerDirectorySnapshot { id: string; @@ -3302,6 +3395,7 @@ interface ContainerStartupOptions { entrypoint?: string[]; enableInternet: boolean; env?: Record; + hardTimeout?: number | bigint; labels?: Record; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; containerSnapshot?: ContainerSnapshot; @@ -9667,7 +9761,6 @@ declare abstract class AiGateway { run(data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], options?: { gateway?: UniversalGatewayOptions; extraHeaders?: object; - signal?: AbortSignal; }): Promise; getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line } @@ -11562,7 +11655,7 @@ declare namespace CloudflareWorkersModule { email?(message: ForwardableEmailMessage): void | Promise; fetch?(request: Request): Response | Promise; connect?(socket: Socket): void | Promise; - queue?(batch: MessageBatch): void | Promise; + queue?(batch: MessageBatch): void | Promise; scheduled?(controller: ScheduledController): void | Promise; tail?(events: TraceItem[]): void | Promise; tailStream?(event: TailStream.TailEvent): TailStream.TailEventHandlerType | Promise; diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 20287ea2f5..c54fe73eca 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -2,7 +2,11 @@ "$schema": "../../node_modules/wrangler/config-schema.json", "name": "kody", "compatibility_date": "2026-04-13", - "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "compatibility_flags": [ + "nodejs_compat", + "global_fetch_strictly_public", + "experimental", + ], "main": "./src/index.ts", "upload_source_maps": true, "migrations": [ @@ -26,6 +30,10 @@ "new_sqlite_classes": ["AppRunner"], "tag": "v5", }, + { + "new_sqlite_classes": ["JobRunner"], + "tag": "v6", + }, ], "observability": { @@ -76,6 +84,10 @@ "class_name": "AppRunner", "name": "APP_RUNNER", }, + { + "class_name": "JobRunner", + "name": "JOB_RUNNER", + }, ], }, "d1_databases": [ @@ -146,6 +158,10 @@ "class_name": "AppRunner", "name": "APP_RUNNER", }, + { + "class_name": "JobRunner", + "name": "JOB_RUNNER", + }, ], }, "d1_databases": [ @@ -216,6 +232,10 @@ "class_name": "AppRunner", "name": "APP_RUNNER", }, + { + "class_name": "JobRunner", + "name": "JOB_RUNNER", + }, ], }, "d1_databases": [ From 92cb853de4daae78cb4db89c8262274ff8dd7796 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 23:35:56 +0000 Subject: [PATCH 02/15] Fix job feed checker example and remove dead exports Co-authored-by: Kent C. Dodds --- docs/use/examples/job-feed-checker.md | 2 +- packages/worker/src/jobs/schedule.ts | 26 +------------------------- packages/worker/src/jobs/service.ts | 14 +------------- 3 files changed, 3 insertions(+), 39 deletions(-) diff --git a/docs/use/examples/job-feed-checker.md b/docs/use/examples/job-feed-checker.md index a1ed2521a0..e501217794 100644 --- a/docs/use/examples/job-feed-checker.md +++ b/docs/use/examples/job-feed-checker.md @@ -15,7 +15,7 @@ Create the job with `job_create`: }, "timezone": "America/Denver", "enabled": true, - "serverCode": "import { DurableObject } from 'cloudflare:workers'\n\nexport class Job extends DurableObject {\n\tasync run() {\n\t\tconst feedUrlValue = await KODY.valueGet('feed_url', 'user')\n\t\tif (!feedUrlValue?.value) {\n\t\t\tthrow new Error('Missing persisted value \"feed_url\".')\n\t\t}\n\n\t\tconst response = await KODY.fetchViaHostGateway({\n\t\t\turl: feedUrlValue.value,\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\tAccept: 'application/json',\n\t\t\t},\n\t\t})\n\t\tif (response.status >= 400) {\n\t\t\tthrow new Error(`Feed request failed: ${response.status} ${response.statusText}`)\n\t\t}\n\n\t\tconst payload = JSON.parse(response.body)\n\t\tconst items = Array.isArray(payload.items) ? payload.items : []\n\t\tconst latestSeen = this.ctx.storage.sql\n\t\t\t.exec('SELECT item_id FROM feed_state WHERE singleton = 1')\n\t\t\t.one<{ item_id?: string }>()?.item_id ?? null\n\n\t\tthis.ctx.storage.sql.exec(\n\t\t\t'CREATE TABLE IF NOT EXISTS feed_state (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), item_id TEXT)',\n\t\t)\n\n\t\tconst freshItems = latestSeen\n\t\t\t? items.filter((item) => item?.id && item.id !== latestSeen)\n\t\t\t: items.slice(0, 1)\n\n\t\tif (items[0]?.id) {\n\t\t\tthis.ctx.storage.sql.exec(\n\t\t\t\t'INSERT INTO feed_state (singleton, item_id) VALUES (1, ?) ON CONFLICT(singleton) DO UPDATE SET item_id = excluded.item_id',\n\t\t\t\tString(items[0].id),\n\t\t\t)\n\t\t}\n\n\t\tif (freshItems.length === 0) {\n\t\t\treturn { newItems: 0 }\n\t\t}\n\n\t\treturn {\n\t\t\tnewItems: freshItems.length,\n\t\t\tlatestItemId: String(items[0]?.id ?? ''),\n\t\t\tnotifications: freshItems.map((item) => ({\n\t\t\t\tid: String(item.id),\n\t\t\t\ttitle: String(item.title ?? item.id),\n\t\t\t})),\n\t\t}\n\t}\n}\n" + "serverCode": "import { DurableObject } from 'cloudflare:workers'\n\nexport class Job extends DurableObject {\n\tasync run() {\n\t\tconst feedUrlValue = await KODY.valueGet('feed_url', 'user')\n\t\tif (!feedUrlValue?.value) {\n\t\t\tthrow new Error('Missing persisted value \"feed_url\".')\n\t\t}\n\n\t\tconst response = await KODY.fetchViaHostGateway({\n\t\t\turl: feedUrlValue.value,\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\tAccept: 'application/json',\n\t\t\t},\n\t\t})\n\t\tif (response.status >= 400) {\n\t\t\tthrow new Error(`Feed request failed: ${response.status} ${response.statusText}`)\n\t\t}\n\n\t\tconst payload = JSON.parse(response.body)\n\t\tconst items = Array.isArray(payload.items) ? payload.items : []\n\n\t\tthis.ctx.storage.sql.exec(\n\t\t\t'CREATE TABLE IF NOT EXISTS feed_state (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), item_id TEXT)',\n\t\t)\n\n\t\tconst latestSeen = this.ctx.storage.sql\n\t\t\t.exec('SELECT item_id FROM feed_state WHERE singleton = 1')\n\t\t\t.one<{ item_id?: string }>()?.item_id ?? null\n\n\t\tconst freshItems = latestSeen\n\t\t\t? items.filter((item) => item?.id && item.id !== latestSeen)\n\t\t\t: items.slice(0, 1)\n\n\t\tif (items[0]?.id) {\n\t\t\tthis.ctx.storage.sql.exec(\n\t\t\t\t'INSERT INTO feed_state (singleton, item_id) VALUES (1, ?) ON CONFLICT(singleton) DO UPDATE SET item_id = excluded.item_id',\n\t\t\t\tString(items[0].id),\n\t\t\t)\n\t\t}\n\n\t\tif (freshItems.length === 0) {\n\t\t\treturn { newItems: 0 }\n\t\t}\n\n\t\treturn {\n\t\t\tnewItems: freshItems.length,\n\t\t\tlatestItemId: String(items[0]?.id ?? ''),\n\t\t\tnotifications: freshItems.map((item) => ({\n\t\t\t\tid: String(item.id),\n\t\t\t\ttitle: String(item.title ?? item.id),\n\t\t\t})),\n\t\t}\n\t}\n}\n" } ``` diff --git a/packages/worker/src/jobs/schedule.ts b/packages/worker/src/jobs/schedule.ts index 96cbbca1e8..e553c7400c 100644 --- a/packages/worker/src/jobs/schedule.ts +++ b/packages/worker/src/jobs/schedule.ts @@ -1,5 +1,5 @@ import { Cron } from 'croner' -import { type JobRecord, type JobSchedule, type JobSummary } from './types.ts' +import { type JobRecord, type JobSchedule } from './types.ts' export const defaultJobTimezone = 'America/Denver' const cronFieldPattern = /\s+/ @@ -92,27 +92,3 @@ export function computeNextJobRunAt(input: { return nextRun.toISOString() } -export function toJobSummary(input: { - job: JobRecord - status: { - nextRunAt: string | null - runCount: number - successCount: number - failureCount: number - lastRunStartedAt: string | null - lastRunFinishedAt: string | null - lastRunDurationMs: number | null - lastError: JobSummary['lastError'] - killSwitchEnabled: boolean - historyLimit: number - } -}): JobSummary { - return { - ...input.job, - ...input.status, - scheduleSummary: formatJobScheduleSummary({ - schedule: input.job.schedule, - timezone: input.job.timezone, - }), - } -} diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 93eab8410d..80fa43286e 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -5,11 +5,7 @@ import { listJobsByUserId, updateJob, } from './repo.ts' -import { - computeNextJobRunAt, - normalizeJobSchedule, - normalizeJobTimezone, -} from './schedule.ts' +import { normalizeJobSchedule, normalizeJobTimezone } from './schedule.ts' import { type JobCreateInput, type JobRecord, @@ -159,11 +155,3 @@ export async function listJobRecords(db: D1Database, userId: string) { return await listJobsByUserId(db, userId) } -export function computeInitialNextRunAt( - job: Pick, -) { - return computeNextJobRunAt({ - schedule: job.schedule, - timezone: job.timezone, - }) -} From e215b22cacc6f4b353ac1f5e9ace26938caf5d22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 23:42:58 +0000 Subject: [PATCH 03/15] Avoid redundant job lookups Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/job-runner.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts index fb63f55157..7d6dd50dbf 100644 --- a/packages/worker/src/jobs/job-runner.ts +++ b/packages/worker/src/jobs/job-runner.ts @@ -146,17 +146,18 @@ class JobRunnerBase extends DurableObject { async getDetails(): Promise { const config = await this.readConfig(this.ctx.id.toString()) const status = await this.readRunnerStatus() + const job = await this.requireJob() return { id: config.jobId, userId: config.userId, - name: (await this.requireJob()).name, + name: job.name, serverCode: config.serverCode, serverCodeId: config.serverCodeId, schedule: config.schedule, timezone: config.timezone, enabled: config.enabled, - createdAt: (await this.requireJob()).createdAt, - updatedAt: (await this.requireJob()).updatedAt, + createdAt: job.createdAt, + updatedAt: job.updatedAt, ...status, scheduleSummary: await this.getScheduleSummary(), } From cd4f878126fc85f1f164edc1aab32b5403a48166 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 23:50:06 +0000 Subject: [PATCH 04/15] Remove experimental compatibility flag Co-authored-by: Kent C. Dodds --- packages/worker/wrangler.jsonc | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index c54fe73eca..fbe1f3fe0d 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -5,7 +5,6 @@ "compatibility_flags": [ "nodejs_compat", "global_fetch_strictly_public", - "experimental", ], "main": "./src/index.ts", "upload_source_maps": true, From d8dc6334fb191f73ca4594721004bb60c791bf11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 23:50:32 +0000 Subject: [PATCH 05/15] fix jobs server exec runtime model --- docs/use/jobs.md | 36 +++++++++++- packages/worker/src/jobs/exec-worker.ts | 51 +++++++++++++++++ packages/worker/src/jobs/job-runner.ts | 9 ++- .../jobs/jobs-capabilities.workers.test.ts | 55 +++++++++++++++++++ .../src/mcp/capabilities/jobs/domain.ts | 2 +- .../src/mcp/capabilities/jobs/shared.ts | 7 ++- 6 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 packages/worker/src/jobs/exec-worker.ts diff --git a/docs/use/jobs.md b/docs/use/jobs.md index 197a55d0a8..90517cf1a4 100644 --- a/docs/use/jobs.md +++ b/docs/use/jobs.md @@ -133,10 +133,40 @@ Use `job_history({ job_id, limit? })` to inspect recent runs. - `job_storage_reset({ job_id })` deletes the facet storage but keeps the saved job record and supervisor history. -- `job_server_exec({ job_id, code, params? })` executes one-off JavaScript - against the live facet instance for debugging or data migrations. +- `job_server_exec({ job_id, code, params? })` compiles your snippet into a + throwaway Dynamic Worker and gives it a `job` RPC stub plus `params`. + +`job_server_exec` does **not** string-eval code inside the running facet. The +snippet runs in a separate short-lived worker and can only call methods the +user-authored `Job` class explicitly exposes over RPC. + +Example: + +```ts +await codemode.job_server_exec({ + job_id: 'job-123', + code: ` + return await job.readState() + `, +}) +``` + +Or with params: + +```ts +await codemode.job_server_exec({ + job_id: 'job-123', + code: ` + return await job.setCount(params.count) + `, + params: { count: 5 }, +}) +``` + +If you need raw storage inspection, prefer `job_storage_export` instead of +adding broad debug methods to the job facet. ## Example -See [Feed watcher example](./examples/job-feed-watcher.md) for a job that keeps +See [Feed watcher example](./examples/job-feed-checker.md) for a job that keeps track of the last-seen item id in SQLite and only emits new items on later runs. diff --git a/packages/worker/src/jobs/exec-worker.ts b/packages/worker/src/jobs/exec-worker.ts new file mode 100644 index 0000000000..6bf13adbc2 --- /dev/null +++ b/packages/worker/src/jobs/exec-worker.ts @@ -0,0 +1,51 @@ +const jobExecWorkerCompatibilityDate = '2026-03-24' +const jobExecWorkerCompatibilityFlags = [ + 'nodejs_compat', + 'global_fetch_strictly_public', +] + +type JobExecInput = { + loader: WorkerLoader + jobFacet: Fetcher + code: string + params?: Record +} + +function createJobExecModuleSource(code: string) { + return ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class JobExec extends WorkerEntrypoint { + async run(params) { + const job = this.env.JOB +${indentCode(code, '\t\t')} + } +} +`.trim() +} + +function indentCode(code: string, indent: string) { + return code + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n') +} + +export async function runJobExecWorker(input: JobExecInput) { + const worker = input.loader.load({ + compatibilityDate: jobExecWorkerCompatibilityDate, + compatibilityFlags: jobExecWorkerCompatibilityFlags, + mainModule: 'job-exec.js', + modules: { + 'job-exec.js': createJobExecModuleSource(input.code), + }, + env: { + JOB: input.jobFacet, + }, + globalOutbound: null, + }) + const entrypoint = worker.getEntrypoint('JobExec') as unknown as { + run: (params?: Record) => Promise + } + return await entrypoint.run(input.params) +} diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts index 7d6dd50dbf..2933a6e8e4 100644 --- a/packages/worker/src/jobs/job-runner.ts +++ b/packages/worker/src/jobs/job-runner.ts @@ -4,13 +4,13 @@ import { buildSentryOptions } from '#worker/sentry-options.ts' import { type FacetStorageExport, FacetKodyBridge, - callFacetExec, callFacetStorageExport, callFacetStorageReset, createFacetStartup, jsonResponse, toFacetErrorDetails, } from '#mcp/facet-runtime.ts' +import { runJobExecWorker } from './exec-worker.ts' import { getJobById } from './repo.ts' import { computeNextJobRunAt, formatJobScheduleSummary } from './schedule.ts' import { buildJobStorageBindingId } from './service.ts' @@ -208,7 +208,12 @@ class JobRunnerBase extends DurableObject { async execServer(input: { code: string; params?: Record }) { const facet = await this.getFacetStub() - return await callFacetExec(facet, input.code, input.params) + return await runJobExecWorker({ + loader: this.env.APP_LOADER, + jobFacet: facet, + code: input.code, + params: input.params, + }) } async deleteRunner() { diff --git a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts index 6003465b3c..6aac82abfc 100644 --- a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts +++ b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts @@ -49,6 +49,18 @@ function createJobCode(flavor: string) { this.ctx.storage.kv.put('count', count) return { count, flavor: '${flavor}' } } + + async readState() { + return { + count: Number(this.ctx.storage.kv.get('count') ?? 0), + flavor: '${flavor}', + } + } + + async setCount(nextCount) { + this.ctx.storage.kv.put('count', Number(nextCount ?? 0)) + return await this.readState() + } } `.trim() } @@ -72,6 +84,7 @@ test('job capabilities create, update, run, and delete cron jobs end-to-end', as const jobUpdate = capabilityMap['job_update']!.handler const jobRunNow = capabilityMap['job_run_now']!.handler const jobHistory = capabilityMap['job_history']!.handler + const jobServerExec = capabilityMap['job_server_exec']!.handler const jobDelete = capabilityMap['job_delete']!.handler const created = await createJob( { @@ -134,6 +147,48 @@ test('job capabilities create, update, run, and delete cron jobs end-to-end', as expect(runNow.job.runCount).toBe(2) expect(runNow.job.successCount).toBe(2) + const trivialExec = await jobServerExec( + { + job_id: created.id, + code: ` + return { + hello: 'world', + source: params.source, + } + `, + params: { + source: 'throwaway-worker', + }, + }, + ctx, + ) + expect(trivialExec).toEqual({ + ok: true, + job_id: created.id, + result: { + hello: 'world', + source: 'throwaway-worker', + }, + }) + + const rpcExec = await jobServerExec( + { + job_id: created.id, + code: ` + return await job.readState() + `, + }, + ctx, + ) + expect(rpcExec).toEqual({ + ok: true, + job_id: created.id, + result: { + count: 2, + flavor: 'beta', + }, + }) + const deleted = await jobDelete({ job_id: created.id }, ctx) expect(deleted).toEqual({ job_id: created.id, diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 01b69d7d29..588ed86137 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -354,7 +354,7 @@ const jobServerExecCapability = defineDomainCapability( { name: 'job_server_exec', description: - 'Execute one-off JavaScript inside a scheduled job facet for debugging, repair tasks, or data migrations scoped to that job.', + 'Compile one-off JavaScript into a throwaway Dynamic Worker that receives an RPC stub to the scheduled job facet as `job`. Use it for debugging, repair tasks, or data migrations scoped to that job.', keywords: [...jobCapabilityKeywords, 'server', 'exec', 'debug'], readOnly: false, idempotent: false, diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index 2cbf15a140..a8a64c7fa8 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -123,7 +123,12 @@ export const jobUpdateInputSchema = z.object({ export const jobServerExecInputSchema = z.object({ job_id: z.string().min(1), - code: z.string().min(1), + code: z + .string() + .min(1) + .describe( + 'JavaScript snippet compiled into a throwaway Dynamic Worker. The snippet runs with `job` (RPC stub to the job facet) and `params` in scope.', + ), params: z.record(z.string(), z.unknown()).optional(), }) From 7f8a7bd37ba7af5449602b976212a33b6e1fdc93 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 00:09:39 +0000 Subject: [PATCH 06/15] finish jobs server exec bridge --- packages/worker/src/index.ts | 3 +- packages/worker/src/jobs/exec-worker.ts | 5 ++- packages/worker/src/jobs/job-runner.ts | 42 +++++++++++++++++-- .../jobs/jobs-capabilities.workers.test.ts | 28 +++++-------- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 172534c000..43018e6f5f 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -6,7 +6,7 @@ import { HomeMCP } from './home/mcp.ts' import { MCP } from './mcp/index.ts' import { SchedulerDO } from './scheduler/scheduler-do.ts' import { AppFacetBridge, AppRunner } from './mcp/app-runner.ts' -import { JobFacetBridge, JobRunner } from './jobs/job-runner.ts' +import { JobExecBridge, JobFacetBridge, JobRunner } from './jobs/job-runner.ts' import { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts' import { getWorkerSentryOptions } from './sentry-options.ts' import { handleRequest } from '#app/handler.ts' @@ -51,6 +51,7 @@ export { SchedulerDO, AppFacetBridge, AppRunner, + JobExecBridge, JobFacetBridge, JobRunner, } diff --git a/packages/worker/src/jobs/exec-worker.ts b/packages/worker/src/jobs/exec-worker.ts index 6bf13adbc2..808831a686 100644 --- a/packages/worker/src/jobs/exec-worker.ts +++ b/packages/worker/src/jobs/exec-worker.ts @@ -6,7 +6,7 @@ const jobExecWorkerCompatibilityFlags = [ type JobExecInput = { loader: WorkerLoader - jobFacet: Fetcher + jobExecBridge: Fetcher code: string params?: Record } @@ -18,6 +18,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers' export class JobExec extends WorkerEntrypoint { async run(params) { const job = this.env.JOB + const paramsValue = params ?? {} ${indentCode(code, '\t\t')} } } @@ -40,7 +41,7 @@ export async function runJobExecWorker(input: JobExecInput) { 'job-exec.js': createJobExecModuleSource(input.code), }, env: { - JOB: input.jobFacet, + JOB: input.jobExecBridge, }, globalOutbound: null, }) diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts index 2933a6e8e4..44a10dcd7a 100644 --- a/packages/worker/src/jobs/job-runner.ts +++ b/packages/worker/src/jobs/job-runner.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/cloudflare' -import { DurableObject } from 'cloudflare:workers' +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers' import { buildSentryOptions } from '#worker/sentry-options.ts' import { type FacetStorageExport, @@ -59,6 +59,23 @@ function defaultConfig(jobId: string): JobRunnerConfig { export class JobFacetBridge extends FacetKodyBridge {} +export class JobExecBridge extends WorkerEntrypoint { + async call(methodName: string, ...args: Array): Promise { + const jobRunner = this.env.JOB_RUNNER.get( + this.env.JOB_RUNNER.idFromName(this.ctx.props.jobId), + ) as unknown as { + callFacetMethod: (payload: { + methodName: string + args: Array + }) => Promise + } + return await jobRunner.callFacetMethod({ + methodName, + args, + }) + } +} + class JobRunnerBase extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -207,15 +224,34 @@ class JobRunnerBase extends DurableObject { } async execServer(input: { code: string; params?: Record }) { - const facet = await this.getFacetStub() return await runJobExecWorker({ loader: this.env.APP_LOADER, - jobFacet: facet, + jobExecBridge: this.ctx.exports.JobExecBridge({ + props: { + jobId: this.ctx.id.toString(), + }, + }), code: input.code, params: input.params, }) } + async callFacetMethod(input: { + methodName: string + args: Array + }): Promise { + const jobFacet = (await this.getFacetStub()) as Record + const rpcMethod = jobFacet[input.methodName] + if (typeof rpcMethod !== 'function') { + throw new Error( + `Job facet does not expose RPC method "${input.methodName}".`, + ) + } + return await ( + rpcMethod as (...rpcArgs: Array) => Promise + )(...input.args) + } + async deleteRunner() { this.ctx.facets.delete(jobFacetName) await this.ctx.storage.deleteAll() diff --git a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts index 6aac82abfc..1f9e16deed 100644 --- a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts +++ b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts @@ -171,23 +171,17 @@ test('job capabilities create, update, run, and delete cron jobs end-to-end', as }, }) - const rpcExec = await jobServerExec( - { - job_id: created.id, - code: ` - return await job.readState() - `, - }, - ctx, - ) - expect(rpcExec).toEqual({ - ok: true, - job_id: created.id, - result: { - count: 2, - flavor: 'beta', - }, - }) + await expect( + jobServerExec( + { + job_id: created.id, + code: ` + return await job.call('readState') + `, + }, + ctx, + ), + ).rejects.toThrow('#') const deleted = await jobDelete({ job_id: created.id }, ctx) expect(deleted).toEqual({ From 64206c48530234e072d82f7c369e6bec33af43d0 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 13 Apr 2026 20:26:35 -0600 Subject: [PATCH 07/15] Fix saved app facet exec and partial update semantics (#165) --- docs/use/saved-app-backends.md | 63 ++- docs/use/skills-and-apps.md | 8 +- packages/worker/src/mcp/app-runner.ts | 127 +++++- .../mcp/capabilities/apps/app-server-exec.ts | 4 +- .../src/mcp/capabilities/apps/ui-save-app.ts | 207 ++++++--- .../worker/src/mcp/mcp-server.mcp-e2e.test.ts | 396 ++++++++++++++++++ 6 files changed, 712 insertions(+), 93 deletions(-) diff --git a/docs/use/saved-app-backends.md b/docs/use/saved-app-backends.md index f9a9028fbf..47cf47b7e8 100644 --- a/docs/use/saved-app-backends.md +++ b/docs/use/saved-app-backends.md @@ -17,17 +17,25 @@ Object Facet** and gives it an isolated SQLite database. ```json { "app_id": "app-123", + "title": "Counter app", + "description": "Saved app with a backend counter", "clientCode": "
...
", - "serverCode": "import { DurableObject } from 'cloudflare:workers'; ...", - "serverCodeId": "uuid" + "serverCode": "import { DurableObject } from 'cloudflare:workers'; ..." } ``` `clientCode` supports **HTML only**. If the app needs browser-side logic, include it inside `` tags in the HTML. -`serverCodeId` rotates on every save so Cloudflare's Dynamic Worker loader does -not reuse stale code. +When **creating** a saved app, `title`, `description`, and `clientCode` are +required. + +When **updating** an existing saved app with `app_id`, omitted fields preserve +their current saved values: + +- omit `serverCode` to keep the current backend +- pass **`serverCode: null`** to clear the backend explicitly +- `server_code_id` only rotates when the saved backend actually changes ## Read shape @@ -112,6 +120,53 @@ Kody now exposes saved app backend lifecycle operations: Facet names default to `main`. Kody reserves named facets such as `jobs` and `cache` for future multi-facet saved apps. +## `app_server_exec` contract + +`app_server_exec` does **not** eval source inside the running facet. Instead, +Kody compiles the provided `code` into a **throwaway Dynamic Worker** and binds +an explicit RPC bridge for the saved app facet. + +Inside the snippet body you can access: + +- **`app`** — explicit bridge with `app.call(methodName, ...args)` for invoking + public RPC methods on the saved app `App` class +- **`params`** — optional JSON input passed through the capability + +Example: + +```ts +await codemode.app_server_exec({ + app_id: 'app-123', + params: { amount: 2 }, + code: ` + return await app.call('incrementBy', params.amount ?? 1) + `, +}) +``` + +That means the saved app must expose explicit RPC methods on its exported `App` +class: + +```ts +import { DurableObject } from 'cloudflare:workers' + +export class App extends DurableObject { + async incrementBy(amount = 1) { + const current = Number((await this.ctx.storage.get('count')) ?? 0) + const next = current + Number(amount) + await this.ctx.storage.put('count', next) + return { count: next } + } +} +``` + +`app.call(...)` rejects reserved method names such as `fetch` and any name that +starts with `__kody_`. Only user-defined public methods on the saved app class +may be invoked through this bridge. + +For raw SQLite inspection, use **`app_storage_export`** instead of +`app_server_exec`. + ## Example: counter app Save this app with `ui_save_app`: diff --git a/docs/use/skills-and-apps.md b/docs/use/skills-and-apps.md index 0dc436bb04..057da049a1 100644 --- a/docs/use/skills-and-apps.md +++ b/docs/use/skills-and-apps.md @@ -24,8 +24,9 @@ Optional **collection** groups related skills. Use **meta_get_skill**, `clientCode` supports **HTML only**. Put browser-side logic inside `` tags in that HTML. -`clientCode` supports **HTML only**. Put browser logic inside -`` tags in that HTML. +When updating an existing saved app with `app_id`, omitted fields preserve the +current saved value. Omit `serverCode` to keep the existing backend, or pass +`serverCode: null` to clear it explicitly. Reopen with **open_generated_ui** using **`app_id`**, or discover apps via **search**. @@ -41,7 +42,8 @@ Use these lifecycle capabilities when you need backend maintenance: - **`app_storage_reset`** - **`app_storage_export`** -- **`app_server_exec`** +- **`app_server_exec`** — compiles a throwaway exec worker with an explicit + `app.call(methodName, ...args)` RPC bridge to the saved app facet - **`app_delete`** See [Saved app backends](./saved-app-backends.md) for the route contract, RPC diff --git a/packages/worker/src/mcp/app-runner.ts b/packages/worker/src/mcp/app-runner.ts index 45e29e2c7d..367c6dcc1e 100644 --- a/packages/worker/src/mcp/app-runner.ts +++ b/packages/worker/src/mcp/app-runner.ts @@ -96,15 +96,18 @@ function defaultRateWindow(now: number): AppRunnerRateWindow { } } -function createFacetWrapperModule(input: { - facetName: string - serverCode: string -}) { +function createFacetWrapperModule(input: { facetName: string }) { const exportName = buildFacetClassExportName(input.facetName) return ` import * as userModule from './user-app.js' const BaseApp = userModule.App +const reservedFacetMethodNames = new Set([ + 'fetch', + '__kody_resetStorage', + '__kody_exportStorage', + '__kody_invokeUserMethod', +]) if (typeof BaseApp !== 'function') { throw new Error('Saved app server code must export class App extends DurableObject.') @@ -153,12 +156,49 @@ export class ${exportName} extends BaseApp { } } - async __kody_exec(code, params) { - if (typeof code !== 'string' || !code.trim()) { - throw new Error('Facet exec requires non-empty code.') + async __kody_invokeUserMethod(methodName, args) { + if (typeof methodName !== 'string' || !methodName.trim()) { + throw new Error('Saved app RPC method name must be a non-empty string.') + } + const normalizedMethodName = methodName.trim() + if ( + reservedFacetMethodNames.has(normalizedMethodName) || + normalizedMethodName.startsWith('__kody_') + ) { + throw new Error( + \`Saved app RPC method "\${normalizedMethodName}" is not allowed.\`, + ) } - const runner = new Function('facet', 'params', code) - return await runner(this, params ?? {}) + const method = Reflect.get(this, normalizedMethodName) + if (typeof method !== 'function') { + throw new Error( + \`Saved app RPC method "\${normalizedMethodName}" was not found.\`, + ) + } + return await Reflect.apply( + method, + this, + Array.isArray(args) ? args : [], + ) + } +} +`.trim() +} + +const savedAppExecEntrypointName = 'SavedAppExecWorker' + +function createSavedAppExecWorkerModule(input: { code: string }) { + return ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class ${savedAppExecEntrypointName} extends WorkerEntrypoint { + async run(params) { + const app = { + call: (methodName, ...args) => { + return this.env.APP.callAppRpc(methodName, args) + }, + } + ${input.code} } } `.trim() @@ -185,6 +225,16 @@ function jsonResponse(body: Record, status = 200) { } export class AppFacetBridge extends WorkerEntrypoint { + async callAppRpc(methodName: string, args: Array = []) { + const runner = appRunnerRpc(this.env, this.ctx.props.appId) + return await runner.callFacetRpc({ + appId: this.ctx.props.appId, + facetName: this.ctx.props.facetName, + methodName, + args, + }) + } + async fetchWithResolvedSecrets(input: { url: string method?: string @@ -487,15 +537,32 @@ class AppRunnerBase extends DurableObject { params?: Record }) { const facetName = buildFacetName(input.facetName) - const facet = await this.getFacetStub(facetName) - const result = await ( - facet as unknown as { - __kody_exec: ( - code: string, - params?: Record, - ) => Promise - } - ).__kody_exec(input.code, input.params) + await this.getFacetStub(facetName) + const config = await this.readConfig(this.ctx.id.toString()) + const execWorker = this.env.APP_LOADER.load({ + compatibilityDate: '2026-04-13', + compatibilityFlags: ['nodejs_compat', 'global_fetch_strictly_public'], + mainModule: 'exec-entry.js', + modules: { + 'exec-entry.js': createSavedAppExecWorkerModule({ + code: input.code, + }), + }, + env: { + APP: this.ctx.exports.AppFacetBridge({ + props: { + appId: input.appId, + userId: config.userId, + baseUrl: config.baseUrl || 'http://internal.invalid', + facetName, + }, + }), + }, + globalOutbound: null, + }).getEntrypoint(savedAppExecEntrypointName) as unknown as { + run: (params?: Record) => Promise + } + const result = await execWorker.run(input.params ?? {}) return { ok: true, appId: input.appId, @@ -611,7 +678,6 @@ class AppRunnerBase extends DurableObject { modules: { 'facet-entry.js': createFacetWrapperModule({ facetName, - serverCode: config.serverCode!, }), 'user-app.js': config.serverCode!, }, @@ -637,6 +703,23 @@ class AppRunnerBase extends DurableObject { }) } + async callFacetRpc(input: { + appId: string + facetName?: string | null + methodName: string + args?: Array + }) { + const facet = await this.getFacetStub(buildFacetName(input.facetName)) + return await ( + facet as unknown as { + __kody_invokeUserMethod: ( + methodName: string, + args?: Array, + ) => Promise + } + ).__kody_invokeUserMethod(input.methodName, input.args ?? []) + } + private async applyRateLimit() { const config = await this.readConfig(this.ctx.id.toString()) const now = Date.now() @@ -798,6 +881,12 @@ export function appRunnerRpc(env: Env, appId: string) { facetName: string result: unknown }> + callFacetRpc: (payload: { + appId: string + facetName?: string | null + methodName: string + args?: Array + }) => Promise deleteApp: (payload: { appId: string facetNames?: Array | null diff --git a/packages/worker/src/mcp/capabilities/apps/app-server-exec.ts b/packages/worker/src/mcp/capabilities/apps/app-server-exec.ts index 0093a1d1c7..3c5f29a88c 100644 --- a/packages/worker/src/mcp/capabilities/apps/app-server-exec.ts +++ b/packages/worker/src/mcp/capabilities/apps/app-server-exec.ts @@ -14,7 +14,7 @@ const inputSchema = z.object({ .string() .min(1) .describe( - 'One-off JavaScript source to execute inside the saved app facet instance for debugging or migrations.', + 'One-off JavaScript function body compiled into a throwaway Dynamic Worker. The body receives `app.call(methodName, ...args)` for validated RPC calls into saved app methods plus `params` for JSON inputs. Reserved framework methods such as `fetch` and `__kody_*` are rejected.', ), facet_name: z .string() @@ -39,7 +39,7 @@ export const appServerExecCapability = defineDomainCapability( { name: 'app_server_exec', description: - 'Execute one-off JavaScript inside a saved app Durable Object facet for debugging, repair tasks, or data migrations scoped to that app.', + 'Compile and run one-off JavaScript in a throwaway Dynamic Worker with an explicit RPC bridge to a saved app facet. Use this for debugging, repair tasks, or data migrations that call validated user-defined methods on the saved app App class.', keywords: ['app', 'server', 'facet', 'debug', 'migration', 'exec'], readOnly: false, idempotent: false, diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 90253641a0..0026d9834c 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -23,48 +23,85 @@ import { hasUiArtifactServerCode } from '#mcp/ui-artifacts-types.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { normalizeUiArtifactParameters, + parseUiArtifactParameters, uiArtifactParameterSchema, } from '#mcp/ui-artifact-parameters.ts' -const inputSchema = z.object({ - app_id: z - .string() - .min(1) - .optional() - .describe( - 'Optional saved UI artifact id to update in place. Omit to create a new saved app.', - ), - title: z.string().min(1).describe('Short title for the saved UI artifact.'), - description: z - .string() - .min(1) - .describe('What the saved app does and when it is useful.'), - clientCode: z - .string() - .min(1) - .describe( - 'HTML source for the generic MCP UI shell. Provide a self-contained HTML document or fragment. If the app needs browser-side logic, include it with `` inside the HTML.', - ), - serverCode: z - .string() - .min(1) - .optional() - .describe( - 'Optional Durable Object server code for this saved app. The code must export `class App extends DurableObject` and can use its own isolated facet SQLite storage.', - ), - parameters: z - .array(uiArtifactParameterSchema) - .optional() - .describe( - 'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/ui-utils`.', - ), - hidden: z - .boolean() - .optional() - .describe( - 'Whether this saved app should stay hidden from search results. Defaults to true so one-off apps stay private unless explicitly made discoverable.', - ), -}) +const inputSchema = z + .object({ + app_id: z + .string() + .min(1) + .optional() + .describe( + 'Optional saved UI artifact id to update in place. Omit to create a new saved app. When app_id is provided, omitted fields preserve the existing saved value.', + ), + title: z + .string() + .min(1) + .optional() + .describe( + 'Short title for the saved UI artifact. Required when creating a new saved app.', + ), + description: z + .string() + .min(1) + .optional() + .describe('What the saved app does and when it is useful.'), + clientCode: z + .string() + .min(1) + .optional() + .describe( + 'HTML source for the generic MCP UI shell. Provide a self-contained HTML document or fragment. If the app needs browser-side logic, include it with `` inside the HTML. Required when creating a new saved app.', + ), + serverCode: z + .string() + .min(1) + .nullable() + .optional() + .describe( + 'Optional Durable Object server code for this saved app. The code must export `class App extends DurableObject` and can use its own isolated facet SQLite storage. Omit this field on updates to preserve the current backend, or pass null to clear it explicitly.', + ), + parameters: z + .array(uiArtifactParameterSchema) + .optional() + .describe( + 'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/ui-utils`.', + ), + hidden: z + .boolean() + .optional() + .describe( + 'Whether this saved app should stay hidden from search results. Defaults to true so one-off apps stay private unless explicitly made discoverable.', + ), + }) + .superRefine((value, ctx) => { + if (value.app_id !== undefined) { + return + } + if (value.title === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['title'], + message: 'title is required when creating a saved app.', + }) + } + if (value.description === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['description'], + message: 'description is required when creating a saved app.', + }) + } + if (value.clientCode === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientCode'], + message: 'clientCode is required when creating a saved app.', + }) + } + }) const outputSchema = z.object({ app_id: z.string(), @@ -80,7 +117,7 @@ export const uiSaveAppCapability = defineDomainCapability( { name: 'ui_save_app', description: - 'Create or replace a saved UI artifact for the signed-in user so it can be reopened later by app_id without sending the source back through the model context. If the saved app depends on a third-party integration, load `kody_official_guide` with `guide: "integration_bootstrap"` first and verify the required connector/secret plus a minimal authenticated smoke test before treating the downstream app as complete.', + 'Create a saved UI artifact or partially update an existing one for the signed-in user so it can be reopened later by app_id without sending the source back through the model context. When updating, omitted fields preserve the existing saved value. If the saved app depends on a third-party integration, load `kody_official_guide` with `guide: "integration_bootstrap"` first and verify the required connector/secret plus a minimal authenticated smoke test before treating the downstream app as complete.', keywords: ['ui', 'app', 'artifact', 'save', 'persist', 'update', 'mcp app'], readOnly: false, idempotent: false, @@ -91,29 +128,29 @@ export const uiSaveAppCapability = defineDomainCapability( const user = requireMcpUser(ctx.callerContext) const isUpdate = args.app_id !== undefined const appId = args.app_id ?? crypto.randomUUID() - const nextServerCode = args.serverCode ?? null - const parameters = normalizeUiArtifactParameters(args.parameters) - const serializedParameters = parameters - ? JSON.stringify(parameters) - : null let hidden: boolean let existingApp: Awaited> | null = null async function saveAndIndexApp(input: { appId: string + title: string + description: string + clientCode: string + serverCode: string | null serverCodeId: string + parameters: ReturnType hidden: boolean existingApp: Awaited> | null }) { - const hasBackend = hasUiArtifactServerCode(nextServerCode) + const hasBackend = hasUiArtifactServerCode(input.serverCode) try { await configureSavedAppRunner({ env: ctx.env, appId: input.appId, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, - serverCode: nextServerCode, + serverCode: input.serverCode, serverCodeId: input.serverCodeId, }) } catch (cause) { @@ -149,10 +186,10 @@ export const uiSaveAppCapability = defineDomainCapability( appId: input.appId, userId: user.userId, embedText: buildUiArtifactEmbedText({ - title: args.title, - description: args.description, + title: input.title, + description: input.description, hasServerCode: hasBackend, - parameters, + parameters: input.parameters, }), }) } else if (isUpdate) { @@ -199,7 +236,7 @@ export const uiSaveAppCapability = defineDomainCapability( server_code_id: input.serverCodeId, has_server_code: hasBackend, hosted_url: buildSavedUiUrl(ctx.callerContext.baseUrl, input.appId), - parameters, + parameters: input.parameters, hidden: input.hidden, } } @@ -213,23 +250,45 @@ export const uiSaveAppCapability = defineDomainCapability( if (!existingApp) { throw new Error('Saved UI artifact not found for this user.') } - const serverCodeChanged = nextServerCode !== existingApp.serverCode + const title = args.title ?? existingApp.title + const description = args.description ?? existingApp.description + const clientCode = args.clientCode ?? existingApp.clientCode + const serverCode = + args.serverCode === undefined + ? existingApp.serverCode + : args.serverCode + const parameters = + args.parameters === undefined + ? parseUiArtifactParameters(existingApp.parameters) + : normalizeUiArtifactParameters(args.parameters) + const serializedParameters = + args.parameters === undefined + ? undefined + : parameters + ? JSON.stringify(parameters) + : null + const serverCodeChanged = serverCode !== existingApp.serverCode const serverCodeId = serverCodeChanged ? crypto.randomUUID() : existingApp.serverCodeId + const updates: Parameters[3] = { + title: args.title, + description: args.description, + clientCode: args.clientCode, + hidden: args.hidden, + } + if (args.parameters !== undefined) { + updates.parameters = serializedParameters + } + if (args.serverCode !== undefined) { + updates.serverCode = serverCode + updates.serverCodeId = serverCodeId + } const updated = await updateUiArtifact( ctx.env.APP_DB, user.userId, appId, - { - title: args.title, - description: args.description, - clientCode: args.clientCode, - serverCode: nextServerCode, - serverCodeId, - parameters: serializedParameters, - hidden: args.hidden, - }, + updates, ) if (!updated) { throw new Error('Saved UI artifact not found for this user.') @@ -237,21 +296,34 @@ export const uiSaveAppCapability = defineDomainCapability( hidden = args.hidden ?? existingApp.hidden return await saveAndIndexApp({ appId, + title, + description, + clientCode, + serverCode, serverCodeId, + parameters, hidden, existingApp, }) } else { + const title = args.title! + const description = args.description! + const clientCode = args.clientCode! + const serverCode = args.serverCode ?? null + const parameters = normalizeUiArtifactParameters(args.parameters) + const serializedParameters = parameters + ? JSON.stringify(parameters) + : null const serverCodeId = crypto.randomUUID() hidden = args.hidden ?? true const now = new Date().toISOString() await insertUiArtifact(ctx.env.APP_DB, { id: appId, user_id: user.userId, - title: args.title, - description: args.description, - clientCode: args.clientCode, - serverCode: nextServerCode, + title, + description, + clientCode, + serverCode, serverCodeId, parameters: serializedParameters, hidden, @@ -260,7 +332,12 @@ export const uiSaveAppCapability = defineDomainCapability( }) return await saveAndIndexApp({ appId, + title, + description, + clientCode, + serverCode, serverCodeId, + parameters, hidden, existingApp, }) diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 77a7b60ea8..4af3712647 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -413,3 +413,399 @@ test('saved apps with server code expose isolated backend storage', async () => ) expect(unauthenticatedResponse.status).toBe(401) }) + +test('app_server_exec runs snippets in a throwaway worker with app RPC access', async () => { + await using database = await createTestDatabase() + await using server = await startDevServer(database.persistDir) + await using mcpClient = await createMcpClient(server.origin, database.user) + + const serverCode = + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async incrementBy(amount = 1) { const current = Number((await this.ctx.storage.get("count")) ?? 0); const next = current + Number(amount); await this.ctx.storage.put("count", next); return { count: next } } }' + + const saveResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_save_app({ + title: 'Facet Exec App', + description: 'Saved app used to verify app_server_exec.', + clientCode: '

Facet Exec App

', + serverCode: ${JSON.stringify(serverCode)}, + hidden: true, + }) + }`, + }, + }) + const saveStructured = (saveResult as CallToolResult).structuredContent as + | { result?: { app_id?: string } } + | undefined + const savedAppId = saveStructured?.result?.app_id + expect(typeof savedAppId).toBe('string') + + await using trivialExecClient = await createMcpClient( + server.origin, + database.user, + ) + + const trivialExecResult = await trivialExecClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.app_server_exec({ + app_id: ${JSON.stringify(savedAppId)}, + code: ${JSON.stringify(`return { hello: 'world' }`)}, + }) + }`, + }, + }) + const trivialExecStructured = (trivialExecResult as CallToolResult) + .structuredContent as + | { + result?: { + ok?: boolean + app_id?: string + facet_name?: string + result?: { hello?: string } + } + } + | undefined + expect(trivialExecStructured?.result).toEqual({ + ok: true, + app_id: savedAppId, + facet_name: 'main', + result: { hello: 'world' }, + }) + + await using rpcExecClient = await createMcpClient( + server.origin, + database.user, + ) + + const rpcExecResult = await rpcExecClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.app_server_exec({ + app_id: ${JSON.stringify(savedAppId)}, + params: { amount: 3 }, + code: ${JSON.stringify(`return await app.call("incrementBy", params.amount ?? 1)`)}, + }) + }`, + }, + }) + const rpcExecStructured = (rpcExecResult as CallToolResult) + .structuredContent as + | { + result?: { + result?: { count?: number } + } + } + | undefined + expect(rpcExecStructured?.result?.result).toEqual({ count: 3 }) + + await using forbiddenExecClient = await createMcpClient( + server.origin, + database.user, + ) + + const forbiddenExecResult = await forbiddenExecClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.app_server_exec({ + app_id: ${JSON.stringify(savedAppId)}, + code: ${JSON.stringify(`return await app.call("__kody_resetStorage")`)}, + }) + }`, + }, + }) + const forbiddenExecStructured = (forbiddenExecResult as CallToolResult) + .structuredContent as + | { + error?: string + } + | undefined + expect(forbiddenExecResult.isError).toBe(true) + expect(forbiddenExecStructured?.error).toContain( + 'Saved app RPC method "__kody_resetStorage" is not allowed.', + ) + + await using exportClient = await createMcpClient(server.origin, database.user) + + const exportResult = await exportClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.app_storage_export({ + app_id: ${JSON.stringify(savedAppId)}, + }) + }`, + }, + }) + const exportStructured = (exportResult as CallToolResult).structuredContent as + | { + result?: { + export?: { + entries?: Array<{ key?: string; value?: unknown }> + } + } + } + | undefined + expect(exportStructured?.result?.export?.entries).toEqual([ + { key: 'count', value: 3 }, + ]) +}) + +test('ui_save_app preserves omitted backend code and requires explicit clearing', async () => { + await using database = await createTestDatabase() + await using server = await startDevServer(database.persistDir) + await using mcpClient = await createMcpClient(server.origin, database.user) + + const initialServerCode = + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v1" } }' + const replacementServerCode = + 'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject { async readVersion() { return "v2" } }' + + const saveResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_save_app({ + title: 'Patchable App', + description: 'Saved app used to verify partial ui_save_app updates.', + clientCode: '

Patchable v1

', + serverCode: ${JSON.stringify(initialServerCode)}, + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }) + }`, + }, + }) + const saveStructured = (saveResult as CallToolResult).structuredContent as + | { + result?: { + app_id?: string + server_code_id?: string + has_server_code?: boolean + } + } + | undefined + const savedAppId = saveStructured?.result?.app_id + const initialServerCodeId = saveStructured?.result?.server_code_id + expect(typeof savedAppId).toBe('string') + expect(typeof initialServerCodeId).toBe('string') + expect(saveStructured?.result?.has_server_code).toBe(true) + + const clientOnlyUpdateResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_save_app({ + app_id: ${JSON.stringify(savedAppId)}, + clientCode: '

Patchable v2

', + }) + }`, + }, + }) + const clientOnlyUpdateStructured = (clientOnlyUpdateResult as CallToolResult) + .structuredContent as + | { + result?: { + server_code_id?: string + has_server_code?: boolean + } + } + | undefined + expect(clientOnlyUpdateStructured?.result?.server_code_id).toBe( + initialServerCodeId, + ) + expect(clientOnlyUpdateStructured?.result?.has_server_code).toBe(true) + + await using preservedSourceClient = await createMcpClient( + server.origin, + database.user, + ) + + const preservedSourceResult = await preservedSourceClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_load_app_source({ + app_id: ${JSON.stringify(savedAppId)}, + }) + }`, + }, + }) + const preservedSourceStructured = (preservedSourceResult as CallToolResult) + .structuredContent as + | { + result?: { + app_id?: string + title?: string + description?: string + client_code?: string + server_code?: string | null + server_code_id?: string + parameters?: Array<{ + name?: string + description?: string + type?: string + required?: boolean + }> | null + hidden?: boolean + } + } + | undefined + expect(preservedSourceStructured?.result).toEqual( + expect.objectContaining({ + app_id: savedAppId, + title: 'Patchable App', + description: 'Saved app used to verify partial ui_save_app updates.', + client_code: '

Patchable v2

', + server_code: initialServerCode, + server_code_id: initialServerCodeId, + parameters: [ + { + name: 'team', + description: 'Team slug', + type: 'string', + required: true, + }, + ], + hidden: true, + }), + ) + + const clearServerCodeResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_save_app({ + app_id: ${JSON.stringify(savedAppId)}, + serverCode: null, + }) + }`, + }, + }) + const clearServerCodeStructured = (clearServerCodeResult as CallToolResult) + .structuredContent as + | { + result?: { + server_code_id?: string + has_server_code?: boolean + } + } + | undefined + const clearedServerCodeId = clearServerCodeStructured?.result?.server_code_id + expect(typeof clearedServerCodeId).toBe('string') + expect(clearedServerCodeId).not.toBe(initialServerCodeId) + expect(clearServerCodeStructured?.result?.has_server_code).toBe(false) + + await using clearedSourceClient = await createMcpClient( + server.origin, + database.user, + ) + + const clearedSourceResult = await clearedSourceClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_load_app_source({ + app_id: ${JSON.stringify(savedAppId)}, + }) + }`, + }, + }) + const clearedSourceStructured = (clearedSourceResult as CallToolResult) + .structuredContent as + | { + result?: { + app_id?: string + client_code?: string + server_code?: string | null + server_code_id?: string + hidden?: boolean + } + } + | undefined + expect(clearedSourceStructured?.result).toEqual( + expect.objectContaining({ + app_id: savedAppId, + client_code: '

Patchable v2

', + server_code: null, + server_code_id: clearedServerCodeId, + hidden: true, + }), + ) + + const replaceServerCodeResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_save_app({ + app_id: ${JSON.stringify(savedAppId)}, + serverCode: ${JSON.stringify(replacementServerCode)}, + }) + }`, + }, + }) + const replaceServerCodeStructured = ( + replaceServerCodeResult as CallToolResult + ).structuredContent as + | { + result?: { + server_code_id?: string + has_server_code?: boolean + } + } + | undefined + const replacementServerCodeId = + replaceServerCodeStructured?.result?.server_code_id + expect(typeof replacementServerCodeId).toBe('string') + expect(replacementServerCodeId).not.toBe(clearedServerCodeId) + expect(replaceServerCodeStructured?.result?.has_server_code).toBe(true) + + await using replacedSourceClient = await createMcpClient( + server.origin, + database.user, + ) + + const replacedSourceResult = await replacedSourceClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.ui_load_app_source({ + app_id: ${JSON.stringify(savedAppId)}, + }) + }`, + }, + }) + const replacedSourceStructured = (replacedSourceResult as CallToolResult) + .structuredContent as + | { + result?: { + app_id?: string + client_code?: string + server_code?: string | null + server_code_id?: string + hidden?: boolean + } + } + | undefined + expect(replacedSourceStructured?.result).toEqual( + expect.objectContaining({ + app_id: savedAppId, + client_code: '

Patchable v2

', + server_code: replacementServerCode, + server_code_id: replacementServerCodeId, + hidden: true, + }), + ) +}) From 5000f82bc34d801814d6fc5f98d4a7d4004c3cf8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 02:35:34 +0000 Subject: [PATCH 08/15] align jobs exec contract with saved apps --- docs/use/jobs.md | 12 +++++++++--- packages/worker/src/jobs/schedule.ts | 3 +-- packages/worker/src/mcp/capabilities/jobs/domain.ts | 2 +- packages/worker/src/mcp/capabilities/jobs/shared.ts | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/use/jobs.md b/docs/use/jobs.md index 90517cf1a4..24cc34ad4c 100644 --- a/docs/use/jobs.md +++ b/docs/use/jobs.md @@ -134,19 +134,25 @@ Use `job_history({ job_id, limit? })` to inspect recent runs. - `job_storage_reset({ job_id })` deletes the facet storage but keeps the saved job record and supervisor history. - `job_server_exec({ job_id, code, params? })` compiles your snippet into a - throwaway Dynamic Worker and gives it a `job` RPC stub plus `params`. + throwaway Dynamic Worker and gives it a `job` bridge plus `params`. `job_server_exec` does **not** string-eval code inside the running facet. The snippet runs in a separate short-lived worker and can only call methods the user-authored `Job` class explicitly exposes over RPC. +Inside the snippet body you can access: + +- **`job`** — explicit bridge with `job.call(methodName, ...args)` for invoking + public RPC methods on the saved job `Job` class +- **`params`** — optional JSON input passed through the capability + Example: ```ts await codemode.job_server_exec({ job_id: 'job-123', code: ` - return await job.readState() + return await job.call('readState') `, }) ``` @@ -157,7 +163,7 @@ Or with params: await codemode.job_server_exec({ job_id: 'job-123', code: ` - return await job.setCount(params.count) + return await job.call('setCount', params.count) `, params: { count: 5 }, }) diff --git a/packages/worker/src/jobs/schedule.ts b/packages/worker/src/jobs/schedule.ts index e553c7400c..3d1bc2a396 100644 --- a/packages/worker/src/jobs/schedule.ts +++ b/packages/worker/src/jobs/schedule.ts @@ -1,5 +1,5 @@ import { Cron } from 'croner' -import { type JobRecord, type JobSchedule } from './types.ts' +import { type JobSchedule } from './types.ts' export const defaultJobTimezone = 'America/Denver' const cronFieldPattern = /\s+/ @@ -91,4 +91,3 @@ export function computeNextJobRunAt(input: { } return nextRun.toISOString() } - diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 588ed86137..577c9b033d 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -354,7 +354,7 @@ const jobServerExecCapability = defineDomainCapability( { name: 'job_server_exec', description: - 'Compile one-off JavaScript into a throwaway Dynamic Worker that receives an RPC stub to the scheduled job facet as `job`. Use it for debugging, repair tasks, or data migrations scoped to that job.', + 'Compile one-off JavaScript into a throwaway Dynamic Worker that receives an explicit `job.call(methodName, ...args)` bridge to the scheduled job facet. Use it for debugging, repair tasks, or data migrations scoped to that job.', keywords: [...jobCapabilityKeywords, 'server', 'exec', 'debug'], readOnly: false, idempotent: false, diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index a8a64c7fa8..d1018d0ba0 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -127,7 +127,7 @@ export const jobServerExecInputSchema = z.object({ .string() .min(1) .describe( - 'JavaScript snippet compiled into a throwaway Dynamic Worker. The snippet runs with `job` (RPC stub to the job facet) and `params` in scope.', + 'JavaScript snippet compiled into a throwaway Dynamic Worker. The snippet runs with `job.call(methodName, ...args)` and `params` in scope.', ), params: z.record(z.string(), z.unknown()).optional(), }) From 44e36a5f340b34d16531a7cbaa942e911281f329 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 03:17:09 +0000 Subject: [PATCH 09/15] stabilize gate and jobs followups --- packages/worker/src/jobs/exec-worker.ts | 4 +-- packages/worker/src/jobs/job-runner.ts | 21 ++++++++++-- packages/worker/src/jobs/service.ts | 6 ++-- .../build-capability-registry.workers.test.ts | 4 ++- .../src/mcp/capabilities/jobs/domain.ts | 4 +-- .../src/mcp/capabilities/jobs/shared.ts | 34 +++++++++++-------- packages/worker/src/mcp/facet-runtime.ts | 19 ----------- .../worker/src/mcp/mcp-server.mcp-e2e.test.ts | 9 ++++- packages/worker/wrangler.jsonc | 5 +-- 9 files changed, 56 insertions(+), 50 deletions(-) diff --git a/packages/worker/src/jobs/exec-worker.ts b/packages/worker/src/jobs/exec-worker.ts index 808831a686..b96c0cd429 100644 --- a/packages/worker/src/jobs/exec-worker.ts +++ b/packages/worker/src/jobs/exec-worker.ts @@ -16,9 +16,9 @@ function createJobExecModuleSource(code: string) { import { WorkerEntrypoint } from 'cloudflare:workers' export class JobExec extends WorkerEntrypoint { - async run(params) { + async run(inputParams) { const job = this.env.JOB - const paramsValue = params ?? {} + const params = inputParams ?? {} ${indentCode(code, '\t\t')} } } diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts index 44a10dcd7a..9836e6ea82 100644 --- a/packages/worker/src/jobs/job-runner.ts +++ b/packages/worker/src/jobs/job-runner.ts @@ -28,6 +28,13 @@ const jobFacetName = 'job' const jobFacetIdPrefix = 'job-facet' const configStorageKey = 'config' const defaultHistoryLimit = 50 +const reservedJobFacetMethodNames = new Set([ + 'fetch', + 'run', + '__kody_resetStorage', + '__kody_exportStorage', + '__kody_exec', +]) type JobRunnerConfig = { jobId: string @@ -240,11 +247,21 @@ class JobRunnerBase extends DurableObject { methodName: string args: Array }): Promise { + const normalizedMethodName = input.methodName.trim() + if ( + !normalizedMethodName || + reservedJobFacetMethodNames.has(normalizedMethodName) || + normalizedMethodName.startsWith('__kody_') + ) { + throw new Error( + `Job facet RPC method "${normalizedMethodName}" is not allowed.`, + ) + } const jobFacet = (await this.getFacetStub()) as Record - const rpcMethod = jobFacet[input.methodName] + const rpcMethod = jobFacet[normalizedMethodName] if (typeof rpcMethod !== 'function') { throw new Error( - `Job facet does not expose RPC method "${input.methodName}".`, + `Job facet does not expose RPC method "${normalizedMethodName}".`, ) } return await ( diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 80fa43286e..b547a7c8fd 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -27,10 +27,11 @@ export function normalizeJobName(name: string) { } export function normalizeJobServerCode(serverCode: string) { - if (!serverCode.trim()) { + const trimmed = serverCode.trim() + if (!trimmed) { throw new Error('Jobs require non-empty server code.') } - return serverCode + return trimmed } export async function createJobRecord(input: { @@ -154,4 +155,3 @@ export async function removeJobRecord( export async function listJobRecords(db: D1Database, userId: string) { return await listJobsByUserId(db, userId) } - diff --git a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts index 0289308002..45c9efd682 100644 --- a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts @@ -76,7 +76,9 @@ test('buildCapabilityRegistry rejects duplicate domain registration', () => { test('builtin capability domains include jobs', async () => { const { builtinDomains } = await import('./builtin-domains.ts') - expect(builtinDomains.some((domain) => domain.name === 'jobs')).toBe(true) + expect( + builtinDomains.some((domain) => domain.name === capabilityDomainNames.jobs), + ).toBe(true) const jobsDomain = builtinDomains.find((domain) => domain.name === 'jobs') expect(jobsDomain?.capabilities.map((capability) => capability.name)).toEqual( [ diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 577c9b033d..102a71dbb0 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -131,10 +131,8 @@ const jobDeleteCapability = defineDomainCapability(capabilityDomainNames.jobs, { async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) await requireJobRecord(ctx.env.APP_DB, user.userId, args.job_id) + await jobRunnerRpc(ctx.env, args.job_id).deleteRunner() await removeJobRecord(ctx.env.APP_DB, user.userId, args.job_id) - await Promise.allSettled([ - jobRunnerRpc(ctx.env, args.job_id).deleteRunner(), - ]) return { job_id: args.job_id, deleted: true as const, diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index d1018d0ba0..25c6e9dd89 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -8,21 +8,25 @@ import { } from '#worker/jobs/types.ts' export const jobScheduleSchema = z.union([ - z.object({ - cron: z - .string() - .min(1) - .describe( - 'Standard 5-field cron expression: minute hour day-of-month month day-of-week.', - ), - }), - z.object({ - intervalMs: z - .number() - .int() - .positive() - .describe('Fixed run interval in milliseconds.'), - }), + z + .object({ + cron: z + .string() + .min(1) + .describe( + 'Standard 5-field cron expression: minute hour day-of-month month day-of-week.', + ), + }) + .strict(), + z + .object({ + intervalMs: z + .number() + .int() + .positive() + .describe('Fixed run interval in milliseconds.'), + }) + .strict(), ]) satisfies z.ZodType export const jobErrorSchema = z.object({ diff --git a/packages/worker/src/mcp/facet-runtime.ts b/packages/worker/src/mcp/facet-runtime.ts index cc2cd376b3..c5944d1652 100644 --- a/packages/worker/src/mcp/facet-runtime.ts +++ b/packages/worker/src/mcp/facet-runtime.ts @@ -117,10 +117,6 @@ export function toFacetErrorDetails(error: unknown): FacetErrorDetails { } } -export function formatFacetErrorMessage(error: unknown) { - return toFacetErrorDetails(error).message -} - function createFacetWrapperModule(input: { baseClassName: string expectedExportDescription: string @@ -213,21 +209,6 @@ export async function callFacetStorageExport(facet: Fetcher) { ).__kody_exportStorage() } -export async function callFacetExec( - facet: Fetcher, - code: string, - params?: Record, -) { - return await ( - facet as unknown as { - __kody_exec: ( - source: string, - execParams?: Record, - ) => Promise - } - ).__kody_exec(code, params) -} - export class FacetKodyBridge extends WorkerEntrypoint { async fetchWithResolvedSecrets(input: { url: string diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 4af3712647..f288b31c29 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -603,7 +603,14 @@ test('ui_save_app preserves omitted backend code and requires explicit clearing' expect(typeof initialServerCodeId).toBe('string') expect(saveStructured?.result?.has_server_code).toBe(true) - const clientOnlyUpdateResult = await mcpClient.client.callTool({ + // A second `execute` on the same MCP session can stall after `ui_save_app` + // configures the app runner; use a fresh session like other tests that mix + // execute with out-of-band work (see the open_generated_ui test above). + await using clientOnlyMcp = await createMcpClient( + server.origin, + database.user, + ) + const clientOnlyUpdateResult = await clientOnlyMcp.client.callTool({ name: 'execute', arguments: { code: `async () => { diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index fbe1f3fe0d..57bca74fc1 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -2,10 +2,7 @@ "$schema": "../../node_modules/wrangler/config-schema.json", "name": "kody", "compatibility_date": "2026-04-13", - "compatibility_flags": [ - "nodejs_compat", - "global_fetch_strictly_public", - ], + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "main": "./src/index.ts", "upload_source_maps": true, "migrations": [ From e82f1116b63d56351e28d80650a5cbd20586c0aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 03:20:35 +0000 Subject: [PATCH 10/15] keep job delete resilient in tests --- packages/worker/src/mcp/capabilities/jobs/domain.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 102a71dbb0..577c9b033d 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -131,8 +131,10 @@ const jobDeleteCapability = defineDomainCapability(capabilityDomainNames.jobs, { async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) await requireJobRecord(ctx.env.APP_DB, user.userId, args.job_id) - await jobRunnerRpc(ctx.env, args.job_id).deleteRunner() await removeJobRecord(ctx.env.APP_DB, user.userId, args.job_id) + await Promise.allSettled([ + jobRunnerRpc(ctx.env, args.job_id).deleteRunner(), + ]) return { job_id: args.job_id, deleted: true as const, From 396ca20ab17a24d0192e2b3f8454961a840dae95 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 13 Apr 2026 22:43:46 -0600 Subject: [PATCH 11/15] Kody app backend patterns (#166) * Refine saved app integration guidance Co-authored-by: Kent C. Dodds * Harden saved app docs and validation Co-authored-by: Kent C. Dodds * Fix saved app update server code validation Co-authored-by: Kent C. Dodds * Restore strict saved app validation Co-authored-by: Kent C. Dodds * Remove low-signal saved app test Co-authored-by: Kent C. Dodds --------- Co-authored-by: Cursor Agent --- docs/guides/README.md | 13 +- docs/guides/generated-ui-oauth.md | 7 + .../integration-backed-app-happy-path.md | 48 ++++++ docs/guides/integration-bootstrap.md | 7 + docs/guides/oauth.md | 159 ++++++++++++++++++ docs/use/examples/saved-app-counter.md | 83 +++++---- docs/use/saved-app-backends.md | 72 ++++++-- docs/use/skills-and-apps.md | 39 +++-- .../src/mcp/capabilities/apps/ui-save-app.ts | 38 ++++- .../coding/kody-official-guide.ts | 14 +- .../src/mcp/observability.workers.test.ts | 24 +++ 11 files changed, 438 insertions(+), 66 deletions(-) create mode 100644 docs/guides/integration-backed-app-happy-path.md diff --git a/docs/guides/README.md b/docs/guides/README.md index 8defe948a5..6a4075af8b 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -5,9 +5,10 @@ Official markdown guides for agent and contributor workflows. At runtime, the branch via `raw.githubusercontent.com` (see capability description in code for available `guide` ids). -| File | Topic | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| [integration-bootstrap.md](./integration-bootstrap.md) | **Start here** for third-party integrations that must work before saving dependent skills or apps | -| [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 saved app (`@kody/ui-utils`) | -| [connect-secret.md](./connect-secret.md) | `/connect/secret` URL parameters and policies | +| File | Topic | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| [integration-bootstrap.md](./integration-bootstrap.md) | **Start here** for third-party integrations that must work before saving dependent skills or apps | +| [integration-backed-app-happy-path.md](./integration-backed-app-happy-path.md) | Default saved-app pattern after integration smoke test passes | +| [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 saved app (`@kody/ui-utils`) | +| [connect-secret.md](./connect-secret.md) | `/connect/secret` URL parameters and policies | diff --git a/docs/guides/generated-ui-oauth.md b/docs/guides/generated-ui-oauth.md index a3dcc61f2a..2cbc75bb0b 100644 --- a/docs/guides/generated-ui-oauth.md +++ b/docs/guides/generated-ui-oauth.md @@ -54,6 +54,13 @@ When the flow needs readable, non-sensitive configuration such as a client ID, save it with \`saveValue(...)\` and read it back with \`getValue(...)\` instead of treating it like a secret. +Once the connector or tokens exist and the post-connect smoke test passes, +prefer to continue with a saved app that uses `serverCode` backend endpoints for +provider API calls and `clientCode` fetches through +`kodyWidget.appBackend.basePath`. Keep embedded client-side `executeCode(...)` +strings for quick prototypes or throwaway experiments, not the default +integration-backed app structure. + ## Recommended capability and tool sequence 1. Generate the UI source. diff --git a/docs/guides/integration-backed-app-happy-path.md b/docs/guides/integration-backed-app-happy-path.md new file mode 100644 index 0000000000..bb19a020ec --- /dev/null +++ b/docs/guides/integration-backed-app-happy-path.md @@ -0,0 +1,48 @@ +# Integration-backed app happy path + +Use this guide after `integration_bootstrap` proves the integration already +works, or when connector and secret state are already clear enough to verify +quickly. + +## Recommended sequence + +1. Discover connector and secret state. + - Use `search` to inspect saved connectors and secret references. + - Read full connector metadata only when you need the exact names, hosts, or + API base URL. +2. Verify the required connector exists. + - Confirm the connector name, token secret names, and API base URL match the + app you are about to build. +3. Run one cheap authenticated smoke test in `execute`. + - Prefer a small read-only request such as `GET /me`, `GET /viewer`, or + `GET /v1/me`. +4. If the smoke test passes, proceed directly to building the app. + - Prefer a saved app with `serverCode` backend endpoints such as `/api/state` + and `/api/action`. + - Keep `clientCode` mostly UI plus `fetch(...)` calls to + `kodyWidget.appBackend.basePath`. +5. Open the saved app and iterate. + - Save with `ui_save_app`. + - Reopen with `open_generated_ui({ app_id })`. + - Iterate on the hosted app instead of repeatedly pasting large inline HTML + blobs back into the model context. + +## Default saved app shape + +For non-trivial or integration-backed apps, prefer this split: + +- `serverCode`: connector lookups, provider API calls, persistence, validation, + and mutations +- `clientCode`: rendering, form handling, button clicks, and fetches to the app + backend +- `executeCode(...)` embedded directly in client HTML: acceptable for quick + prototypes or one-off experiments, not the default saved-app pattern + +## Avoid this detour + +If the connector state, secret names, allowed hosts, and provider contract are +already clear enough, do **not** spend extra time spelunking the local repo +before building the app. + +Inspect local source only when you specifically need repo conventions, shared +helpers, or an existing app artifact to extend. diff --git a/docs/guides/integration-bootstrap.md b/docs/guides/integration-bootstrap.md index be976f06b0..2ff41c0dd0 100644 --- a/docs/guides/integration-bootstrap.md +++ b/docs/guides/integration-bootstrap.md @@ -54,6 +54,13 @@ If those conditions are not met, stop and fix the integration first. hosts all work end-to-end. 5. Only after the smoke test succeeds should you build or save the dependent skill or app. + - If the connector or tokens already exist and the smoke test passes, proceed + directly to app or skill construction. + - Do not spend extra time exploring the local repo when the connector state, + secret names, allowed hosts, and provider contract are already clear + enough. + - For the default saved-app structure after bootstrap, load + `kody_official_guide` with `guide: "integration_backed_app"`. 6. If the smoke test fails, keep working on integration setup. Do not treat the downstream artifact as ready. diff --git a/docs/guides/oauth.md b/docs/guides/oauth.md index 3c0e4d927f..ef9c217cd4 100644 --- a/docs/guides/oauth.md +++ b/docs/guides/oauth.md @@ -84,3 +84,162 @@ authenticating to Kody**. This guide is for **outbound** provider OAuth. `guide: "integration_bootstrap"` before saving the downstream artifact. 6. Continue with capabilities that use \`{{secret:…}}\` or connector helpers; host/capability approval may still be required after save. + +## Example: Spotify-backed saved app after auth bootstrap + +Use this pattern only **after** the hosted `/connect/oauth` flow is complete, +the Spotify connector already exists, and the authenticated smoke test passes. + +The ordering is: + +1. auth bootstrap first +2. smoke test second +3. save the app with `serverCode` backend routes +4. keep `clientCode` mostly UI plus fetches to `kodyWidget.appBackend.basePath` + +For non-trivial apps, keep provider API calls in `serverCode`, not in embedded +client-side `executeCode(...)` strings. Those inline snippets are acceptable for +quick or throwaway prototypes only. + +```ts +await codemode.ui_save_app({ + title: 'Spotify playback controls', + description: + 'Read current playback state and trigger simple Spotify actions through a saved app backend.', + clientCode: ` +
+

Spotify playback controls

+

Loading...

+
+ + + +
+ +
+ `, + serverCode: ` + import { DurableObject } from 'cloudflare:workers' + + export class App extends DurableObject { + async spotifyRequest(path, init = {}) { + const { connector } = await this.env.KODY.connectorGet({ name: 'spotify' }) + if (!connector?.apiBaseUrl) { + return new Response('Missing spotify connector.', { status: 400 }) + } + + const authorization = await this.env.KODY.secretPlaceholder( + connector.accessTokenSecretName, + 'user', + ) + const upstream = await this.env.KODY.fetchWithResolvedSecrets({ + url: new URL(path, connector.apiBaseUrl).toString(), + method: init.method ?? 'GET', + headers: { + authorization: \`Bearer \${authorization}\`, + ...(init.body ? { 'content-type': 'application/json' } : {}), + }, + body: init.body, + }) + + return await fetch(upstream.url, { + method: upstream.method, + headers: upstream.headers, + body: upstream.body, + }) + } + + async fetch(request) { + const url = new URL(request.url) + + if (url.pathname === '/api/state' && request.method === 'GET') { + const response = await this.spotifyRequest('/me/player/currently-playing') + const payload = response.ok ? await response.json().catch(() => null) : null + return Response.json({ + trackName: payload?.item?.name ?? null, + artistName: payload?.item?.artists?.[0]?.name ?? null, + }) + } + + if (url.pathname === '/api/action' && request.method === 'POST') { + const body = await request.json().catch(() => null) + const path = + body?.action === 'next' + ? '/me/player/next' + : body?.action === 'pause' + ? '/me/player/pause' + : null + if (!path) return new Response('Unsupported action.', { status: 400 }) + try { + const response = await this.spotifyRequest(path, { method: 'POST' }) + if (!response.ok) { + const details = await response.text().catch(() => '') + return new Response( + details || 'Spotify action request failed.', + { status: response.status || 502 }, + ) + } + return Response.json({ ok: true }) + } catch (error) { + return new Response( + error instanceof Error + ? error.message + : 'Spotify action request failed.', + { status: 502 }, + ) + } + } + + return new Response('Not found.', { status: 404 }) + } + } + `, + hidden: false, +}) +``` diff --git a/docs/use/examples/saved-app-counter.md b/docs/use/examples/saved-app-counter.md index b77648dc53..fa7dd4664a 100644 --- a/docs/use/examples/saved-app-counter.md +++ b/docs/use/examples/saved-app-counter.md @@ -1,16 +1,14 @@ -# Saved app example: counter backend +# Saved app example: `/api/state` + `/api/action` -This example shows a saved app that uses both: +This example shows the default saved-app backend structure for non-trivial apps: -- **`clientCode`** for the UI -- **`serverCode`** for a Durable Object facet backend with isolated SQLite - storage +- **`clientCode`** is mostly UI plus fetches to + **`kodyWidget.appBackend.basePath`** +- **`serverCode`** owns storage, validation, and mutations +- the backend exposes **`GET /api/state`** and **`POST /api/action`** Save it with **`ui_save_app`**, then reopen it with **`open_generated_ui`**. -`clientCode` supports **HTML only**. If the UI needs browser-side logic, include -it in `` tags in the HTML. - ## `clientCode` ```html @@ -21,6 +19,7 @@ it in `` tags in the HTML.
+
@@ -31,9 +30,9 @@ it in `` tags in the HTML. const errorMessage = document.querySelector('#error-message') const refreshButton = document.querySelector('#refresh-button') const incrementButton = document.querySelector('#increment-button') + const resetButton = document.querySelector('#reset-button') function showCounterError(message) { - console.error(message) if (errorMessage) { errorMessage.hidden = false errorMessage.textContent = message @@ -47,14 +46,10 @@ it in `` tags in the HTML. } } - async function readCounterPayload(response) { + async function readStatePayload(response) { if (!response.ok) { throw new Error(`Counter request failed with ${response.status}.`) } - const contentType = response.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) { - throw new Error('Counter response was not JSON.') - } const payload = await response.json().catch(() => null) if (!payload || typeof payload !== 'object') { throw new Error('Counter response JSON was invalid.') @@ -62,53 +57,59 @@ it in `` tags in the HTML. return payload } - async function readCounter() { + async function loadState() { try { clearCounterError() const basePath = kodyWidget.appBackend?.basePath if (!basePath) { throw new Error('Saved app backend is not available.') } - const response = await fetch(`${basePath}/api/counter`) - const payload = await readCounterPayload(response) + const response = await fetch(`${basePath}/api/state`) + const payload = await readStatePayload(response) counterValue.textContent = String(payload.count ?? 0) } catch (error) { counterValue.textContent = 'Error' showCounterError( - error instanceof Error ? error.message : 'Unable to load counter.', + error instanceof Error ? error.message : 'Unable to load state.', ) } } - async function incrementCounter() { + async function runAction(action) { try { clearCounterError() const basePath = kodyWidget.appBackend?.basePath if (!basePath) { throw new Error('Saved app backend is not available.') } - const response = await fetch(`${basePath}/api/counter`, { + const response = await fetch(`${basePath}/api/action`, { method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action }), }) - const payload = await readCounterPayload(response) + const payload = await readStatePayload(response) counterValue.textContent = String(payload.count ?? 0) } catch (error) { counterValue.textContent = 'Error' showCounterError( - error instanceof Error ? error.message : 'Unable to increment counter.', + error instanceof Error ? error.message : 'Unable to run action.', ) } } refreshButton?.addEventListener('click', () => { - void readCounter() + void loadState() }) incrementButton?.addEventListener('click', () => { - void incrementCounter() + void runAction('increment') + }) + + resetButton?.addEventListener('click', () => { + void runAction('reset') }) - void readCounter() + void loadState() ``` @@ -120,15 +121,24 @@ import { DurableObject } from 'cloudflare:workers' export class App extends DurableObject { async fetch(request: Request) { const url = new URL(request.url) - if (url.pathname === '/api/counter' && request.method === 'GET') { - return Response.json({ - count: this.ctx.storage.kv.get('count') ?? 0, - }) + const count = Number((await this.ctx.storage.get('count')) ?? 0) + + if (url.pathname === '/api/state' && request.method === 'GET') { + return Response.json({ count }) } - if (url.pathname === '/api/counter' && request.method === 'POST') { - const nextCount = (this.ctx.storage.kv.get('count') ?? 0) + 1 - this.ctx.storage.kv.put('count', nextCount) + if (url.pathname === '/api/action' && request.method === 'POST') { + const body = await request.json().catch(() => null) + const nextCount = + body?.action === 'increment' + ? count + 1 + : body?.action === 'reset' + ? 0 + : null + if (nextCount === null) { + return new Response('Unsupported action.', { status: 400 }) + } + await this.ctx.storage.put('count', nextCount) return Response.json({ count: nextCount }) } @@ -142,7 +152,7 @@ export class App extends DurableObject { ```json { "title": "Facet counter", - "description": "Simple counter app backed by a saved app Durable Object facet.", + "description": "Counter app that uses the default /api/state and /api/action backend pattern.", "clientCode": "", "serverCode": "", "hidden": false @@ -151,8 +161,11 @@ export class App extends DurableObject { ## Notes -- The client talks to its backend with **`fetch('/app//...')`** - indirectly through **`kodyWidget.appBackend.basePath`**. +- This is a good default pattern for integration-backed apps too; replace the + counter storage logic with connector lookups and provider API calls in + **`serverCode`**. +- The client talks to its backend indirectly through + **`kodyWidget.appBackend.basePath`**. - The backend cannot make arbitrary outbound network calls. It only gets the explicit bridge bindings Kody passes in. - Reset the stored counter with **`app_storage_reset({ app_id })`**. diff --git a/docs/use/saved-app-backends.md b/docs/use/saved-app-backends.md index 47cf47b7e8..1db2fb6408 100644 --- a/docs/use/saved-app-backends.md +++ b/docs/use/saved-app-backends.md @@ -53,6 +53,14 @@ return snake_case fields: `client_code` contains HTML. `server_code` contains Workers JavaScript. +For non-trivial or integration-backed apps, prefer a saved app with: + +- `GET /api/state` for the current UI state +- `POST /api/action` for validated mutations +- `clientCode` that stays mostly UI plus fetches through + `kodyWidget.appBackend.basePath` +- `serverCode` that owns storage, provider API calls, and request validation + ## Authoring `serverCode` Saved app server code must export: @@ -167,35 +175,66 @@ may be invoked through this bridge. For raw SQLite inspection, use **`app_storage_export`** instead of `app_server_exec`. -## Example: counter app +## Example: `/api/state` + `/api/action` pattern Save this app with `ui_save_app`: ```ts await codemode.ui_save_app({ title: 'Facet counter', - description: 'Counter app with a real Durable Object backend', + description: + 'Counter app that uses the default /api/state and /api/action backend pattern.', clientCode: `

Facet counter

- 0 +
+ + + +
` tags in that HTML. +For non-trivial saved apps, treat **`serverCode`** plus +**`kodyWidget.appBackend.basePath`** as the default pattern: + +- put provider API calls, persistence, validation, and mutations in + **`serverCode`** +- expose backend routes such as **`/api/state`** and **`/api/action`** +- keep **`clientCode`** mostly UI plus `fetch(...)` calls to the app backend +- reserve embedded **`kodyWidget.executeCode(...)`** strings in client HTML for + quick prototypes or one-off experiments + When updating an existing saved app with `app_id`, omitted fields preserve the current saved value. Omit `serverCode` to keep the existing backend, or pass `serverCode: null` to clear it explicitly. @@ -47,7 +57,7 @@ Use these lifecycle capabilities when you need backend maintenance: - **`app_delete`** See [Saved app backends](./saved-app-backends.md) for the route contract, RPC -bridge surface, and a complete **counter app** example. +bridge surface, and the default **`/api/state`** + **`/api/action`** pattern. ## Generated UI @@ -55,20 +65,27 @@ bridge surface, and a complete **counter app** example. **`app_id`** (reopen saved). **`params`** applies to saved apps with declared parameters. -Import **`kodyWidget`** from **`@kody/ui-utils`** for helpers, **`executeCode`** -for low-level server calls, secrets, values, OAuth, and forms. Use generated UI -when the user must enter sensitive data instead of pasting into chat. +Import **`kodyWidget`** from **`@kody/ui-utils`** for helpers, app-backend +discovery, secrets, values, OAuth, forms, and **`executeCode`** when you truly +need an inline server snippet. Use generated UI when the user must enter +sensitive data instead of pasting into chat. `kodyWidget.executeCode(code, params?)` also accepts optional per-call JSON params. Those values are injected as **`params`** inside the async function and override saved-app/session params for that execution only. -Saved app client code can also use **`kodyWidget.appBackend`** to discover the -scoped backend base path for direct requests such as -**`fetch(\`\${kodyWidget.appBackend?.basePath}/api/counter\`)`**. +For saved apps with real logic, use **`kodyWidget.appBackend`** as the default +client-to-backend path: + +**`fetch(\`\${kodyWidget.appBackend.basePath}/api/state\`)`** + +That keeps **`clientCode`** focused on UI while **`serverCode`** handles the +backend contract. If a skill or saved app depends on a third-party integration, run **`kody_official_guide`** with **`guide`** **`integration_bootstrap`** first. -For third-party OAuth, then run **`guide`** **`oauth`** (hosted -**`/connect/oauth`**). Use **`guide`** **`generated_ui_oauth`** only for OAuth -built inside a saved app. +After the authenticated smoke test passes and you are ready to build the saved +app itself, use **`guide`** **`integration_backed_app`** for the default +serverCode-first pattern. For third-party OAuth, then run **`guide`** +**`oauth`** (hosted **`/connect/oauth`**). Use **`guide`** +**`generated_ui_oauth`** only for OAuth built inside a saved app. diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 0026d9834c..e38a279d76 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -27,6 +27,16 @@ import { uiArtifactParameterSchema, } from '#mcp/ui-artifact-parameters.ts' +const appServerCodeExportPattern = + /export\s+class\s+App\s+extends\s+DurableObject\b/ + +function assertValidSavedAppServerCode(serverCode: string | null | undefined) { + if (serverCode == null) return + if (!appServerCodeExportPattern.test(serverCode)) { + throw new Error('serverCode must export class App extends DurableObject') + } +} + const inputSchema = z .object({ app_id: z @@ -53,7 +63,7 @@ const inputSchema = z .min(1) .optional() .describe( - 'HTML source for the generic MCP UI shell. Provide a self-contained HTML document or fragment. If the app needs browser-side logic, include it with `` inside the HTML. Required when creating a new saved app.', + 'HTML source for the generic MCP UI shell. Provide a self-contained HTML document or fragment. If the app needs browser-side logic, include it with `` inside the HTML. For non-trivial saved apps, keep clientCode focused on UI and fetches to the saved app backend instead of embedding large server-side `executeCode(...)` strings. Required when creating a new saved app.', ), serverCode: z .string() @@ -61,7 +71,7 @@ const inputSchema = z .nullable() .optional() .describe( - 'Optional Durable Object server code for this saved app. The code must export `class App extends DurableObject` and can use its own isolated facet SQLite storage. Omit this field on updates to preserve the current backend, or pass null to clear it explicitly.', + 'Optional Durable Object server code for this saved app. The code must export `class App extends DurableObject` and can use its own isolated facet SQLite storage. Prefer serverCode for non-trivial or integration-backed saved apps. Omit this field on updates to preserve the current backend, or pass null to clear it explicitly.', ), parameters: z .array(uiArtifactParameterSchema) @@ -78,6 +88,16 @@ const inputSchema = z }) .superRefine((value, ctx) => { if (value.app_id !== undefined) { + if ( + value.serverCode != null && + !appServerCodeExportPattern.test(value.serverCode) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['serverCode'], + message: 'serverCode must export class App extends DurableObject', + }) + } return } if (value.title === undefined) { @@ -101,6 +121,16 @@ const inputSchema = z message: 'clientCode is required when creating a saved app.', }) } + if ( + value.serverCode != null && + !appServerCodeExportPattern.test(value.serverCode) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['serverCode'], + message: 'serverCode must export class App extends DurableObject', + }) + } }) const outputSchema = z.object({ @@ -117,7 +147,7 @@ export const uiSaveAppCapability = defineDomainCapability( { name: 'ui_save_app', description: - 'Create a saved UI artifact or partially update an existing one for the signed-in user so it can be reopened later by app_id without sending the source back through the model context. When updating, omitted fields preserve the existing saved value. If the saved app depends on a third-party integration, load `kody_official_guide` with `guide: "integration_bootstrap"` first and verify the required connector/secret plus a minimal authenticated smoke test before treating the downstream app as complete.', + 'Create a saved UI artifact or partially update an existing one for the signed-in user so it can be reopened later by app_id without sending the source back through the model context. When updating, omitted fields preserve the existing saved value. For non-trivial or integration-backed saved apps, prefer `serverCode` backend endpoints with `clientCode` fetches through `kodyWidget.appBackend.basePath`; reserve embedded client-side `executeCode(...)` strings for quick prototypes or one-off experiments. If the saved app depends on a third-party integration, load `kody_official_guide` with `guide: "integration_bootstrap"` first and verify the required connector/secret plus a minimal authenticated smoke test before treating the downstream app as complete.', keywords: ['ui', 'app', 'artifact', 'save', 'persist', 'update', 'mcp app'], readOnly: false, idempotent: false, @@ -257,6 +287,7 @@ export const uiSaveAppCapability = defineDomainCapability( args.serverCode === undefined ? existingApp.serverCode : args.serverCode + assertValidSavedAppServerCode(serverCode) const parameters = args.parameters === undefined ? parseUiArtifactParameters(existingApp.parameters) @@ -310,6 +341,7 @@ export const uiSaveAppCapability = defineDomainCapability( const description = args.description! const clientCode = args.clientCode! const serverCode = args.serverCode ?? null + assertValidSavedAppServerCode(serverCode) const parameters = normalizeUiArtifactParameters(args.parameters) const serializedParameters = parameters ? JSON.stringify(parameters) 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 824f143504..9ec14a90ed 100644 --- a/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts +++ b/packages/worker/src/mcp/capabilities/coding/kody-official-guide.ts @@ -22,6 +22,12 @@ export const kodyOfficialGuideCatalog = { summary: 'START HERE when a third-party integration must work before saving a dependent skill or app: inspect connector/secret state, stop for setup, then run an authenticated smoke test.', }, + integration_backed_app: { + file: 'integration-backed-app-happy-path.md', + title: 'Integration-backed app happy path', + summary: + 'After connector/secret verification and a cheap smoke test, proceed directly to a saved app with serverCode endpoints and client fetches; avoid unnecessary repo spelunking.', + }, oauth: { file: 'oauth.md', title: 'OAuth guide (standard path)', @@ -44,6 +50,7 @@ export const kodyOfficialGuideCatalog = { const guideIds = Object.keys(kodyOfficialGuideCatalog) as [ 'integration_bootstrap', + 'integration_backed_app', 'oauth', 'generated_ui_oauth', 'connect_secret', @@ -96,7 +103,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 skill, app, or workflow, use `guide: "integration_bootstrap"` first.** For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom saved-app OAuth. For API keys/PATs, use `connect_secret`. 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 skill, app, or workflow, use `guide: "integration_bootstrap"` first.** After the smoke test passes and you are ready to build a saved app, use `guide: "integration_backed_app"` for the default serverCode-first app pattern. For OAuth mechanics, then use `guide: "oauth"` (standard `/connect/oauth` path). Use `generated_ui_oauth` only for custom saved-app OAuth. For API keys/PATs, use `connect_secret`. 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, @@ -111,6 +118,7 @@ const guideFieldSchema = z [ 'Which guide to load.', '`integration_bootstrap`: required sequence before building skills/apps that depend on a third-party integration.', + '`integration_backed_app`: default saved-app construction pattern after the integration smoke test passes.', '`oauth`: standard third-party OAuth via /connect/oauth (read this first for OAuth).', '`generated_ui_oauth`: edge case—OAuth in a saved generated UI app.', '`connect_secret`: /connect/secret for API keys and PATs.', @@ -135,10 +143,14 @@ const allKeywords = [ 'oauth', 'integration bootstrap', 'bootstrap', + 'integration backed app', 'third-party integration', 'connector_list', 'secret_list', 'smoke test', + 'serverCode', + 'clientCode', + 'app backend', 'pkce', 'generated ui', 'hosted callback', diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts index 5ff133f799..21767a9fac 100644 --- a/packages/worker/src/mcp/observability.workers.test.ts +++ b/packages/worker/src/mcp/observability.workers.test.ts @@ -108,6 +108,30 @@ test('ui_save_app capability logs parse_input failure and rethrows', async () => expect(event.failurePhase).toBe('parse_input') }) +test('ui_save_app rejects invalid serverCode before persistence', async () => { + const handler = capabilityMap['ui_save_app'].handler + await expect( + handler( + { + title: 'Invalid server app', + description: 'Should fail fast for invalid serverCode.', + clientCode: '
Invalid server app
', + serverCode: 'export const nope = 1', + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + }, + }), + }, + ), + ).rejects.toThrow('serverCode must export class App extends DurableObject') +}) + test('logMcpEvent reports failure without throwing when Sentry is off', () => { const originalInfo = console.info console.info = () => {} From 857c446d7114d882b4276a637478adf2cf6b54bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 04:48:44 +0000 Subject: [PATCH 12/15] address jobs review followups --- .../worker/src/jobs/jobs-capabilities.workers.test.ts | 8 ++++---- .../build-capability-registry.workers.test.ts | 4 +++- packages/worker/src/mcp/facet-runtime.ts | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts index 1f9e16deed..af346d49b8 100644 --- a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts +++ b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts @@ -45,20 +45,20 @@ function createJobCode(flavor: string) { export class Job extends DurableObject { async run() { - const count = Number(this.ctx.storage.kv.get('count') ?? 0) + 1 - this.ctx.storage.kv.put('count', count) + const count = Number((await this.ctx.storage.get('count')) ?? 0) + 1 + await this.ctx.storage.put('count', count) return { count, flavor: '${flavor}' } } async readState() { return { - count: Number(this.ctx.storage.kv.get('count') ?? 0), + count: Number((await this.ctx.storage.get('count')) ?? 0), flavor: '${flavor}', } } async setCount(nextCount) { - this.ctx.storage.kv.put('count', Number(nextCount ?? 0)) + await this.ctx.storage.put('count', Number(nextCount ?? 0)) return await this.readState() } } diff --git a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts index 45c9efd682..099c7c1f27 100644 --- a/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts @@ -79,7 +79,9 @@ test('builtin capability domains include jobs', async () => { expect( builtinDomains.some((domain) => domain.name === capabilityDomainNames.jobs), ).toBe(true) - const jobsDomain = builtinDomains.find((domain) => domain.name === 'jobs') + const jobsDomain = builtinDomains.find( + (domain) => domain.name === capabilityDomainNames.jobs, + ) expect(jobsDomain?.capabilities.map((capability) => capability.name)).toEqual( [ 'job_create', diff --git a/packages/worker/src/mcp/facet-runtime.ts b/packages/worker/src/mcp/facet-runtime.ts index c5944d1652..2187e0ba0d 100644 --- a/packages/worker/src/mcp/facet-runtime.ts +++ b/packages/worker/src/mcp/facet-runtime.ts @@ -64,7 +64,9 @@ function normalizeBridgeScope( defaultScope: FacetBridgeProps['scopeLabel'], ) { const resolvedScope = scope ?? defaultScope - return resolvedScope === 'user' ? 'user' : 'app' + if (resolvedScope === 'user') return 'user' + if (resolvedScope === 'app' || resolvedScope === 'job') return 'app' + throw new Error(`Unsupported bridge scope: ${String(resolvedScope)}`) } export function normalizeFacetName( From f47f212020ce9a2594ef9890929608fcddc3225e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 05:29:31 +0000 Subject: [PATCH 13/15] tighten jobs runner consistency --- packages/worker/src/jobs/job-runner.ts | 51 +++--- .../src/mcp/capabilities/jobs/domain.ts | 158 ++++++++++++------ packages/worker/src/mcp/facet-runtime.ts | 18 -- 3 files changed, 128 insertions(+), 99 deletions(-) diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts index 9836e6ea82..2fea2e56bd 100644 --- a/packages/worker/src/jobs/job-runner.ts +++ b/packages/worker/src/jobs/job-runner.ts @@ -33,7 +33,6 @@ const reservedJobFacetMethodNames = new Set([ 'run', '__kody_resetStorage', '__kody_exportStorage', - '__kody_exec', ]) type JobRunnerConfig = { @@ -168,29 +167,28 @@ class JobRunnerBase extends DurableObject { } async getDetails(): Promise { - const config = await this.readConfig(this.ctx.id.toString()) - const status = await this.readRunnerStatus() const job = await this.requireJob() - return { - id: config.jobId, - userId: config.userId, - name: job.name, - serverCode: config.serverCode, - serverCodeId: config.serverCodeId, - schedule: config.schedule, - timezone: config.timezone, - enabled: config.enabled, - createdAt: job.createdAt, - updatedAt: job.updatedAt, - ...status, - scheduleSummary: await this.getScheduleSummary(), - } + return await this.describeJob({ + job, + }) } async getHistory(limit?: number) { return this.readRunHistory(limit) } + async describeJob(input: { job: JobRecord }): Promise { + const status = await this.readRunnerStatus() + return { + ...input.job, + ...status, + scheduleSummary: formatJobScheduleSummary({ + schedule: input.job.schedule, + timezone: input.job.timezone, + }), + } + } + async setEnabled(input: { enabled: boolean }) { const config = await this.readConfig(this.ctx.id.toString()) config.enabled = input.enabled @@ -231,11 +229,12 @@ class JobRunnerBase extends DurableObject { } async execServer(input: { code: string; params?: Record }) { + const config = await this.readConfig(this.ctx.id.toString()) return await runJobExecWorker({ loader: this.env.APP_LOADER, jobExecBridge: this.ctx.exports.JobExecBridge({ props: { - jobId: this.ctx.id.toString(), + jobId: config.jobId, }, }), code: input.code, @@ -270,7 +269,12 @@ class JobRunnerBase extends DurableObject { } async deleteRunner() { - this.ctx.facets.delete(jobFacetName) + try { + this.ctx.facets.delete(jobFacetName) + } catch { + // Local workers-unit facets can throw an internal cleanup error even when + // the supervisor state is about to be deleted successfully. + } await this.ctx.storage.deleteAll() return { ok: true as const } } @@ -406,14 +410,6 @@ class JobRunnerBase extends DurableObject { return job } - private async getScheduleSummary() { - const config = await this.readConfig(this.ctx.id.toString()) - return formatJobScheduleSummary({ - schedule: config.schedule, - timezone: config.timezone, - }) - } - private async readConfig(jobId: string) { const config = await this.ctx.storage.get(configStorageKey) return config ?? defaultConfig(jobId) @@ -594,6 +590,7 @@ export function jobRunnerRpc(env: Env, jobId: string) { killSwitchEnabled?: boolean recomputeNextRunAt?: boolean }) => Promise + describeJob: (payload: { job: JobRecord }) => Promise getDetails: () => Promise getHistory: (limit?: number) => Promise> setEnabled: (payload: { enabled: boolean }) => Promise diff --git a/packages/worker/src/mcp/capabilities/jobs/domain.ts b/packages/worker/src/mcp/capabilities/jobs/domain.ts index 577c9b033d..b9587ef8d6 100644 --- a/packages/worker/src/mcp/capabilities/jobs/domain.ts +++ b/packages/worker/src/mcp/capabilities/jobs/domain.ts @@ -9,6 +9,7 @@ import { listJobRecords, removeJobRecord, requireJobRecord, + restoreJobRecord, updateJobRecord, } from '#worker/jobs/service.ts' import { @@ -45,6 +46,33 @@ const jobStorageResetOutputSchema = z.object({ job_id: z.string(), }) +async function rollbackCreatedJob(input: { + ctx: CapabilityContext + userId: string + jobId: string +}) { + await removeJobRecord(input.ctx.env.APP_DB, input.userId, input.jobId) + await Promise.allSettled([ + jobRunnerRpc(input.ctx.env, input.jobId).deleteRunner(), + ]) +} + +async function rollbackUpdatedJob(input: { + ctx: CapabilityContext + previous: Awaited> +}) { + await restoreJobRecord(input.ctx.env.APP_DB, input.previous) + await Promise.allSettled([ + syncJobRunnerFromDb({ + env: input.ctx.env, + userId: input.previous.userId, + jobId: input.previous.id, + baseUrl: input.ctx.callerContext.baseUrl, + recomputeNextRunAt: true, + }), + ]) +} + const jobCreateCapability = defineDomainCapability(capabilityDomainNames.jobs, { name: 'job_create', description: @@ -62,13 +90,23 @@ const jobCreateCapability = defineDomainCapability(capabilityDomainNames.jobs, { userId: user.userId, data: args, }) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: job.id, - baseUrl: ctx.callerContext.baseUrl, - recomputeNextRunAt: true, - }) + let details + try { + details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: job.id, + baseUrl: ctx.callerContext.baseUrl, + recomputeNextRunAt: true, + }) + } catch (error) { + await rollbackCreatedJob({ + ctx, + userId: user.userId, + jobId: job.id, + }) + throw error + } if (!details) { throw new Error('Unable to configure the created job runner.') } @@ -102,15 +140,26 @@ const jobUpdateCapability = defineDomainCapability(capabilityDomainNames.jobs, { historyLimit: args.patch.history_limit, }, }) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: args.job_id, - baseUrl: ctx.callerContext.baseUrl, - historyLimit: args.patch.history_limit, - killSwitchEnabled: args.patch.kill_switch_enabled, - recomputeNextRunAt: updateResult.scheduleChanged, - }) + let details + try { + details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + historyLimit: args.patch.history_limit, + killSwitchEnabled: args.patch.kill_switch_enabled, + recomputeNextRunAt: + updateResult.scheduleChanged || + args.patch.kill_switch_enabled === false, + }) + } catch (error) { + await rollbackUpdatedJob({ + ctx, + previous: updateResult.before, + }) + throw error + } if (!details) { throw new Error('Unable to synchronize the updated job runner.') } @@ -157,11 +206,8 @@ const jobListCapability = defineDomainCapability(capabilityDomainNames.jobs, { const jobs = await listJobRecords(ctx.env.APP_DB, user.userId) const details = await Promise.all( jobs.map((job) => - syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: job.id, - baseUrl: ctx.callerContext.baseUrl, + jobRunnerRpc(ctx.env, job.id).describeJob({ + job, }), ), ) @@ -181,16 +227,10 @@ const jobGetCapability = defineDomainCapability(capabilityDomainNames.jobs, { outputSchema: jobDetailsSchema, async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: args.job_id, - baseUrl: ctx.callerContext.baseUrl, + const job = await requireJobRecord(ctx.env.APP_DB, user.userId, args.job_id) + return await jobRunnerRpc(ctx.env, args.job_id).describeJob({ + job, }) - if (!details) { - throw new Error(`Job "${args.job_id}" was not found.`) - } - return details }, }) @@ -235,19 +275,28 @@ const jobEnableCapability = defineDomainCapability(capabilityDomainNames.jobs, { outputSchema: jobDetailsSchema, async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) - await updateJobRecord({ + const updateResult = await updateJobRecord({ db: ctx.env.APP_DB, userId: user.userId, jobId: args.job_id, patch: { enabled: true }, }) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: args.job_id, - baseUrl: ctx.callerContext.baseUrl, - recomputeNextRunAt: true, - }) + let details + try { + details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + recomputeNextRunAt: true, + }) + } catch (error) { + await rollbackUpdatedJob({ + ctx, + previous: updateResult.before, + }) + throw error + } if (!details) { throw new Error(`Job "${args.job_id}" was not found.`) } @@ -269,18 +318,27 @@ const jobDisableCapability = defineDomainCapability( outputSchema: jobDetailsSchema, async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) - await updateJobRecord({ + const updateResult = await updateJobRecord({ db: ctx.env.APP_DB, userId: user.userId, jobId: args.job_id, patch: { enabled: false }, }) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: args.job_id, - baseUrl: ctx.callerContext.baseUrl, - }) + let details + try { + details = await syncJobRunnerFromDb({ + env: ctx.env, + userId: user.userId, + jobId: args.job_id, + baseUrl: ctx.callerContext.baseUrl, + }) + } catch (error) { + await rollbackUpdatedJob({ + ctx, + previous: updateResult.before, + }) + throw error + } if (!details) { throw new Error(`Job "${args.job_id}" was not found.`) } @@ -303,15 +361,7 @@ const jobHistoryCapability = defineDomainCapability( outputSchema: z.array(jobHistoryEntrySchema), async handler(args, ctx: CapabilityContext) { const user = requireJobsUser(ctx) - const details = await syncJobRunnerFromDb({ - env: ctx.env, - userId: user.userId, - jobId: args.job_id, - baseUrl: ctx.callerContext.baseUrl, - }) - if (!details) { - throw new Error(`Job "${args.job_id}" was not found.`) - } + await requireJobRecord(ctx.env.APP_DB, user.userId, args.job_id) return await jobRunnerRpc(ctx.env, args.job_id).getHistory(args.limit) }, }, diff --git a/packages/worker/src/mcp/facet-runtime.ts b/packages/worker/src/mcp/facet-runtime.ts index 2187e0ba0d..4992af15ac 100644 --- a/packages/worker/src/mcp/facet-runtime.ts +++ b/packages/worker/src/mcp/facet-runtime.ts @@ -69,14 +69,6 @@ function normalizeBridgeScope( throw new Error(`Unsupported bridge scope: ${String(resolvedScope)}`) } -export function normalizeFacetName( - rawFacetName: string | null | undefined, - fallback = 'main', -) { - const facetName = rawFacetName?.trim() || fallback - return facetName -} - export function buildFacetClassExportName( baseClassName: string, facetName: string, @@ -123,7 +115,6 @@ function createFacetWrapperModule(input: { baseClassName: string expectedExportDescription: string facetName: string - serverCode: string }) { const exportName = buildFacetClassExportName( input.baseClassName, @@ -154,14 +145,6 @@ export class ${exportName} extends BaseFacet { estimatedBytes: this.ctx.storage.sql.databaseSize, } } - - async __kody_exec(code, params) { - if (typeof code !== 'string' || !code.trim()) { - throw new Error('Facet exec requires non-empty code.') - } - const runner = new Function('facet', 'params', code) - return await runner(this, params ?? {}) - } } `.trim() } @@ -180,7 +163,6 @@ export async function createFacetStartup(input: CreateFacetStartupInput) { baseClassName: input.baseClassName, expectedExportDescription: input.expectedExportDescription, facetName: input.facetName, - serverCode: input.serverCode, }), [defaultFacetModuleName]: input.serverCode, }, From 2e908eae6611cce574fda7e36731e333c559622d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 05:31:00 +0000 Subject: [PATCH 14/15] finish jobs consistency fixes --- .../jobs/jobs-capabilities.workers.test.ts | 28 +++++++++++-------- packages/worker/src/jobs/service.ts | 11 ++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts index af346d49b8..fc76fd8123 100644 --- a/packages/worker/src/jobs/jobs-capabilities.workers.test.ts +++ b/packages/worker/src/jobs/jobs-capabilities.workers.test.ts @@ -171,17 +171,23 @@ test('job capabilities create, update, run, and delete cron jobs end-to-end', as }, }) - await expect( - jobServerExec( - { - job_id: created.id, - code: ` - return await job.call('readState') - `, - }, - ctx, - ), - ).rejects.toThrow('#') + const rpcExec = await jobServerExec( + { + job_id: created.id, + code: ` + return await job.call('readState') + `, + }, + ctx, + ) + expect(rpcExec).toEqual({ + ok: true, + job_id: created.id, + result: { + count: 2, + flavor: 'beta', + }, + }) const deleted = await jobDelete({ job_id: created.id }, ctx) expect(deleted).toEqual({ diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index b547a7c8fd..52c7dc2646 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -155,3 +155,14 @@ export async function removeJobRecord( export async function listJobRecords(db: D1Database, userId: string) { return await listJobsByUserId(db, userId) } + +export async function restoreJobRecord(db: D1Database, record: JobRecord) { + await updateJob(db, record.userId, record.id, { + name: record.name, + serverCode: record.serverCode, + serverCodeId: record.serverCodeId, + schedule: record.schedule, + timezone: record.timezone, + enabled: record.enabled, + }) +} From f8b012f39ad25482b6d814f9d83422092c0823cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 05:55:51 +0000 Subject: [PATCH 15/15] fix jobs runner review issues --- vitest.mcp-e2e.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vitest.mcp-e2e.config.ts b/vitest.mcp-e2e.config.ts index 0ffb244265..cd0c0f06ed 100644 --- a/vitest.mcp-e2e.config.ts +++ b/vitest.mcp-e2e.config.ts @@ -1,7 +1,9 @@ import { defineProject, mergeConfig } from 'vitest/config' import { sharedProjectConfig } from './vitest-shared.ts' -const mcpE2eTimeout = process.env.CI ? 30_000 : 10_000 +// Local `wrangler dev` + `d1 migrations apply` cold-start often exceeds 10s; keep +// local timeouts aligned with CI so the suite measures MCP behavior, not harness speed. +const mcpE2eTimeout = 30_000 export default mergeConfig( sharedProjectConfig,