Skip to content

Unify jobs around codemode runtime - #171

Merged
kentcdodds merged 12 commits into
mainfrom
cursor/unify-job-runtime-f161
Apr 15, 2026
Merged

kentcdodds merged 12 commits into
mainfrom
cursor/unify-job-runtime-f161

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 14, 2026 •

Copy link
Copy Markdown
Member

Summary

  • replace job-specific facet backends with stable storageIds and a shared StorageRunner
  • extend execute with storageId/writable plus built-in storage.* and storage.sql(...)
  • add dedicated storage_query and storage_export capabilities for read-only inspection by default
  • add a hard-cut jobs migration to storage_id and remove the old JobRunner path from the worker surface
  • update focused tests and docs so job/storage behavior is described only in its current form
  • address the remaining valid review and CI follow-ups on top of the rebased branch

Testing

  • npm run test -- --run packages/worker/src/mcp/run-codemode-registry.node.test.ts packages/worker/src/mcp/tools/execute.node.test.ts packages/worker/src/jobs/service.node.test.ts packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts packages/worker/src/storage-runner.workers.test.ts
  • npm run test:mcp -- packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
  • npm run typecheck
  • npm run build
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Jobs now always run via codemode and have a stable durable storageId; job code can access a storage API (get/set/list/sql) with writable opt-in.
    • New storage inspection capabilities: export storage as JSON and run SQL queries against a job storage bucket.
  • Changes

    • Job shape unified (code required on create) and persistence consolidated with expanded run metadata; jobs receive deterministic storageId.
  • Documentation

    • Docs updated to reflect the storage-centric job model and execution flow.
  • Tests

    • Added migration and durable-storage integration tests; updated job execution tests to new shapes.

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@cursor[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 49 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 6 minutes and 49 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9112055-9b5c-434b-8c6d-dac89e44f730

📥 Commits

Reviewing files that changed from the base of the PR and between ed8dfda and fd1af51.

📒 Files selected for processing (7)
  • packages/worker/src/jobs/process-due-jobs.node.test.ts
  • packages/worker/src/jobs/service.node.test.ts
  • packages/worker/src/jobs/service.ts
  • packages/worker/src/jobs/types.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/storage-runner.ts
  • packages/worker/src/storage-runner.workers.test.ts
📝 Walkthrough

Walkthrough

Jobs now run solely via codemode and use a durable per-job storage bucket identified by a stable storageId. The per-job facet/JobRunner was removed and replaced by a StorageRunner durable object exposing storage RPC and SQL inspection; codemode tooling was extended to inject storage helpers and enforce writable flags.

Changes

Cohort / File(s) Summary
Docs
docs/contributing/architecture/data-storage.md, docs/use/execute.md
Replaced JobRunner/facet documentation with StorageRunner/storage-centric wording; removed facet execution kind; documented storageId and storage APIs (storage.get/set/list/sql) plus storage_export/storage_query.
Migrations
packages/worker/migrations/0020-jobs-codemode-only.sql, packages/worker/migrations/0021-jobs-storage-id.sql
New migrations convert jobs to codemode-only, synthesize/migrate code, add persistent storage_id (job:<id>), and recreate jobs table/indexes with run-tracking fields.
Storage Runner
packages/worker/src/storage-runner.ts, packages/worker/src/storage-runner.workers.test.ts
Added StorageRunner durable object, RPC surface (getValue,setValue,listValues,exportStorage,sqlQuery), Codemode storage tool generation, helper prelude generation, and integration tests including SQL read/write and read-only enforcement.
Removed Job Runner
packages/worker/src/jobs/job-runner.ts, packages/worker/src/jobs/job-runner.workers.test.ts
Deleted JobRunner durable object, its RPC surface, facet orchestration code, and associated tests.
Job model & repo
packages/worker/src/jobs/types.ts, packages/worker/src/jobs/repo.ts
Removed kind, serverCode*, and methodName fields; made code required; added storageId field and updated serialization/mapping and SQL statements to persist storage_id.
Service & execution
packages/worker/src/jobs/service.ts, packages/worker/src/jobs/service.node.test.ts, packages/worker/src/jobs/process-due-jobs.node.test.ts
Removed runner sync/management and facet path; executeJobOnce always calls runCodemodeWithRegistry with storageContext/storageId; updated tests to reflect storage binding and mocked storage-runner behavior.
Codemode plumbing
packages/worker/src/mcp/run-codemode-registry.ts, packages/worker/src/mcp/tools/execute.ts, packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts, packages/worker/src/mcp/generated-ui-api.ts
runCodemodeWithRegistry now accepts an options object (executorExports, additionalTools, helperPrelude, storageTools); added additionalTools collision checks; introduced storageTools and helper prelude injection; updated callers to pass options and storageContext.
MCP / Storage capabilities
packages/worker/src/mcp/capabilities/storage/*, packages/worker/src/mcp/capabilities/storage/shared.ts
Added storage domain and capabilities storage_export and storage_query with Zod schemas, handlers, and requireStorageUser helper for inspecting buckets and running SQL (read-only by default).
Schemas & observability
packages/shared/src/chat.ts, packages/worker/src/mcp/capabilities/jobs/shared.ts, packages/worker/src/mcp/observability.ts, packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
Added storageId to storage/caller schemas and job view/upsert schemas; removed jobKindSchema and kind-based validations; observability now includes storageContext.
Env & bindings
packages/worker/src/env-schema.ts, packages/worker/worker-configuration.d.ts, packages/worker/wrangler.jsonc, packages/worker/src/index.ts, packages/worker/src/mcp/capabilities/builtin-domains.ts, packages/worker/src/mcp/capabilities/domain-metadata.ts
Renamed binding and types from JOB_RUNNER/JobRunner to STORAGE_RUNNER/StorageRunner across env schema, d.ts, wrangler, and exports; added storage domain to builtin domains and capability metadata.
Tests & migration test
packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts, various updated tests (execute.node.test.ts, oauth-handlers.workers.test.ts, service/process tests)
Added migration tests validating codemode-only transform and storage_id; updated tests to remove kind and assert storageId propagation; adjusted E2E timing and mocks.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Service as Job Service
    participant RunCM as runCodemodeWithRegistry
    participant StorageDO as StorageRunner DO
    participant Executor as Codemode Executor

    User->>Service: request run (jobId, params)
    Service->>Service: resolve job (code, storageId)
    Service->>RunCM: runCodemodeWithRegistry(code, params, options { executorExports, storageTools? })
    RunCM->>Executor: initialize executor with merged tools & helperPrelude
    alt storageTools present
        Executor->>StorageDO: storage RPC (get/set/list/sql) via storageTools
        StorageDO-->>Executor: storage/sql result
    end
    Executor-->>RunCM: execution result
    RunCM-->>Service: outcome
    Service-->>User: final response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nudge the codemode path so bright,
storage burrows hold each job tight,
No more kind-split, one gentle way,
storageId hums as I hop and play,
carrots for tests — a cozy byte. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Unify jobs around codemode runtime' directly reflects the main objective: consolidating job execution to use a shared codemode runtime with StorageRunner instead of per-job facet backends.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/unify-job-runtime-f161

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds
kentcdodds marked this pull request as ready for review April 14, 2026 20:07
@github-actions

github-actions Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-171.kentcdodds.workers.dev

Worker: kody-pr-171
D1: kody-pr-171-db
KV: kody-pr-171-oauth-kv

Mocks:

Comment thread packages/worker/migrations/0020-jobs-codemode-only.sql
Comment thread packages/worker/migrations/0020-jobs-codemode-only.sql

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/src/jobs/service.ts (1)

499-505: ⚠️ Potential issue | 🟠 Major

Let executeJobOnce() own the missing-context failure path.

executeJobOnce() already converts null caller context into a structured execution error, but these new pre-sync calls happen first. For facet jobs whose caller_context_json is missing or unparseable, runJobNow() / runDueJobsForUser() now throw before that failure can be recorded.

💡 Guard the syncs and keep the existing structured failure behavior
-	if (hasFacetState(row.record)) {
+	if (hasFacetState(row.record) && activeCallerContext !== null) {
 		await syncRunnerForJob({
 			env: input.env,
 			job: row.record,
 			callerContext: activeCallerContext,
 		})
 	}
-			if (hasFacetState(job)) {
+			if (hasFacetState(job) && callerContext !== null) {
 				await syncRunnerForJob({
 					env: input.env,
 					job,
 					callerContext,
 				})
 			}

Also applies to: 562-568

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/service.ts` around lines 499 - 505, The pre-sync
call to syncRunnerForJob should be guarded so missing/unparseable caller context
doesn't cause runJobNow()/runDueJobsForUser to throw before executeJobOnce can
record the structured execution error; update the calls that use
hasFacetState(row.record) and syncRunnerForJob(...) to first verify the
activeCallerContext (the parsed caller context used elsewhere) is non-null/valid
and only then invoke syncRunnerForJob, otherwise skip the pre-sync and allow
executeJobOnce (the existing job execution path) to handle and surface the
execution error; apply the same guard to the other similar block that triggers
syncRunnerForJob in runDueJobsForUser/runJobNow.
🧹 Nitpick comments (1)
packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts (1)

10-143: Close the in-memory SQLite handle explicitly in this test.

DatabaseSync is created at Line 10 but never closed. Wrapping the body in try/finally with db.close() will prevent potential open-handle leaks in test runs.

♻️ Suggested update
 test('0020 jobs migration rewrites legacy rows into the final codemode-first shape', async () => {
 	const db = new DatabaseSync(':memory:')
-	db.exec(await readMigration('0018-jobs.sql'))
-	db.exec(await readMigration('0019-jobs-constraints.sql'))
+	try {
+		db.exec(await readMigration('0018-jobs.sql'))
+		db.exec(await readMigration('0019-jobs-constraints.sql'))
 
-	const insertJob = db.prepare(`
+		const insertJob = db.prepare(`
 		...
-	`)
+		`)
 
-	insertJob.run(...)
-	insertJob.run(...)
+		insertJob.run(...)
+		insertJob.run(...)
 
-	db.exec(await readMigration('0020-jobs-codemode-only.sql'))
+		db.exec(await readMigration('0020-jobs-codemode-only.sql'))
 
-	const columns = db
+		const columns = db
 		...
-	expect(rows).toEqual([...])
+		expect(rows).toEqual([...])
+	} finally {
+		db.close()
+	}
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts` around
lines 10 - 143, The test creates a DatabaseSync instance named db but never
closes it; wrap the test body that runs migrations, inserts, and assertions in a
try/finally (or use a finally equivalent) and call db.close() in the finally
block to ensure DatabaseSync is always closed; target the block around the
existing use of DatabaseSync (the insertJob.prepare/run,
db.exec(readMigration(...)), and the final expects) so db.close() executes
regardless of test failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/migrations/0020-jobs-codemode-only.sql`:
- Around line 60-66: The CASE that builds code uses
REPLACE(COALESCE(NULLIF(TRIM(method_name), ''), 'run'), '''', '''''') to escape
single quotes but still allows literal newlines to break the JavaScript string;
replace this manual escaping with a JSON-safe quoted string by passing the
method_name through json_quote() (or
json_quote(COALESCE(NULLIF(TRIM(method_name), ''), 'run'))) when concatenating
the generated 'async (params) => await job.call(...)' expression so quotes and
newlines are properly escaped; update the branch that builds code from
server_code/method_name (the WHEN server_code ... THEN 'async (params) => await
job.call(' ... ) to use json_quote for method_name and remove the custom
REPLACE-based escaping.

In `@packages/worker/src/mcp/capabilities/jobs/shared.ts`:
- Around line 98-103: The schema in shared.ts currently allows null/absent
shapes that should be rejected up-front: remove .nullable() from the
codemode/code fields (the z.string().min(1).nullable().optional() entries) so
update requests with code: null fail validation, and add a schema-level
refinement (superRefine) to the upsert/create schema to reject create payloads
that have methodName present but no serverCode; update or add this validation
where the shared upsert schema is composed (refer to the schema definitions in
packages/worker/src/mcp/capabilities/jobs/shared.ts and the upsert handling in
packages/worker/src/mcp/capabilities/jobs/job-upsert.ts) so
resolveCreateShape()/resolveUpdatedShape() in
packages/worker/src/jobs/service.ts no longer see these as runtime errors.

---

Outside diff comments:
In `@packages/worker/src/jobs/service.ts`:
- Around line 499-505: The pre-sync call to syncRunnerForJob should be guarded
so missing/unparseable caller context doesn't cause
runJobNow()/runDueJobsForUser to throw before executeJobOnce can record the
structured execution error; update the calls that use hasFacetState(row.record)
and syncRunnerForJob(...) to first verify the activeCallerContext (the parsed
caller context used elsewhere) is non-null/valid and only then invoke
syncRunnerForJob, otherwise skip the pre-sync and allow executeJobOnce (the
existing job execution path) to handle and surface the execution error; apply
the same guard to the other similar block that triggers syncRunnerForJob in
runDueJobsForUser/runJobNow.

---

Nitpick comments:
In `@packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts`:
- Around line 10-143: The test creates a DatabaseSync instance named db but
never closes it; wrap the test body that runs migrations, inserts, and
assertions in a try/finally (or use a finally equivalent) and call db.close() in
the finally block to ensure DatabaseSync is always closed; target the block
around the existing use of DatabaseSync (the insertJob.prepare/run,
db.exec(readMigration(...)), and the final expects) so db.close() executes
regardless of test failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ac264a4e-610b-48bb-aac8-7955beb95404

📥 Commits

Reviewing files that changed from the base of the PR and between 41265d8 and 2e055fa.

📒 Files selected for processing (18)
  • docs/contributing/architecture/data-storage.md
  • docs/use/execute.md
  • packages/worker/migrations/0020-jobs-codemode-only.sql
  • packages/worker/src/jobs/job-runner.ts
  • packages/worker/src/jobs/job-runner.workers.test.ts
  • packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts
  • packages/worker/src/jobs/process-due-jobs.node.test.ts
  • packages/worker/src/jobs/repo.ts
  • packages/worker/src/jobs/service.node.test.ts
  • packages/worker/src/jobs/service.ts
  • packages/worker/src/jobs/types.ts
  • packages/worker/src/mcp/capabilities/jobs/domain.ts
  • packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
  • packages/worker/src/mcp/capabilities/jobs/shared.ts
  • packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/tools/execute.ts
💤 Files with no reviewable changes (2)
  • packages/worker/src/jobs/process-due-jobs.node.test.ts
  • packages/worker/src/jobs/job-runner.ts

Comment on lines +60 to +66
CASE
WHEN code IS NOT NULL AND TRIM(code) != '' THEN code
WHEN server_code IS NOT NULL AND TRIM(server_code) != '' THEN
'async (params) => await job.call(''' ||
REPLACE(COALESCE(NULLIF(TRIM(method_name), ''), 'run'), '''', '''''') ||
''', params)'
END AS code,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

pip install --quiet esprima

python - <<'PY'
import sqlite3
import esprima

conn = sqlite3.connect(":memory:")
expr = """
SELECT
  'async (params) => await job.call(''' ||
  REPLACE(?, '''', '''''') ||
  ''', params)'
"""

for method_name in ["run", "it's", "line\nbreak"]:
    (generated_code,) = conn.execute(expr, (method_name,)).fetchone()
    print(f"\nmethod_name={method_name!r}")
    print(f"generated_code={generated_code!r}")
    try:
        esprima.parseScript(f"({generated_code})")
        print("parse=ok")
    except Exception as exc:
        print(f"parse=FAIL -> {exc}")
PY

Repository: kentcdodds/kody

Length of output: 654


🏁 Script executed:

fd -type f -name "0020-jobs-codemode-only.sql"

Repository: kentcdodds/kody

Length of output: 229


🏁 Script executed:

cat -n packages/worker/migrations/0020-jobs-codemode-only.sql | sed -n '55,70p'

Repository: kentcdodds/kody

Length of output: 592


🏁 Script executed:

# Check for SQLite version or other migration setup files
fd -type f -name "*.sql" packages/worker/migrations/ | head -20

Repository: kentcdodds/kody

Length of output: 229


🏁 Script executed:

# Check if json_quote is used elsewhere in the codebase
rg "json_quote" --type sql

Repository: kentcdodds/kody

Length of output: 41


🏁 Script executed:

pip install --quiet esprima

python - <<'PY'
import sqlite3
import esprima

conn = sqlite3.connect(":memory:")
# Test with the ACTUAL SQL from the migration file using CHAR(92)
expr = """
SELECT
  'async (params) => await job.call(''' ||
  REPLACE(?, '''', CHAR(92) || '''') ||
  ''', params)'
"""

for method_name in ["run", "it's", "line\nbreak"]:
    (generated_code,) = conn.execute(expr, (method_name,)).fetchone()
    print(f"\nmethod_name={method_name!r}")
    print(f"generated_code={generated_code!r}")
    try:
        esprima.parseScript(f"({generated_code})")
        print("parse=ok")
    except Exception as exc:
        print(f"parse=FAIL -> {exc}")
PY

Repository: kentcdodds/kody

Length of output: 395


🏁 Script executed:

# Check the broader context of the migration to understand schema constraints
cat -n packages/worker/migrations/0020-jobs-codemode-only.sql | head -55

Repository: kentcdodds/kody

Length of output: 1622


🏁 Script executed:

# Test if json_quote() would properly escape for JavaScript
pip install --quiet esprima

python - <<'PY'
import sqlite3
import esprima

conn = sqlite3.connect(":memory:")
# Test with json_quote approach suggested in review
expr = """
SELECT
  'async (params) => await job.call(' ||
  json_quote(?) ||
  ', params)'
"""

for method_name in ["run", "it's", "line\nbreak"]:
    try:
        (generated_code,) = conn.execute(expr, (method_name,)).fetchone()
        print(f"\nmethod_name={method_name!r}")
        print(f"generated_code={generated_code!r}")
        try:
            esprima.parseScript(f"({generated_code})")
            print("parse=ok")
        except Exception as exc:
            print(f"parse=FAIL -> {exc}")
    except Exception as e:
        print(f"Error with method_name={method_name!r}: {e}")
PY

Repository: kentcdodds/kody

Length of output: 357


The migration partially escapes method_name for JavaScript, but literal newlines will still break.

The code uses CHAR(92) (backslash) to escape single quotes, which fixes the it's case. However, if method_name contains a literal newline, the generated code will have an unescaped newline inside the string literal, causing a parse error. Use json_quote() instead to properly escape both quotes and newlines for JavaScript:

Recommended fix
 	CASE
 		WHEN code IS NOT NULL AND TRIM(code) != '' THEN code
 		WHEN server_code IS NOT NULL AND TRIM(server_code) != '' THEN
-			'async (params) => await job.call(''' ||
-			REPLACE(COALESCE(NULLIF(TRIM(method_name), ''), 'run'), '''', CHAR(92) || '''') ||
-			''', params)'
+			'async (params) => await job.call(' ||
+			json_quote(COALESCE(NULLIF(TRIM(method_name), ''), 'run')) ||
+			', params)'
 	END AS code,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/migrations/0020-jobs-codemode-only.sql` around lines 60 - 66,
The CASE that builds code uses REPLACE(COALESCE(NULLIF(TRIM(method_name), ''),
'run'), '''', '''''') to escape single quotes but still allows literal newlines
to break the JavaScript string; replace this manual escaping with a JSON-safe
quoted string by passing the method_name through json_quote() (or
json_quote(COALESCE(NULLIF(TRIM(method_name), ''), 'run'))) when concatenating
the generated 'async (params) => await job.call(...)' expression so quotes and
newlines are properly escaped; update the branch that builds code from
server_code/method_name (the WHEN server_code ... THEN 'async (params) => await
job.call(' ... ) to use json_quote for method_name and remove the custom
REPLACE-based escaping.

Comment thread packages/worker/src/mcp/capabilities/jobs/shared.ts

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Migration escaping misses backslashes in method names
    • Escaped backslashes before single quotes in the generated JS string so method names with backslashes are preserved safely.

You can send follow-ups to the cloud agent here.

Comment thread packages/worker/migrations/0020-jobs-codemode-only.sql

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/worker/migrations/0020-jobs-codemode-only.sql (1)

60-65: ⚠️ Potential issue | 🟠 Major

Use json_quote for method_name serialization in generated JS wrapper.

Line 63-Line 65 still builds a single-quoted JS string via manual REPLACE(...), which does not escape literal newlines. A newline in method_name will produce invalid JavaScript after migration.

Proposed fix
 	CASE
 		WHEN code IS NOT NULL AND TRIM(code) != '' THEN code
 		WHEN server_code IS NOT NULL AND TRIM(server_code) != '' THEN
-			'async (params) => await job.call(''' ||
-			REPLACE(COALESCE(NULLIF(TRIM(method_name), ''), 'run'), '''', CHAR(92) || '''') ||
-			''', params)'
+			'async (params) => await job.call(' ||
+			json_quote(COALESCE(NULLIF(TRIM(method_name), ''), 'run')) ||
+			', params)'
 		ELSE 'async () => { throw new Error("migration: missing code") }'
 	END AS code,
#!/bin/bash
set -euo pipefail

python - <<'PY'
import sqlite3

conn = sqlite3.connect(":memory:")
expr = """
SELECT
  'async (params) => await job.call(''' ||
  REPLACE(COALESCE(NULLIF(TRIM(?), ''), 'run'), '''', CHAR(92) || '''') ||
  ''', params)'
"""

for method_name in ["run", "it's", "line\nbreak"]:
    (generated,) = conn.execute(expr, (method_name,)).fetchone()
    print(f"method_name={method_name!r}")
    print(f"generated={generated!r}")
    print("contains_literal_newline=", "\\n" in generated)
    print("-" * 40)
PY
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/migrations/0020-jobs-codemode-only.sql` around lines 60 - 65,
The CASE expression that builds the JS wrapper currently uses REPLACE(...) to
escape single quotes for method_name (in the branch that returns 'async (params)
=> await job.call(...)'), which fails to escape literal newlines and other
characters; replace the manual REPLACE-based serialization with sqlite's
json_quote on the COALESCE(NULLIF(TRIM(method_name), ''), 'run') value so the
generated JS string is safely escaped (keep the surrounding concatenation that
builds 'async (params) => await job.call(' || json_quote(...) || ', params)').
Ensure the symbol references are the CASE branch that constructs the JS wrapper
and the TRIM(method_name)/COALESCE expression.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/worker/migrations/0020-jobs-codemode-only.sql`:
- Around line 60-65: The CASE expression that builds the JS wrapper currently
uses REPLACE(...) to escape single quotes for method_name (in the branch that
returns 'async (params) => await job.call(...)'), which fails to escape literal
newlines and other characters; replace the manual REPLACE-based serialization
with sqlite's json_quote on the COALESCE(NULLIF(TRIM(method_name), ''), 'run')
value so the generated JS string is safely escaped (keep the surrounding
concatenation that builds 'async (params) => await job.call(' || json_quote(...)
|| ', params)'). Ensure the symbol references are the CASE branch that
constructs the JS wrapper and the TRIM(method_name)/COALESCE expression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e0ee097-ae84-4add-a92d-bf1a413d93a4

📥 Commits

Reviewing files that changed from the base of the PR and between 2e055fa and fde741f.

📒 Files selected for processing (1)
  • packages/worker/migrations/0020-jobs-codemode-only.sql

Comment thread packages/worker/migrations/0020-jobs-codemode-only.sql
Comment thread packages/worker/src/storage-runner.ts
Comment thread packages/worker/src/jobs/types.ts Outdated
userId: callerContext.user?.userId ?? '',
storageId: storageTools.storageId,
writable: storageTools.writable,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storageTools.userId silently ignored in buildCodemodeFns

Medium Severity

buildCodemodeFns accepts storageTools with a userId field via StorageToolOptions, but ignores it — instead using callerContext.user?.userId ?? '' when creating storage codemode tools. If a caller ever passes a storageTools.userId that differs from the caller context user (e.g., for cross-user storage inspection), the wrong Durable Object would be addressed. The userId field on StorageToolOptions is effectively dead, creating a misleading API contract.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 752fec3. Configure here.

cursoragent and others added 7 commits April 14, 2026 22:30
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/src/jobs/types.ts (1)

101-120: ⚠️ Potential issue | 🟠 Major

Don't let update/upsert clear a required code field.

Line 104 and Line 115 still allow code: null, but JobRecord.code and JobCreateInput.code are now required. That leaves the public contract able to express a persisted state the codemode-only runtime no longer supports.

🔧 Proposed fix
 export type JobUpdateInput = {
 	id: string
 	name?: string
-	code?: string | null
+	code?: string
 	params?: Record<string, unknown> | null
 	schedule?: JobSchedule
 	timezone?: string | null
 	enabled?: boolean
 	killSwitchEnabled?: boolean
 }
 
 export type JobUpsertInput = {
 	id?: string
 	name?: string
-	code?: string | null
+	code?: string
 	params?: Record<string, unknown> | null
 	schedule?: JobSchedule
 	timezone?: string | null
 	enabled?: boolean
 	killSwitchEnabled?: boolean
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/types.ts` around lines 101 - 120, JobUpdateInput and
JobUpsertInput currently allow code?: string | null which can clear a required
code field; update these types (JobUpdateInput and JobUpsertInput) to forbid
null for code (make it either code?: string or code: string when appropriate) so
callers cannot set code to null and violate the runtime expectation expressed by
JobRecord.code and JobCreateInput.code; ensure the change preserves optionality
for omitted fields but disallows explicit null, and update any usage sites or
validators that rely on null semantics to use absence instead.
🧹 Nitpick comments (4)
packages/worker/src/mcp/storage-bindings.ts (1)

24-29: Normalize returned binding keys after trim checks.

The code validates trimmed identifiers but returns raw strings. Returning canonical (trimmed) keys avoids whitespace-driven bucket key drift.

♻️ Proposed refactor
 if (scope === 'app') {
-	return storageContext?.appId?.trim()
-		? storageContext.appId
-		: storageContext?.storageId?.trim()
-			? storageContext.storageId
-			: null
+	const appId = storageContext?.appId?.trim()
+	if (appId) return appId
+	const storageId = storageContext?.storageId?.trim()
+	return storageId || null
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/storage-bindings.ts` around lines 24 - 29, The block
in storage-bindings.ts that checks scope === 'app' validates trimmed identifiers
but returns raw values; change the returns to the canonical trimmed versions so
whitespace is removed from binding keys: when returning storageContext.appId or
storageContext.storageId use their .trim() results (guarding for null/undefined)
instead of the original strings; update the conditional expressions around
storageContext?.appId and storageContext?.storageId to both check and return the
trimmed value to prevent bucket key drift.
packages/worker/src/storage-runner.workers.test.ts (1)

131-151: Consider using expect().rejects for cleaner error assertion.

The test correctly verifies that mutating SQL is blocked when writable: false, but could be simplified using Vitest's built-in rejection matcher.

♻️ Suggested refactor
 test('storage runner blocks mutating SQL when writable is false', async () => {
 	const storageId = createExecuteStorageId()
 	const runner = storageRunnerRpc({
 		env,
 		userId: 'user-123',
 		storageId,
 	})

-	try {
-		await runner.sqlQuery({
-			query: 'delete from counters',
-			writable: false,
-		})
-		throw new Error('Expected read-only SQL mutation to fail.')
-	} catch (error) {
-		expect(error).toBeInstanceOf(Error)
-		expect((error as Error).message).toBe(
-			'Read-only storage.sql only allows SELECT, EXPLAIN, and schema PRAGMA queries. Pass writable: true to allow mutations.',
-		)
-	}
+	await expect(
+		runner.sqlQuery({
+			query: 'delete from counters',
+			writable: false,
+		}),
+	).rejects.toThrow(
+		'Read-only storage.sql only allows SELECT, EXPLAIN, and schema PRAGMA queries. Pass writable: true to allow mutations.',
+	)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.workers.test.ts` around lines 131 - 151,
The test "storage runner blocks mutating SQL when writable is false" uses
try/catch for error assertions; replace it with Vitest's promise rejection
matcher by calling storageRunnerRpc(...).sqlQuery(...) and asserting with
expect(promise).rejects.toThrow(...) (or .rejects.toEqual(...) if you want exact
message) to assert the error message, targeting the runner.sqlQuery call
returned promise from storageRunnerRpc to make the test concise and idiomatic.
packages/worker/src/mcp/capabilities/storage/storage-query.ts (1)

5-7: Consider using requireStorageUser for consistency with storage-export.ts.

This file imports requireMcpUser directly from #mcp/capabilities/meta/require-user.ts and calls it as requireMcpUser(ctx.callerContext) on line 46, whereas storage-export.ts uses the shared helper requireStorageUser(ctx) from ./shared.ts. Both do the same thing, but using the shared helper would be more consistent across storage capabilities.

♻️ Suggested change for consistency
 import { z } from 'zod'
 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 { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
 import { storageRunnerRpc } from '#worker/storage-runner.ts'
-import { storageIdSchema } from './shared.ts'
+import { storageIdSchema, requireStorageUser } from './shared.ts'

And on line 46:

-		const user = requireMcpUser(ctx.callerContext)
+		const user = requireStorageUser(ctx)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/capabilities/storage/storage-query.ts` around lines 5
- 7, Replace the direct use of requireMcpUser(ctx.callerContext) in
storage-query.ts with the shared helper requireStorageUser(ctx) used by
storage-export.ts: remove the import of requireMcpUser from
'#mcp/capabilities/meta/require-user.ts', import requireStorageUser from
'./shared.ts' alongside storageIdSchema, and call requireStorageUser(ctx)
wherever requireMcpUser(ctx.callerContext) is currently used (e.g., in the
function that currently invokes requireMcpUser) to keep storage capability auth
logic consistent.
packages/worker/src/jobs/types.ts (1)

59-63: Avoid a second source of truth for job storage IDs.

This duplicates createJobStorageId in packages/worker/src/storage-runner.ts (Lines 56-58). Since creation, migration, and lookup all depend on the exact same string format, two builders here are an easy drift point. Prefer one shared implementation and re-export it where needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/types.ts` around lines 59 - 63, The job storage ID
builder here duplicates the logic in createJobStorageId (and jobStorageIdPrefix)
in storage-runner.ts; remove buildJobStorageId and jobStorageIdPrefix from
packages/worker/src/jobs/types.ts and instead import and re-export the single
source of truth createJobStorageId (or export the prefix if needed) so all
callers use createJobStorageId from storage-runner.ts to avoid drift between
implementations. Ensure any references to buildJobStorageId are updated to
import createJobStorageId and keep the exported API the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/src/jobs/service.node.test.ts`:
- Line 6: The test imports updateJob but never uses it; remove updateJob from
the import list in the import statement that currently reads import { createJob,
executeJobOnce, updateJob } from './service.ts' so it becomes only import {
createJob, executeJobOnce } from './service.ts' to eliminate the unused import
warning.

In `@packages/worker/src/mcp/capabilities/storage/shared.ts`:
- Around line 9-16: There is an unused duplicate schema: remove dead code by
deleting storageSqlOutputSchema, or preferably refactor storage-query.ts to
import storageSqlOutputSchema and replace its local outputSchema with
storageSqlOutputSchema.extend({ ok: z.boolean(), query: z.string(), writable:
z.boolean() }) so the shared base fields live in storageSqlOutputSchema and you
avoid duplication; update any imports/exports accordingly (symbols:
storageSqlOutputSchema, outputSchema, storage-query.ts).

In `@packages/worker/src/storage-runner.ts`:
- Around line 48-50: The durable-object name builder buildStorageRunnerName
currently concatenates raw userId and storageId with ':' causing ambiguous
collisions when either component contains ':'; update buildStorageRunnerName to
encode both components (e.g., via encodeURIComponent or a stable base64url
encoder) before joining with ':' so the resulting name is unambiguous and
reversible; ensure you apply the same decoding/lookup logic wherever
buildStorageRunnerName is consumed if needed.
- Around line 94-118: The assertSqlAllowed function currently only checks the
leading prefix (allowedReadOnlyPrefixes) allowing multi-statement payloads like
"SELECT 1; DELETE ..."; update assertSqlAllowed to reject any multi-statement
queries when writable is falsy by detecting semicolons or multiple
statements—e.g., after trimming ensure the query contains no ';' and/or that
splitting on ';' yields exactly one non-empty statement and that the single
statement matches the allowed read-only prefixes; keep the existing writable
branch intact so writable: true still permits mutations.
- Around line 120-130: The cursorToSqlResult function currently materializes the
entire cursor via cursor.toArray(), which can OOM for large SELECTs called by
sqlQuery; update sqlQuery (and cursorToSqlResult) to enforce a safe max row
limit (reusing the same pagination bounds as listValues, e.g., 250–1000) or
stream results by iterating the SqlStorageCursor with cursor.next()/for…of and
stop after the limit, then build StorageSqlResult.columns, rows (truncated to
the enforced limit), rowCount (actual returned rows), and preserve
rowsRead/rowsWritten; ensure the limit is applied before materializing and that
sqlQuery documents/returns a truncated result rather than loading the full
result set.

---

Outside diff comments:
In `@packages/worker/src/jobs/types.ts`:
- Around line 101-120: JobUpdateInput and JobUpsertInput currently allow code?:
string | null which can clear a required code field; update these types
(JobUpdateInput and JobUpsertInput) to forbid null for code (make it either
code?: string or code: string when appropriate) so callers cannot set code to
null and violate the runtime expectation expressed by JobRecord.code and
JobCreateInput.code; ensure the change preserves optionality for omitted fields
but disallows explicit null, and update any usage sites or validators that rely
on null semantics to use absence instead.

---

Nitpick comments:
In `@packages/worker/src/jobs/types.ts`:
- Around line 59-63: The job storage ID builder here duplicates the logic in
createJobStorageId (and jobStorageIdPrefix) in storage-runner.ts; remove
buildJobStorageId and jobStorageIdPrefix from packages/worker/src/jobs/types.ts
and instead import and re-export the single source of truth createJobStorageId
(or export the prefix if needed) so all callers use createJobStorageId from
storage-runner.ts to avoid drift between implementations. Ensure any references
to buildJobStorageId are updated to import createJobStorageId and keep the
exported API the same.

In `@packages/worker/src/mcp/capabilities/storage/storage-query.ts`:
- Around line 5-7: Replace the direct use of requireMcpUser(ctx.callerContext)
in storage-query.ts with the shared helper requireStorageUser(ctx) used by
storage-export.ts: remove the import of requireMcpUser from
'#mcp/capabilities/meta/require-user.ts', import requireStorageUser from
'./shared.ts' alongside storageIdSchema, and call requireStorageUser(ctx)
wherever requireMcpUser(ctx.callerContext) is currently used (e.g., in the
function that currently invokes requireMcpUser) to keep storage capability auth
logic consistent.

In `@packages/worker/src/mcp/storage-bindings.ts`:
- Around line 24-29: The block in storage-bindings.ts that checks scope ===
'app' validates trimmed identifiers but returns raw values; change the returns
to the canonical trimmed versions so whitespace is removed from binding keys:
when returning storageContext.appId or storageContext.storageId use their
.trim() results (guarding for null/undefined) instead of the original strings;
update the conditional expressions around storageContext?.appId and
storageContext?.storageId to both check and return the trimmed value to prevent
bucket key drift.

In `@packages/worker/src/storage-runner.workers.test.ts`:
- Around line 131-151: The test "storage runner blocks mutating SQL when
writable is false" uses try/catch for error assertions; replace it with Vitest's
promise rejection matcher by calling storageRunnerRpc(...).sqlQuery(...) and
asserting with expect(promise).rejects.toThrow(...) (or .rejects.toEqual(...) if
you want exact message) to assert the error message, targeting the
runner.sqlQuery call returned promise from storageRunnerRpc to make the test
concise and idiomatic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: af5a1e50-9b89-458b-b7be-7bb4badd9d5b

📥 Commits

Reviewing files that changed from the base of the PR and between fde741f and c86e203.

📒 Files selected for processing (36)
  • docs/contributing/architecture/data-storage.md
  • docs/use/execute.md
  • packages/shared/src/chat.ts
  • packages/worker/migrations/0020-jobs-codemode-only.sql
  • packages/worker/migrations/0021-jobs-storage-id.sql
  • packages/worker/src/env-schema.ts
  • packages/worker/src/index.ts
  • packages/worker/src/jobs/job-runner.ts
  • packages/worker/src/jobs/job-runner.workers.test.ts
  • packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts
  • packages/worker/src/jobs/repo.ts
  • packages/worker/src/jobs/service.node.test.ts
  • packages/worker/src/jobs/service.ts
  • packages/worker/src/jobs/types.ts
  • packages/worker/src/mcp/app-runner.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/capabilities/jobs/domain.ts
  • packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
  • packages/worker/src/mcp/capabilities/jobs/shared.ts
  • packages/worker/src/mcp/capabilities/storage/domain.ts
  • packages/worker/src/mcp/capabilities/storage/shared.ts
  • packages/worker/src/mcp/capabilities/storage/storage-export.ts
  • packages/worker/src/mcp/capabilities/storage/storage-query.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/observability.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/storage-bindings.ts
  • packages/worker/src/mcp/storage.ts
  • packages/worker/src/mcp/tools/execute.node.test.ts
  • packages/worker/src/mcp/tools/execute.ts
  • packages/worker/src/oauth-handlers.workers.test.ts
  • packages/worker/src/storage-runner.ts
  • packages/worker/src/storage-runner.workers.test.ts
  • packages/worker/worker-configuration.d.ts
  • packages/worker/wrangler.jsonc
💤 Files with no reviewable changes (2)
  • packages/worker/src/jobs/job-runner.ts
  • packages/worker/src/jobs/job-runner.workers.test.ts
✅ Files skipped from review due to trivial changes (5)
  • packages/worker/src/mcp/storage.ts
  • packages/worker/src/mcp/capabilities/jobs/domain.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • docs/use/execute.md
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/worker/src/mcp/generated-ui-api.ts
  • docs/contributing/architecture/data-storage.md
  • packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts
  • packages/worker/src/mcp/tools/execute.ts
  • packages/worker/migrations/0020-jobs-codemode-only.sql
  • packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
  • packages/worker/src/jobs/repo.ts
  • packages/worker/src/mcp/run-codemode-registry.ts

import { saveSecret } from '#mcp/secrets/service.ts'
import { saveValue } from '#mcp/values/service.ts'
import { createJob, executeJobOnce } from './service.ts'
import { createJob, executeJobOnce, updateJob } from './service.ts'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused import updateJob.

Static analysis indicates updateJob is imported but never used in this test file.

🧹 Suggested fix
-import { createJob, executeJobOnce, updateJob } from './service.ts'
+import { createJob, executeJobOnce } from './service.ts'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { createJob, executeJobOnce, updateJob } from './service.ts'
import { createJob, executeJobOnce } from './service.ts'
🧰 Tools
🪛 GitHub Check: 🧹 Lint

[warning] 6-6: eslint(no-unused-vars)
Identifier 'updateJob' is imported but never used.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/service.node.test.ts` at line 6, The test imports
updateJob but never uses it; remove updateJob from the import list in the import
statement that currently reads import { createJob, executeJobOnce, updateJob }
from './service.ts' so it becomes only import { createJob, executeJobOnce } from
'./service.ts' to eliminate the unused import warning.

Comment on lines +9 to +16
export const storageSqlOutputSchema = z.object({
storage_id: z.string(),
columns: z.array(z.string()),
rows: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.null()]))),
row_count: z.number(),
rows_read: z.number(),
rows_written: z.number(),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for usages of storageSqlOutputSchema
rg -n 'storageSqlOutputSchema' --type=ts

Repository: kentcdodds/kody

Length of output: 164


🏁 Script executed:

fd -t f "storage-query" --type f

Repository: kentcdodds/kody

Length of output: 120


🏁 Script executed:

cat -n packages/worker/src/mcp/capabilities/storage/shared.ts

Repository: kentcdodds/kody

Length of output: 1286


🏁 Script executed:

rg -n "import.*shared" packages/worker/src/mcp/capabilities/storage/ --type=ts

Repository: kentcdodds/kody

Length of output: 326


🏁 Script executed:

cat -n packages/worker/src/mcp/capabilities/storage/storage-query.ts

Repository: kentcdodds/kody

Length of output: 2748


🏁 Script executed:

rg -n "outputSchema" packages/worker/src/mcp/capabilities/storage/ --type=ts -A 3

Repository: kentcdodds/kody

Length of output: 1277


🏁 Script executed:

rg -n "storageSql" packages/worker/src/mcp/ --type=ts

Repository: kentcdodds/kody

Length of output: 164


🏁 Script executed:

rg -n "storageExportOutputSchema|storageIdSchema" packages/worker/src/mcp/capabilities/storage/ --type=ts

Repository: kentcdodds/kody

Length of output: 855


Remove dead code storageSqlOutputSchema or refactor storage-query.ts to use it.

The schema is never imported or used anywhere in the codebase. Meanwhile, storage-query.ts defines a local outputSchema that duplicates the base fields from storageSqlOutputSchema plus additional fields (ok, query, writable). Either remove the unused schema, or refactor storage-query.ts to use storageSqlOutputSchema.extend({...}) to avoid field duplication and potential drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/capabilities/storage/shared.ts` around lines 9 - 16,
There is an unused duplicate schema: remove dead code by deleting
storageSqlOutputSchema, or preferably refactor storage-query.ts to import
storageSqlOutputSchema and replace its local outputSchema with
storageSqlOutputSchema.extend({ ok: z.boolean(), query: z.string(), writable:
z.boolean() }) so the shared base fields live in storageSqlOutputSchema and you
avoid duplication; update any imports/exports accordingly (symbols:
storageSqlOutputSchema, outputSchema, storage-query.ts).

Comment thread packages/worker/src/storage-runner.ts
Comment thread packages/worker/src/storage-runner.ts
Comment on lines +120 to +130
function cursorToSqlResult(
cursor: SqlStorageCursor<Record<string, StorageSqlValue>>,
): StorageSqlResult {
const rows = cursor.toArray()
return {
columns: [...cursor.columnNames],
rows,
rowCount: rows.length,
rowsRead: cursor.rowsRead,
rowsWritten: cursor.rowsWritten,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What APIs does Cloudflare Durable Object SqlStorageCursorexpose for incremental iteration or limiting rows withouttoArray(), and are there documented response-size limits for Durable Object RPC payloads?

💡 Result:

Cloudflare Durable Objects SqlStorageCursor exposes the following APIs for incremental iteration without toArray: next, raw (returns Iterator over raw column arrays), and for...of iteration (Iterable/Iterator protocol). It also has one for single-row results. There are no documented methods like limit or skip on the cursor itself; pagination must use SQL LIMIT/OFFSET in the query. Properties include columnNames (string[]), rowsRead (number of rows iterated so far), and rowsWritten. No explicit documented response-size limits for Durable Object RPC payloads were found in official docs. Durable Objects are Workers with general Workers limits applying: response header size 128KB total, response body no enforced limit (CDN cache limits 512MB Free/Pro/Business), memory 128MB per isolate. SQLite row size limit is 2MB, SQL statement 100KB. RPC via stubs uses standard Worker fetch with these constraints; large responses risk memory exhaustion or practical limits from serialization/transfer.

Citations:


🏁 Script executed:

cat -n packages/worker/src/storage-runner.ts | sed -n '100,230p'

Repository: kentcdodds/kody

Length of output: 4261


🏁 Script executed:

rg -n "sqlQuery|rowCount|MAX.*ROW|LIMIT" packages/worker/src/storage-runner.ts

Repository: kentcdodds/kody

Length of output: 203


🏁 Script executed:

rg -n "normalizePageSize|pageSize" packages/worker/src/storage-runner.ts

Repository: kentcdodds/kody

Length of output: 633


🏁 Script executed:

cat -n packages/worker/src/storage-runner.ts | sed -n '68,85p'

Repository: kentcdodds/kody

Length of output: 709


🏁 Script executed:

cat -n packages/worker/src/storage-runner.ts | sed -n '1,70p'

Repository: kentcdodds/kody

Length of output: 1990


Add row limits to SQL query results.

The sqlQuery method (line 206) executes arbitrary SELECT queries without enforcing a maximum row count. Line 123 materializes the entire result with toArray(), which can exhaust memory on broad queries. Unlike listValues, which applies pagination bounds (250–1,000 rows), sqlQuery has no safeguards. Add a SQL LIMIT clause or use the cursor's incremental iteration APIs (next() or for…of) to stream results instead of materializing them.

This also applies to the query execution pattern at lines 206–216.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.ts` around lines 120 - 130, The
cursorToSqlResult function currently materializes the entire cursor via
cursor.toArray(), which can OOM for large SELECTs called by sqlQuery; update
sqlQuery (and cursorToSqlResult) to enforce a safe max row limit (reusing the
same pagination bounds as listValues, e.g., 250–1000) or stream results by
iterating the SqlStorageCursor with cursor.next()/for…of and stop after the
limit, then build StorageSqlResult.columns, rows (truncated to the enforced
limit), rowCount (actual returned rows), and preserve rowsRead/rowsWritten;
ensure the limit is applied before materializing and that sqlQuery
documents/returns a truncated result rather than loading the full result set.

@cursor

cursor Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Migration generates job.call() but runtime never defines job
    • Added legacy job helpers that inject a job.call bridge and execute stored server code so migrated facet jobs can run in the codemode sandbox.
  • ✅ Fixed: Duplicate unused createStorageCodemodeTools in storage-runner
    • Removed the unused exported helper in storage-runner.ts to eliminate the duplicate implementation.
  • ✅ Fixed: Unused buildJobStorageId duplicates createJobStorageId
    • Deleted the unused job storage ID helpers from jobs/types.ts to avoid duplicate, unused exports.
Preview (33eb0fc173)
diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md
--- a/docs/contributing/architecture/data-storage.md
+++ b/docs/contributing/architecture/data-storage.md
@@ -53,23 +53,24 @@
 - The Worker routes same-origin browser chat traffic to the agent using the
   existing app session cookie rather than the public MCP OAuth flow
 
-## Durable Objects (`JobManager` and `JobRunner`)
+## Durable Objects (`JobManager` and `StorageRunner`)
 
-Unified jobs use two Durable Object roles:
+Jobs use two Durable Object roles:
 
 - `JobManager`: one object per user, responsible only for alarm scheduling and
   dispatching due jobs from D1-backed metadata
-- `JobRunner`: one object per facet-backed job, responsible for dynamic-worker
-  loading, explicit RPC bridging, and isolated facet SQLite state
+- `StorageRunner`: one object per durable storage id, responsible for isolated
+  SQLite state that can be bound to execute calls, jobs, and dedicated storage
+  inspection capabilities
 
 Storage split:
 
 - D1 `jobs` table: job metadata, persisted caller context, schedule, run
-  counters, last error, last duration, and run history
+  counters, last error, last duration, run history, codemode source, and
+  stable `storage_id`
 - `JobManager` SQLite: only alarm bookkeeping needed to wake the right user's
   due jobs
-- `JobRunner` parent SQLite: facet runner config and observability for one job
-- Facet SQLite: the user job's isolated Durable Object state
+- `StorageRunner` SQLite: isolated durable state addressed by `storageId`
 
 ## Configuration reference
 
@@ -81,5 +82,5 @@
 - `MCP_OBJECT` (Durable Objects)
 - `ChatAgent` (Durable Objects)
 - `JOB_MANAGER` (Durable Objects)
-- `JOB_RUNNER` (Durable Objects)
+- `STORAGE_RUNNER` (Durable Objects)
 - `ASSETS` (static assets bucket)

diff --git a/docs/use/execute.md b/docs/use/execute.md
--- a/docs/use/execute.md
+++ b/docs/use/execute.md
@@ -45,13 +45,16 @@
 - **`job_run_now`** — trigger a job immediately without changing its normal
   schedule
 
-Jobs support two execution kinds:
+Jobs have:
 
-- **`codemode`** jobs store async arrow function source and run through the same
+- **`code`** stores the async arrow function source and runs through the same
   execute/capability runtime as normal `execute`
-- **`facet`** jobs store `serverCode` that exports
-  **`class Job extends DurableObject`** and run through an isolated per-job
-  facet Durable Object with its own SQLite state
+- Each job has a stable **`storageId`** that identifies its durable storage
+  bucket
+- Scheduled jobs run with writable storage access
+- Ad hoc execute calls can bind to a storage bucket with **`storageId`** and use
+  **`storage.get(...)`**, **`storage.set(...)`**, **`storage.list(...)`**, and
+  **`storage.sql(query, params?)`**
 
 Schedules support:
 
@@ -60,6 +63,12 @@
 - **interval** — fixed durations such as **`15m`**, **`1h`**, or **`1d`**
 - **once** — an ISO 8601 UTC timestamp such as **`2026-04-17T15:00:00Z`**
 
+For dedicated inspection, use:
+
+- **`storage_export`** — export one storage bucket as JSON
+- **`storage_query`** — run SQL against one storage bucket (read-only by
+  default, opt into writes explicitly)
+
 ## Long-term memory
 
 Kody can surface a small number of relevant long-term memories when you pass a

diff --git a/packages/shared/src/chat.ts b/packages/shared/src/chat.ts
--- a/packages/shared/src/chat.ts
+++ b/packages/shared/src/chat.ts
@@ -44,6 +44,7 @@
 export const mcpStorageContextSchema = object({
 	sessionId: optional(nullable(string())),
 	appId: optional(nullable(string())),
+	storageId: optional(nullable(string())),
 })
 
 const remoteConnectorRefSchema = object({

diff --git a/packages/worker/migrations/0020-jobs-codemode-only.sql b/packages/worker/migrations/0020-jobs-codemode-only.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0020-jobs-codemode-only.sql
@@ -1,0 +1,104 @@
+PRAGMA defer_foreign_keys = ON;
+
+CREATE TABLE jobs_v3 (
+	id TEXT PRIMARY KEY NOT NULL,
+	user_id TEXT NOT NULL,
+	name TEXT NOT NULL,
+	code TEXT NOT NULL,
+	server_code TEXT,
+	server_code_id TEXT,
+	method_name TEXT,
+	params_json TEXT,
+	schedule_json TEXT NOT NULL,
+	timezone TEXT NOT NULL,
+	enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+	kill_switch_enabled INTEGER NOT NULL DEFAULT 0 CHECK (kill_switch_enabled IN (0, 1)),
+	caller_context_json TEXT NOT NULL,
+	created_at TEXT NOT NULL,
+	updated_at TEXT NOT NULL,
+	last_run_at TEXT,
+	last_run_status TEXT,
+	last_run_error TEXT,
+	last_duration_ms INTEGER,
+	next_run_at TEXT NOT NULL,
+	run_count INTEGER NOT NULL DEFAULT 0,
+	success_count INTEGER NOT NULL DEFAULT 0,
+	error_count INTEGER NOT NULL DEFAULT 0,
+	run_history_json TEXT NOT NULL DEFAULT '[]'
+);
+
+INSERT INTO jobs_v3 (
+	id,
+	user_id,
+	name,
+	code,
+	server_code,
+	server_code_id,
+	method_name,
+	params_json,
+	schedule_json,
+	timezone,
+	enabled,
+	kill_switch_enabled,
+	caller_context_json,
+	created_at,
+	updated_at,
+	last_run_at,
+	last_run_status,
+	last_run_error,
+	last_duration_ms,
+	next_run_at,
+	run_count,
+	success_count,
+	error_count,
+	run_history_json
+)
+SELECT
+	id,
+	user_id,
+	name,
+	CASE
+		WHEN code IS NOT NULL AND TRIM(code) != '' THEN code
+		WHEN server_code IS NOT NULL AND TRIM(server_code) != '' THEN
+			'async (params) => await job.call(''' ||
+			REPLACE(REPLACE(COALESCE(NULLIF(TRIM(method_name), ''), 'run'), CHAR(92), CHAR(92) || CHAR(92)), '''', CHAR(92) || '''') ||
+			''', params)'
+		ELSE 'async () => { throw new Error("migration: missing code") }'
+	END AS code,
+	server_code,
+	server_code_id,
+	CASE
+		WHEN server_code IS NOT NULL AND TRIM(server_code) != '' THEN
+			COALESCE(NULLIF(TRIM(method_name), ''), 'run')
+		ELSE NULL
+	END AS method_name,
+	params_json,
+	schedule_json,
+	timezone,
+	enabled,
+	kill_switch_enabled,
+	caller_context_json,
+	created_at,
+	updated_at,
+	last_run_at,
+	last_run_status,
+	last_run_error,
+	last_duration_ms,
+	next_run_at,
+	run_count,
+	success_count,
+	error_count,
+	run_history_json
+FROM jobs;
+
+DROP TABLE jobs;
+
+ALTER TABLE jobs_v3 RENAME TO jobs;
+
+CREATE INDEX idx_jobs_user_next_run_at
+	ON jobs(user_id, enabled, kill_switch_enabled, next_run_at);
+
+CREATE INDEX idx_jobs_user_name
+	ON jobs(user_id, name);
+
+PRAGMA defer_foreign_keys = OFF;

diff --git a/packages/worker/migrations/0021-jobs-storage-id.sql b/packages/worker/migrations/0021-jobs-storage-id.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0021-jobs-storage-id.sql
@@ -1,0 +1,87 @@
+PRAGMA defer_foreign_keys = ON;
+
+CREATE TABLE jobs_v4 (
+	id TEXT PRIMARY KEY NOT NULL,
+	user_id TEXT NOT NULL,
+	name TEXT NOT NULL,
+	code TEXT NOT NULL,
+	storage_id TEXT NOT NULL,
+	params_json TEXT,
+	schedule_json TEXT NOT NULL,
+	timezone TEXT NOT NULL,
+	enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+	kill_switch_enabled INTEGER NOT NULL DEFAULT 0 CHECK (kill_switch_enabled IN (0, 1)),
+	caller_context_json TEXT NOT NULL,
+	created_at TEXT NOT NULL,
+	updated_at TEXT NOT NULL,
+	last_run_at TEXT,
+	last_run_status TEXT,
+	last_run_error TEXT,
+	last_duration_ms INTEGER,
+	next_run_at TEXT NOT NULL,
+	run_count INTEGER NOT NULL DEFAULT 0,
+	success_count INTEGER NOT NULL DEFAULT 0,
+	error_count INTEGER NOT NULL DEFAULT 0,
+	run_history_json TEXT NOT NULL DEFAULT '[]'
+);
+
+INSERT INTO jobs_v4 (
+	id,
+	user_id,
+	name,
+	code,
+	storage_id,
+	params_json,
+	schedule_json,
+	timezone,
+	enabled,
+	kill_switch_enabled,
+	caller_context_json,
+	created_at,
+	updated_at,
+	last_run_at,
+	last_run_status,
+	last_run_error,
+	last_duration_ms,
+	next_run_at,
+	run_count,
+	success_count,
+	error_count,
+	run_history_json
+)
+SELECT
+	id,
+	user_id,
+	name,
+	code,
+	'job:' || id AS storage_id,
+	params_json,
+	schedule_json,
+	timezone,
+	enabled,
+	kill_switch_enabled,
+	caller_context_json,
+	created_at,
+	updated_at,
+	last_run_at,
+	last_run_status,
+	last_run_error,
+	last_duration_ms,
+	next_run_at,
+	run_count,
+	success_count,
+	error_count,
+	run_history_json
+FROM jobs;
+
+DROP TABLE jobs;
+
+ALTER TABLE jobs_v4 RENAME TO jobs;
+
+CREATE INDEX idx_jobs_user_next_run_at
+	ON jobs(user_id, enabled, kill_switch_enabled, next_run_at);
+
+CREATE INDEX idx_jobs_user_name
+	ON jobs(user_id, name);
+
+PRAGMA defer_foreign_keys = OFF;

diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts
--- a/packages/worker/src/env-schema.ts
+++ b/packages/worker/src/env-schema.ts
@@ -176,12 +176,15 @@
 			)
 		},
 	),
-	JOB_RUNNER: createSchema<unknown, DurableObjectNamespace>(
+	STORAGE_RUNNER: createSchema<unknown, DurableObjectNamespace>(
 		(value, context) => {
 			if (value) {
 				return { value: value as DurableObjectNamespace }
 			}
-			return fail('Missing JOB_RUNNER binding for facet jobs.', context.path)
+			return fail(
+				'Missing STORAGE_RUNNER binding for durable execute and job storage.',
+				context.path,
+			)
 		},
 	),
 	APP_BASE_URL: optionalUrlStringSchema,

diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts
--- a/packages/worker/src/index.ts
+++ b/packages/worker/src/index.ts
@@ -6,7 +6,7 @@
 import { MCP } from './mcp/index.ts'
 import { AppFacetBridge, AppRunner } from './mcp/app-runner.ts'
 import { JobManager } from './jobs/manager-do.ts'
-import { JobFacetBridge, JobRunner } from './jobs/job-runner.ts'
+import { StorageRunner } from './storage-runner.ts'
 import { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts'
 import { getWorkerSentryOptions } from './sentry-options.ts'
 import { handleRequest } from '#app/handler.ts'
@@ -50,9 +50,8 @@
 	MCP,
 	AppFacetBridge,
 	AppRunner,
-	JobFacetBridge,
 	JobManager,
-	JobRunner,
+	StorageRunner,
 }
 
 const claudeWidgetDomainSuffix = '.claudemcpcontent.com'

diff --git a/packages/worker/src/jobs/job-runner.ts b/packages/worker/src/jobs/job-runner.ts
deleted file mode 100644
--- a/packages/worker/src/jobs/job-runner.ts
+++ /dev/null
@@ -1,698 +1,0 @@
-import * as Sentry from '@sentry/cloudflare'
-import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'
-import { buildSentryOptions } from '#worker/sentry-options.ts'
-import { expandSecretPlaceholders } from '#mcp/fetch-gateway.ts'
-import { getValue, listValues, saveValue } from '#mcp/values/service.ts'
-import { resolveSecret } from '#mcp/secrets/service.ts'
-import {
-	buildConnectorValueName,
-	parseConnectorConfig,
-	parseConnectorJson,
-} from '#mcp/capabilities/values/connector-shared.ts'
-
-const defaultJobFacetName = 'main'
-const defaultStorageExportPageSize = 250
-const maxStorageExportPageSize = 1_000
-const jobFacetClassExportName = 'JobFacet'
-const jobExecEntrypointName = 'JobExecWorker'
-
-type JobRunnerConfig = {
-	jobId: string
-	userId: string
-	baseUrl: string
-	storageContext: {
-		sessionId: string | null
-		appId: string | null
-	}
-	facetNames: Array<string>
-	serverCode: string
-	serverCodeId: string
-	methodName: string
-	killSwitchEnabled: boolean
-	lastError: string | null
-}
-
-type JobBridgeProps = {
-	jobId: string
-	userId: string
-	baseUrl: string
-	storageContext: {
-		sessionId: string | null
-		appId: string | null
-	}
-	facetName: string
-}
-
-type JobStorageExport = {
-	entries: Array<{
-		key: string
-		value: unknown
-	}>
-	estimatedBytes: number
-	truncated: boolean
-	nextStartAfter: string | null
-	pageSize: number
-}
-
-const configStorageKey = 'config'
-
-function createFacetWrapperModule() {
-	return `
-import * as userModule from './user-job.js'
-
-const BaseJob = userModule.Job
-const reservedMethodNames = new Set([
-	'fetch',
-	'alarm',
-	'webSocketMessage',
-	'webSocketClose',
-	'webSocketError',
-	'__kody_resetStorage',
-	'__kody_exportStorage',
-	'__kody_invokeUserMethod',
-])
-
-if (typeof BaseJob !== 'function') {
-	throw new Error('Facet job server code must export class Job extends DurableObject.')
-}
-
-export class ${jobFacetClassExportName} extends BaseJob {
-	async __kody_resetStorage() {
-		await this.ctx.storage.deleteAll()
-		return { ok: true }
-	}
-
-	async __kody_exportStorage(options) {
-		const requestedPageSize =
-			typeof options?.pageSize === 'number' && Number.isFinite(options.pageSize)
-				? Math.trunc(options.pageSize)
-				: ${defaultStorageExportPageSize}
-		const pageSize = Math.min(
-			Math.max(requestedPageSize, 1),
-			${maxStorageExportPageSize},
-		)
-		const startAfter =
-			typeof options?.startAfter === 'string' && options.startAfter
-				? options.startAfter
-				: undefined
-		const entries = []
-		let nextStartAfter = null
-		let truncated = false
-		const listedEntries = await this.ctx.storage.list({
-			...(startAfter ? { startAfter } : {}),
-			limit: pageSize + 1,
-		})
-		for (const [key, value] of listedEntries) {
-			if (entries.length === pageSize) {
-				truncated = true
-				break
-			}
-			entries.push({ key, value })
-			nextStartAfter = key
-		}
-		return {
-			entries,
-			estimatedBytes: this.ctx.storage.sql.databaseSize,
-			truncated,
-			nextStartAfter: truncated ? nextStartAfter : null,
-			pageSize,
-		}
-	}
-
-	async __kody_invokeUserMethod(methodName, args) {
-		if (typeof methodName !== 'string' || !methodName.trim()) {
-			throw new Error('Job RPC method name must be a non-empty string.')
-		}
-		const normalizedMethodName = methodName.trim()
-		if (
-			reservedMethodNames.has(normalizedMethodName) ||
-			normalizedMethodName.startsWith('__kody_')
-		) {
-			throw new Error(\`Job RPC method "\${normalizedMethodName}" is not allowed.\`)
-		}
-		const method = Reflect.get(this, normalizedMethodName)
-		if (typeof method !== 'function') {
-			throw new Error(\`Job RPC method "\${normalizedMethodName}" was not found.\`)
-		}
-		return await Reflect.apply(
-			method,
-			this,
-			Array.isArray(args) ? args : [],
-		)
-	}
-}
-	`.trim()
-}
-
-function createJobExecWorkerModule(input: { code: string }) {
-	return `
-import { WorkerEntrypoint } from 'cloudflare:workers'
-
-export class ${jobExecEntrypointName} extends WorkerEntrypoint {
-	async run(params) {
-		const job = {
-			call: (methodName, ...args) => {
-				return this.env.JOB.callJobRpc(methodName, args)
-			},
-		}
-		const __kodyUserCode = (${input.code})
-		return await __kodyUserCode(params)
-	}
-}
-	`.trim()
-}
-
-function normalizeStorageContext(
-	storageContext:
-		| {
-				sessionId?: string | null
-				appId?: string | null
-		  }
-		| null
-		| undefined,
-) {
-	return {
-		sessionId: storageContext?.sessionId ?? null,
-		appId: storageContext?.appId ?? null,
-	}
-}
-
-function defaultConfig(jobId: string): JobRunnerConfig {
-	return {
-		jobId,
-		userId: '',
-		baseUrl: '',
-		storageContext: {
-			sessionId: null,
-			appId: null,
-		},
-		facetNames: [defaultJobFacetName],
-		serverCode: '',
-		serverCodeId: crypto.randomUUID(),
-		methodName: 'run',
-		killSwitchEnabled: false,
-		lastError: null,
-	}
-}
-
-export class JobFacetBridge extends WorkerEntrypoint<Env, JobBridgeProps> {
-	async callJobRpc(methodName: string, args: Array<unknown> = []) {
-		return await jobRunnerRpc(this.env, this.ctx.props.jobId).callJobRpc({
-			jobId: this.ctx.props.jobId,
-			facetName: this.ctx.props.facetName,
-			methodName,
-			args,
-		})
-	}
-
-	async fetchWithResolvedSecrets(input: {
-		url: string
-		method?: string
-		headers?: Record<string, string>
-		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: this.ctx.props.storageContext,
-			},
-			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 connectorGet(args: Record<string, unknown>) {
-		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: this.ctx.props.storageContext,
-		})
-		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: this.ctx.props.storageContext,
-		})
-		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<typeof value> => value != null)
-		return { connectors }
-	}
-
-	async valueGet(name: string, scope: 'app' | 'user' = 'app') {
-		return await getValue({
-			env: this.env,
-			userId: this.ctx.props.userId,
-			name,
-			scope,
-			storageContext: this.ctx.props.storageContext,
-		})
-	}
-
-	async valueSet(input: {
-		name: string
-		value: string
-		description?: string
-		scope?: 'app' | 'user'
-	}) {
-		return await saveValue({
-			env: this.env,
-			userId: this.ctx.props.userId,
-			name: input.name,
-			value: input.value,
-			description: input.description ?? '',
-			scope: input.scope ?? 'app',
-			storageContext: this.ctx.props.storageContext,
-		})
-	}
-
-	async secretPlaceholder(name: string, scope: 'app' | 'user' = 'app') {
-		const resolved = await resolveSecret({
-			env: this.env,
-			userId: this.ctx.props.userId,
-			name,
-			scope,
-			storageContext: this.ctx.props.storageContext,
-		})
-		if (!resolved.found) {
-			throw new Error(`Secret "${name}" was not found.`)
-		}
-		return `{{secret:${name}|scope=${scope}}}`
-	}
-
-	async metaRunSkill(
-		name: string,
-		params: Record<string, unknown> | undefined,
-	) {
-		const { metaRunSkillCapability } =
-			await import('#mcp/capabilities/meta/meta-run-skill.ts')
-		const { createMcpCallerContext } = await import('#mcp/context.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: `job:${this.ctx.props.jobId}`,
-					},
-					storageContext: this.ctx.props.storageContext,
-				}),
-			},
-		)
-	}
-}
-
-class JobRunnerBase extends DurableObject<Env> {
-	async configure(input: {
-		jobId: string
-		userId: string
-		baseUrl?: string
-		storageContext?: {
-			sessionId?: string | null
-			appId?: string | null
-		} | null
-		serverCode: string
-		serverCodeId: string
-		methodName?: string | null
-		killSwitchEnabled?: boolean
-	}) {
-		const existing = await this.readConfig(input.jobId)
-		const nextConfig: JobRunnerConfig = {
-			...existing,
-			jobId: input.jobId,
-			userId: input.userId,
-			baseUrl: input.baseUrl ?? existing.baseUrl,
-			storageContext:
-				input.storageContext === undefined
-					? existing.storageContext
-					: normalizeStorageContext(input.storageContext),
-			serverCode: input.serverCode,
-			serverCodeId: input.serverCodeId,
-			methodName: input.methodName?.trim() || existing.methodName || 'run',
-			killSwitchEnabled: input.killSwitchEnabled ?? existing.killSwitchEnabled,
-		}
-		await this.writeConfig(nextConfig)
-		const codeChanged =
-			existing.serverCodeId !== nextConfig.serverCodeId ||
-			existing.serverCode !== nextConfig.serverCode
-		const bridgeChanged =
-			existing.userId !== nextConfig.userId ||
-			existing.baseUrl !== nextConfig.baseUrl ||
-			JSON.stringify(existing.storageContext) !==
-				JSON.stringify(nextConfig.storageContext)
-		if (codeChanged || bridgeChanged) {
-			for (const facetName of nextConfig.facetNames) {
-				this.ctx.facets.abort(
-					facetName,
-					new Error(
-						codeChanged ? 'Job code updated.' : 'Job bridge context updated.',
-					),
-				)
-			}
-		}
-		return nextConfig
-	}
-
-	async resetStorage(input: { jobId: string; facetName?: string | null }) {
-		const facetName = input.facetName?.trim() || defaultJobFacetName
-		const facet = await this.getFacetStub(input.jobId, facetName)
-		await (
-			facet as unknown as { __kody_resetStorage: () => Promise<unknown> }
-		).__kody_resetStorage()
-		this.ctx.facets.abort(facetName, new Error('Facet storage reset.'))
-		return {
-			ok: true,
-			jobId: input.jobId,
-			facetName,
-		}
-	}
-
-	async exportStorage(input: {
-		jobId: string
-		facetName?: string | null
-		pageSize?: number
-		startAfter?: string | null
-	}) {
-		const facetName = input.facetName?.trim() || defaultJobFacetName
-		const facet = await this.getFacetStub(input.jobId, facetName)
-		const result = await (
-			facet as unknown as {
-				__kody_exportStorage: (payload?: {
-					pageSize?: number
-					startAfter?: string | null
-				}) => Promise<JobStorageExport>
-			}
-		).__kody_exportStorage({
-			pageSize: input.pageSize,
-			startAfter: input.startAfter ?? null,
-		})
-		return {
-			ok: true,
-			jobId: input.jobId,
-			facetName,
-			export: result,
-		}
-	}
-
-	async runStoredJob(input: {
-		jobId: string
-		facetName?: string | null
-		methodName?: string | null
-		params?: Record<string, unknown>
-	}) {
-		const config = await this.readConfig(input.jobId)
-		const methodName = input.methodName?.trim() || config.methodName || 'run'
-		const result = await this.callJobRpc({
-			jobId: input.jobId,
-			facetName: input.facetName ?? defaultJobFacetName,
-			methodName,
-			args: input.params === undefined ? [] : [input.params],
-		})
-		return {
-			ok: true,
-			jobId: input.jobId,
... diff truncated: showing 800 of 3898 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/worker/src/jobs/service.ts Outdated
Comment thread packages/worker/src/jobs/service.ts Outdated
Comment thread packages/worker/src/jobs/process-due-jobs.node.test.ts
Co-authored-by: me <me@kentcdodds.com>
@cursor
cursor Bot force-pushed the cursor/unify-job-runtime-f161 branch from 33eb0fc to ed8dfda Compare April 14, 2026 23:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
packages/worker/src/jobs/service.node.test.ts (1)

6-6: ⚠️ Potential issue | 🟡 Minor

Remove unused import updateJob.

Static analysis indicates updateJob is imported but never used in this test file.

🧹 Suggested fix
-import { createJob, executeJobOnce, updateJob } from './service.ts'
+import { createJob, executeJobOnce } from './service.ts'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/jobs/service.node.test.ts` at line 6, The test imports an
unused symbol updateJob from './service.ts'; remove updateJob from the import
list in the test (leave createJob and executeJobOnce) so the import statement
only imports used functions and eliminates the unused-import lint error; update
the import line that currently reads import { createJob, executeJobOnce,
updateJob } from './service.ts' to exclude updateJob.
packages/worker/src/storage-runner.ts (3)

94-118: ⚠️ Potential issue | 🔴 Critical

Unresolved: Read-only SQL guard is bypassable with multi-statement queries.

The assertSqlAllowed function only validates the leading prefix, so a query like SELECT 1; DROP TABLE kv; passes the read-only check. Since Cloudflare Durable Objects' ctx.storage.sql.exec() executes all semicolon-separated statements, this allows write operations in read-only mode.

🛡️ Suggested fix
 function assertSqlAllowed(query: string, writable: boolean | undefined) {
 	const trimmed = query.trim()
 	if (!trimmed) {
 		throw new Error('storage.sql requires a non-empty query.')
 	}
 	if (writable) return trimmed
+	// Reject multi-statement queries in read-only mode
+	if (trimmed.includes(';')) {
+		throw new Error(
+			'Read-only storage.sql does not allow multi-statement queries. Remove semicolons or pass writable: true.',
+		)
+	}
 	const normalized = trimmed.toLowerCase()
 	const allowedReadOnlyPrefixes = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.ts` around lines 94 - 118, The read-only
guard in assertSqlAllowed is bypassable via multi-statement SQL (e.g., "SELECT
1; DROP TABLE ...") because it only checks the leading prefix; update
assertSqlAllowed (used before calling ctx.storage.sql.exec()) to either reject
queries containing multiple statements (detect semicolons outside
quotes/comments) when writable is false, or split the SQL into individual
statements and validate each one against the allowedReadOnlyPrefixes; ensure the
writable parameter still permits multi-statement mutations and that the new
check handles whitespace/comments and quoted semicolons safely so only truly
single or all-read-only statements are allowed in read-only mode.

120-131: ⚠️ Potential issue | 🟠 Major

Unresolved: SQL query results are unbounded and can exhaust memory.

The cursorToSqlResult function calls cursor.toArray() which materializes the entire result set. Unlike listValues which enforces pagination (max 1,000 rows), sqlQuery has no safeguards against queries returning millions of rows.

🛡️ Suggested fix — enforce a row limit
+const maxSqlResultRows = 1_000
+
 function cursorToSqlResult(
 	cursor: SqlStorageCursor<Record<string, StorageSqlValue>>,
 ): StorageSqlResult {
-	const rows = cursor.toArray()
+	const rows: Array<Record<string, StorageSqlValue>> = []
+	for (const row of cursor) {
+		if (rows.length >= maxSqlResultRows) break
+		rows.push(row)
+	}
 	return {
 		columns: [...cursor.columnNames],
 		rows,
 		rowCount: rows.length,
 		rowsRead: cursor.rowsRead,
 		rowsWritten: cursor.rowsWritten,
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.ts` around lines 120 - 131,
cursorToSqlResult currently calls cursor.toArray() and can materialize an
unbounded result set from sqlQuery; change cursorToSqlResult to enforce a hard
row limit (e.g. reuse the listValues pagination cap like MAX_LIST_ROWS or
introduce MAX_SQL_ROWS = 1000), iterate the cursor and collect at most that many
rows rather than calling toArray(), set rowCount to the collected size, preserve
rowsRead/rowsWritten, and if the result was truncated either attach a flag (e.g.
truncated: true) or log/warn so callers know the output was limited; update any
callers of sqlQuery/sqlCursor expectations accordingly (refer to
cursorToSqlResult and sqlQuery).

48-50: ⚠️ Potential issue | 🟠 Major

Unresolved: Durable object name collision risk.

The buildStorageRunnerName function still concatenates userId and storageId with :, but storageId already contains : (e.g., job:uuid). This makes the DO name ambiguous: (userId = "a:b", storageId = "c") produces the same name as (userId = "a", storageId = "b:c").

🔧 Suggested fix
 function buildStorageRunnerName(userId: string, storageId: string) {
-	return `${userId}:${storageId}`
+	return JSON.stringify([userId, storageId])
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.ts` around lines 48 - 50,
buildStorageRunnerName currently concatenates userId and storageId with ":"
which allows collisions when either part contains ":"; update
buildStorageRunnerName to produce an unambiguous name by encoding each component
(e.g., base64url encode userId and storageId) and then join them (e.g.,
`${b64(userId)}:${b64(storageId)}`) or alternatively include explicit lengths
(e.g., `${userId.length}:${userId}:${storageId.length}:${storageId}`); change
the implementation in the buildStorageRunnerName function to use one of these
approaches so DO names cannot collide.
🧹 Nitpick comments (2)
packages/worker/src/storage-runner.workers.test.ts (1)

131-151: Consider using expect().rejects for cleaner error assertion.

The try/catch pattern works but expect().rejects provides a more declarative assertion style.

♻️ Optional refactor using expect().rejects
-test('storage runner blocks mutating SQL when writable is false', async () => {
-	const storageId = createExecuteStorageId()
-	const runner = storageRunnerRpc({
-		env,
-		userId: 'user-123',
-		storageId,
-	})
-
-	try {
-		await runner.sqlQuery({
-			query: 'delete from counters',
-			writable: false,
-		})
-		throw new Error('Expected read-only SQL mutation to fail.')
-	} catch (error) {
-		expect(error).toBeInstanceOf(Error)
-		expect((error as Error).message).toBe(
-			'Read-only storage.sql only allows SELECT, EXPLAIN, and schema PRAGMA queries. Pass writable: true to allow mutations.',
-		)
-	}
-})
+test('storage runner blocks mutating SQL when writable is false', async () => {
+	const storageId = createExecuteStorageId()
+	const runner = storageRunnerRpc({
+		env,
+		userId: 'user-123',
+		storageId,
+	})
+
+	await expect(
+		runner.sqlQuery({
+			query: 'delete from counters',
+			writable: false,
+		}),
+	).rejects.toThrow(
+		'Read-only storage.sql only allows SELECT, EXPLAIN, and schema PRAGMA queries. Pass writable: true to allow mutations.',
+	)
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/storage-runner.workers.test.ts` around lines 131 - 151,
Replace the try/catch assertion in the "storage runner blocks mutating SQL when
writable is false" test with a declarative promise rejection assertion: call
runner.sqlQuery with the same payload and use Jest's expect(...).rejects matcher
to assert it rejects with an Error and the exact message; locate the test by its
name and the call to runner.sqlQuery({ query: 'delete from counters', writable:
false }) and substitute the try/catch block with an await
expect(runner.sqlQuery(...)).rejects.toThrow( /* expected message */ ) (or
equivalent rejects.toEqual/error-message assertion).
packages/worker/src/mcp/run-codemode-registry.ts (1)

452-562: Duplicated createStorageCodemodeTools implementations across files.

This file defines createStorageCodemodeTools locally (lines 452–562), while packages/worker/src/storage-runner.ts also exports a createStorageCodemodeTools function (lines 262–345). The implementations differ subtly in how they handle the writable flag and tool inclusion:

  • Here: Always includes all storage tools but guards mutations with ensureWritable() at runtime.
  • storage-runner.ts: Conditionally includes mutation tools only when writable: true.

Consider consolidating into a single implementation in storage-runner.ts and importing it here to avoid divergence.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 452 - 562, The
PR duplicates createStorageCodemodeTools: this file defines a local
createStorageCodemodeTools (with ensureWritable guards and always-exported
mutation tools) while storage-runner.ts already exports a differing
implementation; consolidate by removing the local implementation and importing
the canonical createStorageCodemodeTools from storage-runner.ts, update callers
here to use the imported function, and ensure the exported tool set and writable
behavior match the storage-runner.ts version (verify ensureWritable,
storage_get/storage_set/storage_delete/storage_list/storage_clear/storage_sql
symbols remain supported).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/worker/src/jobs/service.node.test.ts`:
- Line 6: The test imports an unused symbol updateJob from './service.ts';
remove updateJob from the import list in the test (leave createJob and
executeJobOnce) so the import statement only imports used functions and
eliminates the unused-import lint error; update the import line that currently
reads import { createJob, executeJobOnce, updateJob } from './service.ts' to
exclude updateJob.

In `@packages/worker/src/storage-runner.ts`:
- Around line 94-118: The read-only guard in assertSqlAllowed is bypassable via
multi-statement SQL (e.g., "SELECT 1; DROP TABLE ...") because it only checks
the leading prefix; update assertSqlAllowed (used before calling
ctx.storage.sql.exec()) to either reject queries containing multiple statements
(detect semicolons outside quotes/comments) when writable is false, or split the
SQL into individual statements and validate each one against the
allowedReadOnlyPrefixes; ensure the writable parameter still permits
multi-statement mutations and that the new check handles whitespace/comments and
quoted semicolons safely so only truly single or all-read-only statements are
allowed in read-only mode.
- Around line 120-131: cursorToSqlResult currently calls cursor.toArray() and
can materialize an unbounded result set from sqlQuery; change cursorToSqlResult
to enforce a hard row limit (e.g. reuse the listValues pagination cap like
MAX_LIST_ROWS or introduce MAX_SQL_ROWS = 1000), iterate the cursor and collect
at most that many rows rather than calling toArray(), set rowCount to the
collected size, preserve rowsRead/rowsWritten, and if the result was truncated
either attach a flag (e.g. truncated: true) or log/warn so callers know the
output was limited; update any callers of sqlQuery/sqlCursor expectations
accordingly (refer to cursorToSqlResult and sqlQuery).
- Around line 48-50: buildStorageRunnerName currently concatenates userId and
storageId with ":" which allows collisions when either part contains ":"; update
buildStorageRunnerName to produce an unambiguous name by encoding each component
(e.g., base64url encode userId and storageId) and then join them (e.g.,
`${b64(userId)}:${b64(storageId)}`) or alternatively include explicit lengths
(e.g., `${userId.length}:${userId}:${storageId.length}:${storageId}`); change
the implementation in the buildStorageRunnerName function to use one of these
approaches so DO names cannot collide.

---

Nitpick comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 452-562: The PR duplicates createStorageCodemodeTools: this file
defines a local createStorageCodemodeTools (with ensureWritable guards and
always-exported mutation tools) while storage-runner.ts already exports a
differing implementation; consolidate by removing the local implementation and
importing the canonical createStorageCodemodeTools from storage-runner.ts,
update callers here to use the imported function, and ensure the exported tool
set and writable behavior match the storage-runner.ts version (verify
ensureWritable,
storage_get/storage_set/storage_delete/storage_list/storage_clear/storage_sql
symbols remain supported).

In `@packages/worker/src/storage-runner.workers.test.ts`:
- Around line 131-151: Replace the try/catch assertion in the "storage runner
blocks mutating SQL when writable is false" test with a declarative promise
rejection assertion: call runner.sqlQuery with the same payload and use Jest's
expect(...).rejects matcher to assert it rejects with an Error and the exact
message; locate the test by its name and the call to runner.sqlQuery({ query:
'delete from counters', writable: false }) and substitute the try/catch block
with an await expect(runner.sqlQuery(...)).rejects.toThrow( /* expected message
*/ ) (or equivalent rejects.toEqual/error-message assertion).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da271e34-b943-4927-990d-7d6594dde638

📥 Commits

Reviewing files that changed from the base of the PR and between c86e203 and ed8dfda.

📒 Files selected for processing (48)
  • docs/contributing/architecture/data-storage.md
  • docs/use/execute.md
  • packages/shared/src/chat.ts
  • packages/worker/migrations/0020-jobs-codemode-only.sql
  • packages/worker/migrations/0021-jobs-storage-id.sql
  • packages/worker/src/env-schema.ts
  • packages/worker/src/index.ts
  • packages/worker/src/jobs/job-runner.ts
  • packages/worker/src/jobs/job-runner.workers.test.ts
  • packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts
  • packages/worker/src/jobs/process-due-jobs.node.test.ts
  • packages/worker/src/jobs/repo.ts
  • packages/worker/src/jobs/service.node.test.ts
  • packages/worker/src/jobs/service.ts
  • packages/worker/src/jobs/types.ts
  • packages/worker/src/mcp/app-runner.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/capabilities/jobs/domain.ts
  • packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
  • packages/worker/src/mcp/capabilities/jobs/shared.ts
  • packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts
  • packages/worker/src/mcp/capabilities/secrets/secret-delete.ts
  • packages/worker/src/mcp/capabilities/secrets/secret-list.ts
  • packages/worker/src/mcp/capabilities/secrets/secret-set.ts
  • packages/worker/src/mcp/capabilities/storage/domain.ts
  • packages/worker/src/mcp/capabilities/storage/shared.ts
  • packages/worker/src/mcp/capabilities/storage/storage-export.ts
  • packages/worker/src/mcp/capabilities/storage/storage-query.ts
  • packages/worker/src/mcp/capabilities/values/connector-get.ts
  • packages/worker/src/mcp/capabilities/values/connector-list.ts
  • packages/worker/src/mcp/capabilities/values/value-delete.ts
  • packages/worker/src/mcp/capabilities/values/value-get.ts
  • packages/worker/src/mcp/capabilities/values/value-list.ts
  • packages/worker/src/mcp/capabilities/values/value-set.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
  • packages/worker/src/mcp/observability.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/storage-bindings.ts
  • packages/worker/src/mcp/storage.ts
  • packages/worker/src/mcp/tools/execute.node.test.ts
  • packages/worker/src/mcp/tools/execute.ts
  • packages/worker/src/oauth-handlers.workers.test.ts
  • packages/worker/src/storage-runner.ts
  • packages/worker/src/storage-runner.workers.test.ts
  • packages/worker/worker-configuration.d.ts
  • packages/worker/wrangler.jsonc
💤 Files with no reviewable changes (3)
  • packages/worker/src/jobs/process-due-jobs.node.test.ts
  • packages/worker/src/jobs/job-runner.workers.test.ts
  • packages/worker/src/jobs/job-runner.ts
✅ Files skipped from review due to trivial changes (7)
  • packages/worker/src/mcp/app-runner.ts
  • packages/worker/src/mcp/capabilities/jobs/domain.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/jobs/repo.ts
  • packages/worker/migrations/0021-jobs-storage-id.sql
  • packages/worker/src/mcp/capabilities/storage/shared.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • packages/worker/src/mcp/storage.ts
  • packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts
  • packages/shared/src/chat.ts
  • packages/worker/src/mcp/capabilities/storage/domain.ts
  • packages/worker/src/mcp/observability.ts
  • packages/worker/src/index.ts
  • packages/worker/src/mcp/capabilities/jobs/job-upsert.ts
  • docs/use/execute.md
  • packages/worker/src/mcp/capabilities/storage/storage-query.ts
  • packages/worker/src/env-schema.ts
  • packages/worker/src/jobs/jobs-codemode-only-migration.node.test.ts
  • packages/worker/migrations/0020-jobs-codemode-only.sql
  • packages/worker/src/mcp/tools/execute.ts
  • docs/contributing/architecture/data-storage.md
  • packages/worker/src/mcp/capabilities/storage/storage-export.ts
  • packages/worker/src/mcp/storage-bindings.ts
  • packages/worker/worker-configuration.d.ts
  • packages/worker/src/jobs/types.ts

Co-authored-by: me <me@kentcdodds.com>
Comment thread packages/worker/src/jobs/service.node.test.ts
Co-authored-by: me <me@kentcdodds.com>
Comment thread packages/worker/src/jobs/service.ts
Co-authored-by: me <me@kentcdodds.com>
Comment thread packages/worker/src/storage-runner.ts
Co-authored-by: me <me@kentcdodds.com>
@kentcdodds
kentcdodds merged commit 9cde01d into main Apr 15, 2026
9 checks passed
@kentcdodds
kentcdodds deleted the cursor/unify-job-runtime-f161 branch April 15, 2026 00:26

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.

Reviewed by Cursor Bugbot for commit fd1af51. Configure here.

writable: storageTools.writable,
})
: {}),
...additionalTools,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage tools silently override capabilities without collision check

Medium Severity

buildCodemodeFns checks additionalTools for name collisions against capabilityMap but applies no such check for the storage codemode tools (storage_get, storage_list, storage_sql, etc.). These tools are spread after capabilities, silently overriding any capability with a matching name. Today the names don't collide, but the storageDomain already registers capabilities in the same storage_* namespace (storage_export, storage_query), making a future collision likely if new storage capabilities or tools are added.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fd1af51. Configure here.

if (trimmed.includes(';')) {
throw new Error(
'Read-only storage.sql only allows a single SELECT, EXPLAIN, or schema PRAGMA statement. Pass writable: true to allow multi-statement or mutating queries.',
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read-only SQL check blocks semicolons inside string literals

Low Severity

assertSqlAllowed rejects any read-only query whose text contains a semicolon via trimmed.includes(';'), which also matches semicolons inside SQL string literals (e.g., SELECT 'a;b' AS val). Valid single-statement read-only queries that happen to include a literal semicolon character will be incorrectly rejected, forcing users to switch to writable: true even though the query is genuinely read-only.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fd1af51. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants