From eac7a2087e00a4dc2d695ed0e6870fb1f4a6a93c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 16:41:10 +0000 Subject: [PATCH 01/42] Add repo source foundation and session capabilities --- package-lock.json | 307 ++++++---- packages/shared/src/chat.ts | 15 + .../worker/migrations/0023-entity-sources.sql | 22 + .../0024-repo-sessions-and-source-columns.sql | 49 ++ packages/worker/package.json | 2 + packages/worker/src/jobs/repo.ts | 21 +- packages/worker/src/jobs/types.ts | 14 +- .../src/mcp/capabilities/apps/ui-save-app.ts | 1 + .../src/mcp/capabilities/builtin-domains.ts | 2 + .../src/mcp/capabilities/domain-metadata.ts | 1 + .../mcp/capabilities/meta/meta-save-skill.ts | 7 +- .../src/mcp/capabilities/repo/domain.ts | 23 + .../worker/src/mcp/capabilities/repo/index.ts | 5 + .../capabilities/repo/repo-discard-session.ts | 35 ++ .../mcp/capabilities/repo/repo-get-session.ts | 24 + .../capabilities/repo/repo-open-session.ts | 64 ++ .../mcp/capabilities/repo/repo-read-file.ts | 31 + .../src/mcp/capabilities/repo/repo-search.ts | 41 ++ .../src/mcp/capabilities/repo/repo-shared.ts | 158 +++++ .../mcp/capabilities/repo/repo-write-file.ts | 33 + packages/worker/src/mcp/context.ts | 3 + .../worker/src/mcp/skills/mcp-skills-repo.ts | 18 +- .../worker/src/mcp/skills/mcp-skills-types.ts | 1 + packages/worker/src/mcp/ui-artifacts-repo.ts | 16 +- packages/worker/src/mcp/ui-artifacts-types.ts | 1 + packages/worker/src/oauth-handlers.ts | 1 + packages/worker/src/repo/artifacts.ts | 161 +++++ packages/worker/src/repo/entity-sources.ts | 171 ++++++ packages/worker/src/repo/manifest.ts | 56 ++ packages/worker/src/repo/repo-session-do.ts | 568 ++++++++++++++++++ packages/worker/src/repo/repo-sessions.ts | 196 ++++++ packages/worker/src/repo/source-service.ts | 112 ++++ packages/worker/src/repo/types.ts | 196 ++++++ packages/worker/worker-configuration.d.ts | 62 +- packages/worker/wrangler.jsonc | 34 ++ 35 files changed, 2313 insertions(+), 138 deletions(-) create mode 100644 packages/worker/migrations/0023-entity-sources.sql create mode 100644 packages/worker/migrations/0024-repo-sessions-and-source-columns.sql create mode 100644 packages/worker/src/mcp/capabilities/repo/domain.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/index.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-get-session.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-open-session.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-read-file.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-search.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-shared.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-write-file.ts create mode 100644 packages/worker/src/repo/artifacts.ts create mode 100644 packages/worker/src/repo/entity-sources.ts create mode 100644 packages/worker/src/repo/manifest.ts create mode 100644 packages/worker/src/repo/repo-session-do.ts create mode 100644 packages/worker/src/repo/repo-sessions.ts create mode 100644 packages/worker/src/repo/source-service.ts create mode 100644 packages/worker/src/repo/types.ts diff --git a/package-lock.json b/package-lock.json index 601fe2b1c2..066bdc90b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -678,7 +678,6 @@ "resolved": "https://registry.npmjs.org/@cloudflare/shell/-/shell-0.3.2.tgz", "integrity": "sha512-2UYlhp308IxKWutGcdOJ8+QTR2J1sck108JJ0AEDYG/aNaapKSCrm0Rvd3EFAfqAPtzsqtFqDOKU/4LtpCF8dw==", "license": "MIT", - "peer": true, "dependencies": { "@cloudflare/codemode": "^0.3.4", "isomorphic-git": "^1.37.5" @@ -1212,6 +1211,38 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@cloudflare/worker-bundler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@cloudflare/worker-bundler/-/worker-bundler-0.1.1.tgz", + "integrity": "sha512-pwEgMFk7RORJZD7sg0GgnW47zATPAFCJocfiiGCEJ6qjeD+QFENwnp495Nudim1LkPkt0EsERQykNfV/c3gaaA==", + "license": "MIT", + "dependencies": { + "@typescript/vfs": "^1.6.4", + "es-module-lexer": "^2.0.0", + "esbuild-wasm": "0.28.0", + "resolve.exports": "^2.0.3", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", + "sucrase": "^3.35.1", + "typescript": "^6.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cloudflare/worker-bundler/node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260415.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260415.1.tgz", @@ -1412,6 +1443,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1428,6 +1460,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1444,6 +1477,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1460,6 +1494,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1493,6 +1528,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1509,6 +1545,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1525,6 +1562,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1541,6 +1579,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1557,6 +1596,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1573,6 +1613,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1589,6 +1630,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1605,6 +1647,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1621,6 +1664,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1637,6 +1681,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1653,6 +1698,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1669,6 +1715,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1685,6 +1732,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1701,6 +1749,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1717,6 +1766,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1733,6 +1783,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1749,6 +1800,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1765,6 +1817,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1781,6 +1834,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1797,6 +1851,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1813,6 +1868,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4088,9 +4144,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4108,9 +4161,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4128,9 +4178,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4148,9 +4195,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4168,9 +4212,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4188,9 +4229,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4208,9 +4246,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4228,9 +4263,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5577,9 +5609,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5596,9 +5625,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5615,9 +5641,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5634,9 +5657,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5653,9 +5673,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5672,9 +5689,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5904,9 +5918,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5921,9 +5932,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5937,9 +5945,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5954,9 +5959,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5971,9 +5973,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5988,9 +5987,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6005,9 +6001,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6022,9 +6015,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6039,9 +6029,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6056,9 +6043,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6073,9 +6057,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6089,9 +6070,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6106,9 +6084,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8086,7 +8061,6 @@ }, "node_modules/@typescript/vfs": { "version": "1.6.4", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3" @@ -9257,7 +9231,6 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -9585,6 +9558,12 @@ "node": ">=14" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "dev": true, @@ -9791,8 +9770,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -10250,8 +10228,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/cli-cursor": { "version": "3.1.0", @@ -10650,7 +10627,6 @@ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -11278,7 +11254,6 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", - "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -11443,8 +11418,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dns-equal": { "version": "1.0.0", @@ -11826,7 +11800,6 @@ }, "node_modules/es-module-lexer": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -11884,7 +11857,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -11922,6 +11895,18 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/esbuild-wasm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz", + "integrity": "sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg==", + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", @@ -11929,6 +11914,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12415,7 +12401,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -12430,7 +12415,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -12893,6 +12877,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14086,7 +14071,6 @@ "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.37.5.tgz", "integrity": "sha512-wek54c5uFvd3WsxewLWt6h0GXKWQh0P8rRXns9bN1rHNjcgCb3+0lmyAsP594NeTtQFeCJQVS9b0kjbkD1l5qg==", "license": "MIT", - "peer": true, "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", @@ -14126,7 +14110,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -14137,7 +14120,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -14147,7 +14129,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", - "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -14207,7 +14188,7 @@ }, "node_modules/jiti": { "version": "2.6.1", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -14531,6 +14512,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14551,6 +14533,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14571,6 +14554,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14591,6 +14575,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14611,6 +14596,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14631,6 +14617,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14651,6 +14638,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14671,6 +14659,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14691,6 +14680,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14711,6 +14701,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14731,6 +14722,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -16056,7 +16048,6 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -16120,7 +16111,6 @@ "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.5" } @@ -16264,6 +16254,17 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", @@ -17014,11 +17015,19 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "license": "MIT", @@ -17416,7 +17425,6 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6.0" } @@ -17867,7 +17875,6 @@ }, "node_modules/resolve.exports": { "version": "2.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -18155,7 +18162,6 @@ }, "node_modules/semver": { "version": "7.7.4", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -18266,7 +18272,6 @@ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "license": "(MIT AND BSD-3-Clause)", - "peer": true, "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", @@ -18503,8 +18508,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", @@ -18525,7 +18529,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -18590,6 +18593,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "devOptional": true, @@ -18838,6 +18853,43 @@ "dev": true, "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/super-regex": { "version": "0.2.0", "dev": true, @@ -18920,6 +18972,27 @@ "node": ">=6" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/throttleit": { "version": "2.1.0", "license": "MIT", @@ -19021,7 +19094,6 @@ "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "license": "MIT", - "peer": true, "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", @@ -19103,6 +19175,12 @@ "node": ">=6.10" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "dev": true, @@ -19267,7 +19345,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -20984,7 +21061,7 @@ }, "node_modules/yaml": { "version": "2.8.3", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -21130,7 +21207,9 @@ "dependencies": { "@cloudflare/ai-chat": "^0.4.4", "@cloudflare/codemode": "^0.3.4", + "@cloudflare/shell": "^0.3.2", "@cloudflare/think": "^0.2.4", + "@cloudflare/worker-bundler": "^0.1.1", "@cloudflare/workers-oauth-provider": "^0.4.0", "@epic-web/invariant": "^1.0.0", "@kody-internal/shared": "file:../shared", diff --git a/packages/shared/src/chat.ts b/packages/shared/src/chat.ts index c198e94e20..fd0e389f1b 100644 --- a/packages/shared/src/chat.ts +++ b/packages/shared/src/chat.ts @@ -47,6 +47,19 @@ export const mcpStorageContextSchema = object({ storageId: optional(nullable(string())), }) +export const mcpRepoContextSchema = object({ + sourceId: optional(nullable(string())), + repoId: optional(nullable(string())), + sessionId: optional(nullable(string())), + sessionRepoId: optional(nullable(string())), + baseCommit: optional(nullable(string())), + manifestPath: optional(nullable(string())), + sourceRoot: optional(nullable(string())), + publishedCommit: optional(nullable(string())), + entityKind: optional(nullable(string())), + entityId: optional(nullable(string())), +}) + const remoteConnectorRefSchema = object({ kind: remoteConnectorKindFieldSchema, instanceId: remoteConnectorInstanceIdFieldSchema, @@ -58,10 +71,12 @@ export const mcpCallerContextSchema = object({ homeConnectorId: optional(nullable(string())), remoteConnectors: optional(nullable(array(remoteConnectorRefSchema))), storageContext: optional(nullable(mcpStorageContextSchema)), + repoContext: optional(nullable(mcpRepoContextSchema)), }) export type McpUserContext = InferOutput export type McpStorageContext = InferOutput +export type McpRepoContext = InferOutput export type McpCallerContext = InferOutput export const chatAgentPropsSchema = object({ diff --git a/packages/worker/migrations/0023-entity-sources.sql b/packages/worker/migrations/0023-entity-sources.sql new file mode 100644 index 0000000000..7987ef2ea9 --- /dev/null +++ b/packages/worker/migrations/0023-entity-sources.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS entity_sources ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + entity_kind TEXT NOT NULL, + entity_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + published_commit TEXT, + indexed_commit TEXT, + manifest_path TEXT NOT NULL DEFAULT 'kody.json', + source_root TEXT NOT NULL DEFAULT '/', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_entity_sources_user_entity +ON entity_sources(user_id, entity_kind, entity_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_entity_sources_repo_id +ON entity_sources(repo_id); + +CREATE INDEX IF NOT EXISTS idx_entity_sources_user_id +ON entity_sources(user_id); diff --git a/packages/worker/migrations/0024-repo-sessions-and-source-columns.sql b/packages/worker/migrations/0024-repo-sessions-and-source-columns.sql new file mode 100644 index 0000000000..a22f6d47f3 --- /dev/null +++ b/packages/worker/migrations/0024-repo-sessions-and-source-columns.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS repo_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + source_id TEXT NOT NULL, + session_repo_id TEXT NOT NULL, + session_repo_name TEXT NOT NULL, + session_repo_namespace TEXT NOT NULL DEFAULT 'default', + base_commit TEXT NOT NULL, + source_root TEXT NOT NULL DEFAULT '/', + conversation_id TEXT, + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT, + last_checkpoint_at TEXT, + last_checkpoint_commit TEXT, + last_check_run_id TEXT, + last_check_tree_hash TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_repo_sessions_user_id +ON repo_sessions(user_id); + +CREATE INDEX IF NOT EXISTS idx_repo_sessions_source_id +ON repo_sessions(source_id); + +CREATE INDEX IF NOT EXISTS idx_repo_sessions_conversation_id +ON repo_sessions(conversation_id); + +ALTER TABLE mcp_skills +ADD COLUMN source_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_mcp_skills_source_id +ON mcp_skills(source_id); + +ALTER TABLE ui_artifacts +ADD COLUMN source_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_ui_artifacts_source_id +ON ui_artifacts(source_id); + +ALTER TABLE jobs +ADD COLUMN source_id TEXT; + +ALTER TABLE jobs +ADD COLUMN published_commit TEXT; + +CREATE INDEX IF NOT EXISTS idx_jobs_source_id +ON jobs(source_id); diff --git a/packages/worker/package.json b/packages/worker/package.json index 4bd5ad7dcb..b9dd368870 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -8,7 +8,9 @@ "dependencies": { "@cloudflare/ai-chat": "^0.4.4", "@cloudflare/codemode": "^0.3.4", + "@cloudflare/shell": "^0.3.2", "@cloudflare/think": "^0.2.4", + "@cloudflare/worker-bundler": "^0.1.1", "@cloudflare/workers-oauth-provider": "^0.4.0", "@epic-web/invariant": "^1.0.0", "@kody-internal/shared": "file:../shared", diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts index e2d286362e..6cd2de0621 100644 --- a/packages/worker/src/jobs/repo.ts +++ b/packages/worker/src/jobs/repo.ts @@ -5,6 +5,8 @@ type JobRowRecord = { user_id: string name: string code: string | null + source_id: string | null + published_commit: string | null storage_id: string | null params_json: string | null schedule_json: string @@ -36,6 +38,8 @@ function serializeJob(job: JobRecord) { id: job.id, name: job.name, code: job.code, + source_id: job.sourceId ?? null, + published_commit: job.publishedCommit ?? null, storage_id: job.storageId, params_json: job.params ? JSON.stringify(job.params) : null, schedule_json: JSON.stringify(job.schedule), @@ -72,6 +76,11 @@ function mapRow(row: Record): JobRow { userId: String(row['user_id']), name: String(row['name']), code: String(row['code']), + sourceId: row['source_id'] == null ? undefined : String(row['source_id']), + publishedCommit: + row['published_commit'] == null + ? undefined + : String(row['published_commit']), storageId: String(row['storage_id']), params: parseJson | undefined>( row['params_json'] == null ? null : String(row['params_json']), @@ -112,6 +121,8 @@ function mapRow(row: Record): JobRow { user_id: String(row['user_id']), name: record.name, code: record.code, + source_id: record.sourceId ?? null, + published_commit: record.publishedCommit ?? null, storage_id: record.storageId ?? null, params_json: row['params_json'] == null ? null : String(row['params_json']), schedule_json: String(row['schedule_json']), @@ -151,17 +162,19 @@ export async function insertJobRow(input: { await input.db .prepare( `INSERT INTO jobs ( - id, user_id, name, code, storage_id, params_json, schedule_json, timezone, enabled, + id, user_id, name, code, source_id, published_commit, 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( serialized.id, input.userId, serialized.name, serialized.code, + serialized.source_id, + serialized.published_commit, serialized.storage_id, serialized.params_json, serialized.schedule_json, @@ -194,7 +207,7 @@ export async function updateJobRow(input: { const result = await input.db .prepare( `UPDATE jobs SET - name = ?, code = ?, storage_id = ?, params_json = ?, schedule_json = ?, timezone = ?, + name = ?, code = ?, source_id = ?, published_commit = ?, storage_id = ?, params_json = ?, schedule_json = ?, timezone = ?, enabled = ?, kill_switch_enabled = ?, caller_context_json = ?, 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 = ? @@ -203,6 +216,8 @@ export async function updateJobRow(input: { .bind( serialized.name, serialized.code, + serialized.source_id, + serialized.published_commit, serialized.storage_id, serialized.params_json, serialized.schedule_json, diff --git a/packages/worker/src/jobs/types.ts b/packages/worker/src/jobs/types.ts index 555fd9a349..54cef61f14 100644 --- a/packages/worker/src/jobs/types.ts +++ b/packages/worker/src/jobs/types.ts @@ -33,6 +33,8 @@ export type JobRecord = { userId: string name: string code: string + sourceId?: string + publishedCommit?: string storageId: string params?: Record schedule: JobSchedule @@ -77,7 +79,11 @@ export type JobExecutionOutcome = { export type PersistedJobCallerContext = Pick< McpCallerContext, - 'baseUrl' | 'homeConnectorId' | 'remoteConnectors' | 'storageContext' + | 'baseUrl' + | 'homeConnectorId' + | 'remoteConnectors' + | 'storageContext' + | 'repoContext' > & { user: McpUserContext } @@ -85,6 +91,8 @@ export type PersistedJobCallerContext = Pick< export type JobCreateInput = { name: string code: string + sourceId?: string + publishedCommit?: string params?: Record schedule: JobSchedule timezone?: string | null @@ -96,6 +104,8 @@ export type JobUpdateInput = { id: string name?: string code?: string + sourceId?: string | null + publishedCommit?: string | null params?: Record | null schedule?: JobSchedule timezone?: string | null @@ -107,6 +117,8 @@ export type JobUpsertInput = { id?: string name?: string code?: string + sourceId?: string | null + publishedCommit?: string | null params?: Record | null schedule?: JobSchedule timezone?: string | null diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index e38a279d76..924cdb4ef9 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -352,6 +352,7 @@ export const uiSaveAppCapability = defineDomainCapability( await insertUiArtifact(ctx.env.APP_DB, { id: appId, user_id: user.userId, + sourceId: null, title, description, clientCode, diff --git a/packages/worker/src/mcp/capabilities/builtin-domains.ts b/packages/worker/src/mcp/capabilities/builtin-domains.ts index 34db2dd5d2..068511bd0e 100644 --- a/packages/worker/src/mcp/capabilities/builtin-domains.ts +++ b/packages/worker/src/mcp/capabilities/builtin-domains.ts @@ -2,6 +2,7 @@ import { appsDomain } from './apps/domain.ts' import { codingDomain } from './coding/domain.ts' import { jobsDomain } from './jobs/domain.ts' import { metaDomain } from './meta/domain.ts' +import { repoDomain } from './repo/domain.ts' import { secretsDomain } from './secrets/domain.ts' import { storageDomain } from './storage/domain.ts' import { valuesDomain } from './values/domain.ts' @@ -19,6 +20,7 @@ export const builtinDomains = [ codingDomain, jobsDomain, metaDomain, + repoDomain, secretsDomain, storageDomain, valuesDomain, diff --git a/packages/worker/src/mcp/capabilities/domain-metadata.ts b/packages/worker/src/mcp/capabilities/domain-metadata.ts index fd554a4a5f..2c797f5e28 100644 --- a/packages/worker/src/mcp/capabilities/domain-metadata.ts +++ b/packages/worker/src/mcp/capabilities/domain-metadata.ts @@ -6,6 +6,7 @@ export const capabilityDomainNames = { jobs: 'jobs', math: 'math', meta: 'meta', + repo: 'repo', secrets: 'secrets', storage: 'storage', values: 'values', diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index e4da4db145..135b7413a6 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -128,7 +128,10 @@ export const metaSaveSkillCapability = defineDomainCapability( ctx.env.APP_DB, user.userId, existing.name, - prep.rowPayload, + { + source_id: existing.source_id, + ...prep.rowPayload, + }, ) if (!updated) { throw new Error('Skill not found for this user.') @@ -138,6 +141,7 @@ export const metaSaveSkillCapability = defineDomainCapability( await insertMcpSkill(ctx.env.APP_DB, { id: skillId, user_id: user.userId, + source_id: null, ...prep.rowPayload, created_at: now, updated_at: now, @@ -162,6 +166,7 @@ export const metaSaveSkillCapability = defineDomainCapability( } catch (cause) { if (existing) { await updateMcpSkill(ctx.env.APP_DB, user.userId, existing.name, { + source_id: existing.source_id, name: existing.name, title: existing.title, description: existing.description, diff --git a/packages/worker/src/mcp/capabilities/repo/domain.ts b/packages/worker/src/mcp/capabilities/repo/domain.ts new file mode 100644 index 0000000000..5a80c54f29 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/domain.ts @@ -0,0 +1,23 @@ +import { defineDomain } from '#mcp/capabilities/define-domain.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { repoDiscardSessionCapability } from './repo-discard-session.ts' +import { repoGetSessionCapability } from './repo-get-session.ts' +import { repoOpenSessionCapability } from './repo-open-session.ts' +import { repoReadFileCapability } from './repo-read-file.ts' +import { repoSearchCapability } from './repo-search.ts' +import { repoWriteFileCapability } from './repo-write-file.ts' + +export const repoDomain = defineDomain({ + name: capabilityDomainNames.repo, + description: + 'Repo-backed source sessions for opening entity workspaces, reading files, applying edits, and searching code inside live session overlays.', + keywords: ['repo', 'artifact', 'session', 'workspace', 'edit', 'search'], + capabilities: [ + repoOpenSessionCapability, + repoGetSessionCapability, + repoReadFileCapability, + repoWriteFileCapability, + repoSearchCapability, + repoDiscardSessionCapability, + ], +}) diff --git a/packages/worker/src/mcp/capabilities/repo/index.ts b/packages/worker/src/mcp/capabilities/repo/index.ts new file mode 100644 index 0000000000..ff79fa2acc --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/index.ts @@ -0,0 +1,5 @@ +import { repoDomain } from './domain.ts' + +export { repoDomain } from './domain.ts' + +export const repoCapabilities = repoDomain.capabilities diff --git a/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts new file mode 100644 index 0000000000..7ca5ae826b --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts @@ -0,0 +1,35 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { + repoSessionIdSchema, + repoDiscardSessionOutputSchema, +} from './repo-shared.ts' + +export const repoDiscardSessionCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_discard_session', + description: + 'Discard a repo editing session and delete its tracked workspace state.', + keywords: ['repo', 'session', 'discard', 'delete', 'close'], + readOnly: false, + idempotent: true, + destructive: true, + inputSchema: repoSessionIdSchema, + outputSchema: repoDiscardSessionOutputSchema, + async handler(args, ctx) { + const result = await repoSessionRpc( + ctx.env, + args.session_id, + ).discardSession({ + sessionId: args.session_id, + }) + return { + ok: true as const, + session_id: result.sessionId, + deleted: result.deleted, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts new file mode 100644 index 0000000000..d813d86e59 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts @@ -0,0 +1,24 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { repoSessionIdSchema, repoSessionInfoSchema } from './repo-shared.ts' + +export const repoGetSessionCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_get_session', + description: + 'Inspect one repo editing session by id so the model can resume work without reopening it.', + keywords: ['repo', 'session', 'inspect', 'resume'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: repoSessionIdSchema, + outputSchema: repoSessionInfoSchema, + async handler(args, ctx) { + return await repoSessionRpc(ctx.env, args.session_id).getSessionInfo({ + sessionId: args.session_id, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts new file mode 100644 index 0000000000..591783675e --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts @@ -0,0 +1,64 @@ +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 { getActiveRepoSessionByConversation } from '#worker/repo/repo-sessions.ts' +import { getEntitySourceById } from '#worker/repo/entity-sources.ts' +import { + repoSessionInfoSchema, + repoOpenSessionInputSchema, +} from './repo-shared.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' + +export const repoOpenSessionCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_open_session', + description: + 'Open or resume a repo-backed editing session for a saved source artifact so later repo capabilities can read, search, edit, validate, and publish against a mutable session fork.', + keywords: ['repo', 'session', 'open', 'resume', 'artifact', 'source'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: repoOpenSessionInputSchema, + outputSchema: repoSessionInfoSchema, + async handler(args, ctx: CapabilityContext) { + const user = ctx.callerContext.user + if (!user) { + throw new Error('repo_open_session requires an authenticated user.') + } + + const existingSession = + args.conversation_id == null + ? null + : await getActiveRepoSessionByConversation(ctx.env.APP_DB, { + userId: user.userId, + conversationId: args.conversation_id, + }) + if (existingSession) { + return repoSessionRpc(ctx.env, existingSession.id).getSessionInfo({ + sessionId: existingSession.id, + }) + } + + const source = await getEntitySourceById(ctx.env.APP_DB, args.source_id) + if (!source || source.user_id !== user.userId) { + throw new Error('Repo source was not found for this user.') + } + + const sessionId = + crypto.randomUUID?.() ?? + `repo-session-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}` + + return repoSessionRpc(ctx.env, sessionId).openSession({ + sessionId, + sourceId: source.id, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + conversationId: args.conversation_id ?? null, + sourceRoot: args.source_root ?? source.source_root, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts b/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts new file mode 100644 index 0000000000..1660057d5a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts @@ -0,0 +1,31 @@ +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 { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { + repoReadFileInputSchema, + repoReadFileOutputSchema, +} from './repo-shared.ts' + +export const repoReadFileCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_read_file', + description: + 'Read a file from the active repo session workspace. Reads the live session overlay, not just the published base commit.', + keywords: ['repo', 'session', 'read', 'file', 'workspace'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: repoReadFileInputSchema, + outputSchema: repoReadFileOutputSchema, + async handler(args, ctx: CapabilityContext) { + requireMcpUser(ctx.callerContext) + return repoSessionRpc(ctx.env, args.session_id).readFile({ + sessionId: args.session_id, + path: args.path, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-search.ts b/packages/worker/src/mcp/capabilities/repo/repo-search.ts new file mode 100644 index 0000000000..1b891a6256 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-search.ts @@ -0,0 +1,41 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { repoSearchInputSchema, repoSearchOutputSchema } from './repo-shared.ts' + +export const repoSearchCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_search', + description: + 'Search within a repo session using rg-style lexical matching over the live session workspace. This is scoped code/file search, not semantic retrieval.', + keywords: ['repo', 'search', 'ripgrep', 'regex', 'literal', 'files'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: repoSearchInputSchema, + outputSchema: repoSearchOutputSchema, + async handler(args, ctx) { + const session = repoSessionRpc(ctx.env, args.session_id) + const result = await session.search({ + sessionId: args.session_id, + pattern: args.pattern, + mode: args.mode, + glob: args.glob, + path: args.path, + caseSensitive: args.case_sensitive, + before: args.before, + after: args.after, + limit: args.limit, + outputMode: args.output_mode, + }) + return { + files: result.files, + total_files: result.totalFiles, + total_matches: result.totalMatches, + output_mode: result.outputMode, + truncated: result.truncated, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts new file mode 100644 index 0000000000..c090c62ddd --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts @@ -0,0 +1,158 @@ +import { z } from 'zod' + +export const repoSearchModeSchema = z.enum(['literal', 'regex']) +export const repoSearchOutputModeSchema = z.enum(['content', 'files']) + +export const repoSessionIdSchema = z.object({ + session_id: z.string().min(1).describe('Active repo session id.'), +}) + +export const repoSessionIdInputSchema = repoSessionIdSchema + +export const repoOpenSessionInputSchema = z.object({ + source_id: z + .string() + .min(1) + .describe('Shared source id to open a session for.'), + conversation_id: z + .string() + .min(1) + .optional() + .describe( + 'Optional conversation id to associate with this repo session for default resolution in later calls.', + ), + source_root: z + .string() + .min(1) + .optional() + .describe( + 'Optional repo subdirectory to treat as the working source root.', + ), + default_branch: z + .string() + .min(1) + .optional() + .describe('Optional default branch name hint for session creation.'), +}) + +export const repoSourceRefSchema = z.object({ + source_id: z + .string() + .min(1) + .describe('Shared source id to open a session for.'), +}) + +export const repoSessionInfoSchema = z.object({ + id: z.string(), + source_id: z.string(), + source_root: z.string(), + base_commit: z.string(), + session_repo_id: z.string(), + session_repo_name: z.string(), + session_repo_namespace: z.string(), + conversation_id: z.string().nullable(), + last_checkpoint_commit: z.string().nullable(), + last_check_run_id: z.string().nullable(), + last_check_tree_hash: z.string().nullable(), + expires_at: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + published_commit: z.string().nullable(), + manifest_path: z.string(), + entity_type: z.enum(['skill', 'app', 'job']), +}) + +export const repoReadFileInputSchema = repoSessionIdSchema.extend({ + path: z.string().min(1).describe('Repo-relative file path to read.'), +}) + +export const repoFileInputSchema = repoReadFileInputSchema + +export const repoReadFileOutputSchema = z.object({ + path: z.string(), + content: z.string().nullable(), +}) + +export const repoWriteFileInputSchema = repoSessionIdSchema.extend({ + path: z.string().min(1).describe('Repo-relative file path to write.'), + content: z + .string() + .describe('Full file contents to persist in the repo session.'), +}) + +export const repoWriteFileOutputSchema = z.object({ + ok: z.literal(true), + path: z.string(), +}) + +export const repoSearchInputSchema = repoSessionIdSchema.extend({ + pattern: z.string().min(1).describe('Literal text or regex to search for.'), + mode: repoSearchModeSchema + .optional() + .describe('Search mode. Defaults to literal.'), + glob: z + .string() + .min(1) + .optional() + .describe('Optional glob filter for files to search.'), + path: z + .string() + .min(1) + .optional() + .describe('Optional repo-relative subpath to scope the search to.'), + case_sensitive: z + .boolean() + .optional() + .describe('Whether matching should be case-sensitive.'), + before: z + .number() + .int() + .min(0) + .optional() + .describe('Lines of context to include before each match.'), + after: z + .number() + .int() + .min(0) + .optional() + .describe('Lines of context to include after each match.'), + limit: z + .number() + .int() + .min(1) + .optional() + .describe('Maximum number of matches to return before truncation.'), + output_mode: repoSearchOutputModeSchema + .optional() + .describe('Whether to return match content or just files with matches.'), +}) + +export const repoSearchMatchSchema = z.object({ + line: z.number().int().min(1), + column: z.number().int().min(1), + match: z.string(), + lineText: z.string(), + beforeLines: z.array(z.string()), + afterLines: z.array(z.string()), +}) + +export const repoSearchFileMatchSchema = z.object({ + path: z.string(), + matches: z.array(repoSearchMatchSchema), +}) + +export const repoSearchOutputSchema = z.object({ + files: z.array(repoSearchFileMatchSchema), + total_files: z.number().int().min(0), + total_matches: z.number().int().min(0), + output_mode: repoSearchOutputModeSchema, + truncated: z.boolean(), +}) + +export const repoDiscardSessionOutputSchema = z.object({ + ok: z.literal(true), + session_id: z.string(), + deleted: z.boolean(), +}) + +export const repoDiscardSessionInputSchema = repoSessionIdSchema diff --git a/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts b/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts new file mode 100644 index 0000000000..f007eda0d8 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts @@ -0,0 +1,33 @@ +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 { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { + repoWriteFileInputSchema, + repoWriteFileOutputSchema, +} from './repo-shared.ts' + +export const repoWriteFileCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_write_file', + description: + 'Write the full contents of one file inside an active repo session.', + keywords: ['repo', 'session', 'write', 'file', 'edit'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: repoWriteFileInputSchema, + outputSchema: repoWriteFileOutputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireMcpUser(ctx.callerContext) + void user + return await repoSessionRpc(ctx.env, args.session_id).writeFile({ + sessionId: args.session_id, + path: args.path, + content: args.content, + }) + }, + }, +) diff --git a/packages/worker/src/mcp/context.ts b/packages/worker/src/mcp/context.ts index 58aae180be..05364d08b2 100644 --- a/packages/worker/src/mcp/context.ts +++ b/packages/worker/src/mcp/context.ts @@ -2,6 +2,7 @@ import { parseSafe } from 'remix/data-schema' import { mcpCallerContextSchema, type McpCallerContext, + type McpRepoContext, type McpStorageContext, type McpUserContext, } from '@kody-internal/shared/chat.ts' @@ -15,6 +16,7 @@ export function createMcpCallerContext(input: { homeConnectorId?: string | null remoteConnectors?: Array | null storageContext?: McpStorageContext | null + repoContext?: McpRepoContext | null }): McpCallerContext { return { baseUrl: input.baseUrl, @@ -22,6 +24,7 @@ export function createMcpCallerContext(input: { homeConnectorId: input.homeConnectorId ?? null, remoteConnectors: input.remoteConnectors ?? null, storageContext: input.storageContext ?? null, + repoContext: input.repoContext ?? null, } } diff --git a/packages/worker/src/mcp/skills/mcp-skills-repo.ts b/packages/worker/src/mcp/skills/mcp-skills-repo.ts index a273aaf1d8..36c535edec 100644 --- a/packages/worker/src/mcp/skills/mcp-skills-repo.ts +++ b/packages/worker/src/mcp/skills/mcp-skills-repo.ts @@ -25,11 +25,11 @@ export async function insertMcpSkill( await db .prepare( `INSERT INTO mcp_skills ( - id, user_id, name, title, description, keywords, code, search_text, + id, user_id, name, title, description, source_id, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, @@ -37,6 +37,7 @@ export async function insertMcpSkill( row.name, row.title, row.description, + row.source_id ?? null, row.keywords, row.code, row.search_text ?? null, @@ -63,7 +64,7 @@ export async function getMcpSkillByName( const normalizedSkillName = normalizeSkillName(skillName) const result = await db .prepare( - `SELECT id, user_id, name, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, source_id, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -84,7 +85,7 @@ export async function getMcpSkillByNameCandidates( const placeholders = skillNames.map(() => '?').join(', ') const result = await db .prepare( - `SELECT id, user_id, name, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, source_id, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -146,6 +147,7 @@ export async function updateMcpSkill( name: string title: string description: string + source_id: string | null keywords: string code: string search_text: string | null @@ -165,7 +167,7 @@ export async function updateMcpSkill( const out = await db .prepare( `UPDATE mcp_skills SET - name = ?, title = ?, description = ?, keywords = ?, code = ?, search_text = ?, + name = ?, title = ?, description = ?, source_id = ?, keywords = ?, code = ?, search_text = ?, uses_capabilities = ?, parameters = ?, collection_name = ?, collection_slug = ?, inferred_capabilities = ?, inference_partial = ?, read_only = ?, idempotent = ?, destructive = ?, updated_at = ? @@ -175,6 +177,7 @@ export async function updateMcpSkill( fields.name, fields.title, fields.description, + fields.source_id, fields.keywords, fields.code, fields.search_text, @@ -213,7 +216,7 @@ export async function listMcpSkillsByUserId( ): Promise> { const { results } = await db .prepare( - `SELECT id, user_id, name, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, source_id, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -260,7 +263,7 @@ export async function listAllMcpSkills( ): Promise> { const { results } = await db .prepare( - `SELECT id, user_id, name, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, source_id, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -277,6 +280,7 @@ function mapRow(r: Record): McpSkillRow { name: String(r['name']), title: String(r['title']), description: String(r['description']), + source_id: r['source_id'] == null ? null : String(r['source_id']), keywords: String(r['keywords']), code: String(r['code']), search_text: r['search_text'] == null ? null : String(r['search_text']), diff --git a/packages/worker/src/mcp/skills/mcp-skills-types.ts b/packages/worker/src/mcp/skills/mcp-skills-types.ts index a8847ccf64..574fc8fe3d 100644 --- a/packages/worker/src/mcp/skills/mcp-skills-types.ts +++ b/packages/worker/src/mcp/skills/mcp-skills-types.ts @@ -6,6 +6,7 @@ export type McpSkillRow = { description: string collection_name: string | null collection_slug: string | null + source_id: string | null keywords: string code: string search_text: string | null diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts index bd03afbf5f..23babf09cd 100644 --- a/packages/worker/src/mcp/ui-artifacts-repo.ts +++ b/packages/worker/src/mcp/ui-artifacts-repo.ts @@ -15,15 +15,16 @@ export async function insertUiArtifact( await db .prepare( `INSERT INTO ui_artifacts ( - id, user_id, title, description, client_code, server_code, + id, user_id, title, description, source_id, client_code, server_code, server_code_id, parameters, hidden, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, row.user_id, row.title, row.description, + row.sourceId ?? null, row.clientCode, row.serverCode ?? null, row.serverCodeId, @@ -42,7 +43,7 @@ export async function getUiArtifactById( ): Promise { const result = await db .prepare( - `SELECT id, user_id, title, description, client_code, server_code, + `SELECT id, user_id, title, description, source_id, client_code, server_code, server_code_id, parameters, hidden, created_at, updated_at FROM ui_artifacts WHERE id = ? AND user_id = ?`, ) @@ -63,7 +64,7 @@ export async function getUiArtifactByOwnerIds( const placeholders = ownerIds.map(() => '?').join(', ') const result = await db .prepare( - `SELECT id, user_id, title, description, client_code, server_code, + `SELECT id, user_id, title, description, source_id, client_code, server_code, server_code_id, parameters, hidden, created_at, updated_at FROM ui_artifacts WHERE id = ? AND user_id IN (${placeholders}) @@ -96,6 +97,7 @@ export async function updateUiArtifact( UiArtifactRow, | 'title' | 'description' + | 'sourceId' | 'clientCode' | 'serverCode' | 'serverCodeId' @@ -117,6 +119,9 @@ export async function updateUiArtifact( if (updates.description !== undefined) { addAssignment('description', updates.description) } + if (updates.sourceId !== undefined) { + addAssignment('source_id', updates.sourceId ?? null) + } if (updates.clientCode !== undefined) { addAssignment('client_code', updates.clientCode) } @@ -152,7 +157,7 @@ export async function listUiArtifactsByUserId( const hidden = options?.hidden const { results } = await db .prepare( - `SELECT id, user_id, title, description, client_code, server_code, + `SELECT id, user_id, title, description, source_id, client_code, server_code, server_code_id, parameters, hidden, created_at, updated_at FROM ui_artifacts WHERE user_id = ? @@ -169,6 +174,7 @@ function mapRow(row: Record): UiArtifactRow { user_id: String(row['user_id']), title: String(row['title']), description: String(row['description']), + sourceId: row['source_id'] == null ? null : String(row['source_id']), clientCode: String(row['client_code']), serverCode: row['server_code'] == null ? null : String(row['server_code']), serverCodeId: String(row['server_code_id']), diff --git a/packages/worker/src/mcp/ui-artifacts-types.ts b/packages/worker/src/mcp/ui-artifacts-types.ts index e8c7804ad3..b3c8fc7690 100644 --- a/packages/worker/src/mcp/ui-artifacts-types.ts +++ b/packages/worker/src/mcp/ui-artifacts-types.ts @@ -3,6 +3,7 @@ export type UiArtifactRow = { user_id: string title: string description: string + sourceId: string | null clientCode: string serverCode: string | null serverCodeId: string diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index 1a0feac27c..07fb00c095 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -820,6 +820,7 @@ async function handleSkillRunnerRequest(request: Request, env: Env) { homeConnectorId: null, remoteConnectors: null, storageContext: null, + repoContext: null, }, name, params, diff --git a/packages/worker/src/repo/artifacts.ts b/packages/worker/src/repo/artifacts.ts new file mode 100644 index 0000000000..43ddc91278 --- /dev/null +++ b/packages/worker/src/repo/artifacts.ts @@ -0,0 +1,161 @@ +import { type EntityKind } from './types.ts' + +export type ArtifactToken = { + id: string + plaintext: string + scope: string + expiresAt: string +} + +export type ArtifactRepoInfo = { + id: string + name: string + description: string | null + defaultBranch: string + createdAt: string + updatedAt: string + lastPushAt: string | null + source: string | null + readOnly: boolean + remote: string +} + +export type ArtifactRepoHandle = { + info(): Promise + createToken(scope?: 'write' | 'read', ttl?: number): Promise + fork(target: { + name: string + namespace?: string + readOnly?: boolean + }): Promise<{ + id: string + name: string + description: string | null + defaultBranch: string + remote: string + token: string + expiresAt: string + repo: ArtifactRepoHandle + }> +} + +export type ArtifactGetRepoResult = + | { status: 'ready'; repo: ArtifactRepoHandle } + | { status: 'not_found' } + | { status: 'importing'; retryAfter: number } + | { status: 'forking'; retryAfter: number } + +export type ArtifactNamespaceBinding = { + create( + name: string, + opts?: { readOnly?: boolean }, + ): Promise<{ + id: string + name: string + description: string | null + defaultBranch: string + remote: string + token: string + expiresAt: string + }> + get(name: string): Promise + list(opts?: { limit?: number; cursor?: string }): Promise<{ + repos: Array> + total: number + cursor?: string + }> +} + +export function getArtifactsBinding( + env: Env, +): ArtifactNamespaceBinding & Record { + const binding = (env as Env & { ARTIFACTS?: ArtifactNamespaceBinding }) + .ARTIFACTS + if (!binding) { + throw new Error('ARTIFACTS binding is not configured.') + } + return binding +} + +export function getArtifactsNamespace(env: Env) { + return ( + (env as Env & { ARTIFACTS_NAMESPACE?: string | undefined }) + .ARTIFACTS_NAMESPACE ?? 'default' + ) +} + +function normalizeRepoNamePart(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function trimRepoName(value: string) { + return value.slice(0, 63).replace(/-+$/g, '') +} + +export function buildEntityRepoId(input: { + entityKind: EntityKind + entityId: string +}) { + return trimRepoName( + normalizeRepoNamePart(`${input.entityKind}-${input.entityId}`), + ) +} + +export function buildSessionRepoId(input: { + entityKind: EntityKind + entityId: string + sessionId: string +}) { + return trimRepoName( + normalizeRepoNamePart( + `${input.entityKind}-${input.entityId}-session-${input.sessionId}`, + ), + ) +} + +export function parseArtifactTokenSecret(token: string) { + return token.split('?expires=')[0] ?? token +} + +export function buildAuthenticatedArtifactsRemote(input: { + remote: string + token: string +}) { + const tokenSecret = parseArtifactTokenSecret(input.token) + return `https://x:${tokenSecret}@${input.remote.slice('https://'.length)}` +} + +export async function resolveArtifactSourceRepo(env: Env, repoId: string) { + const binding = getArtifactsBinding(env) + const result = await binding.get(repoId) + if (result.status !== 'ready') { + throw new Error( + `Artifacts repo "${repoId}" is ${result.status}${ + 'retryAfter' in result ? ` (retry after ${result.retryAfter}s)` : '' + }.`, + ) + } + return result.repo +} + +export async function resolveSessionRepo( + env: Env, + input: { namespace?: string | null; name: string }, +) { + const binding = getArtifactsBinding(env) + void input.namespace + const result = await binding.get(input.name) + if (result.status !== 'ready') { + throw new Error( + `Artifacts repo "${input.name}" is ${result.status}${ + 'retryAfter' in result ? ` (retry after ${result.retryAfter}s)` : '' + }.`, + ) + } + return result.repo +} diff --git a/packages/worker/src/repo/entity-sources.ts b/packages/worker/src/repo/entity-sources.ts new file mode 100644 index 0000000000..456fc88363 --- /dev/null +++ b/packages/worker/src/repo/entity-sources.ts @@ -0,0 +1,171 @@ +import { + type EntityKind, + type EntitySourceRow, + entitySourceRowSchema, +} from './types.ts' + +function mapEntitySourceRow(row: Record): EntitySourceRow { + return entitySourceRowSchema.parse({ + id: String(row['id']), + user_id: String(row['user_id']), + entity_kind: String(row['entity_kind']), + entity_id: String(row['entity_id']), + repo_id: String(row['repo_id']), + published_commit: + row['published_commit'] == null ? null : String(row['published_commit']), + indexed_commit: + row['indexed_commit'] == null ? null : String(row['indexed_commit']), + manifest_path: String(row['manifest_path']), + source_root: String(row['source_root']), + created_at: String(row['created_at']), + updated_at: String(row['updated_at']), + }) +} + +export async function insertEntitySource( + db: D1Database, + row: EntitySourceRow, +): Promise { + await db + .prepare( + `INSERT INTO entity_sources ( + id, user_id, entity_kind, entity_id, repo_id, published_commit, indexed_commit, + manifest_path, source_root, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + row.id, + row.user_id, + row.entity_kind, + row.entity_id, + row.repo_id, + row.published_commit, + row.indexed_commit, + row.manifest_path, + row.source_root, + row.created_at, + row.updated_at, + ) + .run() +} + +export async function getEntitySourceById( + db: D1Database, + id: string, +): Promise { + const result = await db + .prepare(`SELECT * FROM entity_sources WHERE id = ?`) + .bind(id) + .first>() + return result ? mapEntitySourceRow(result) : null +} + +export async function getEntitySourceByEntity( + db: D1Database, + input: { + userId: string + entityKind: EntityKind + entityId: string + }, +): Promise { + const result = await db + .prepare( + `SELECT * FROM entity_sources + WHERE user_id = ? AND entity_kind = ? AND entity_id = ? + LIMIT 1`, + ) + .bind(input.userId, input.entityKind, input.entityId) + .first>() + return result ? mapEntitySourceRow(result) : null +} + +export async function listEntitySourcesByUser( + db: D1Database, + userId: string, +): Promise> { + const { results } = await db + .prepare( + `SELECT * FROM entity_sources + WHERE user_id = ? + ORDER BY updated_at DESC, created_at DESC`, + ) + .bind(userId) + .all>() + return (results ?? []).map(mapEntitySourceRow) +} + +export async function updateEntitySource( + db: D1Database, + input: { + id: string + userId: string + repoId?: string + publishedCommit?: string | null + indexedCommit?: string | null + manifestPath?: string + sourceRoot?: string + }, +): Promise { + const assignments: Array = [] + const values: Array = [] + const add = (column: string, value: unknown) => { + assignments.push(`${column} = ?`) + values.push(value) + } + if (input.repoId !== undefined) add('repo_id', input.repoId) + if (input.publishedCommit !== undefined) { + add('published_commit', input.publishedCommit) + } + if (input.indexedCommit !== undefined) + add('indexed_commit', input.indexedCommit) + if (input.manifestPath !== undefined) add('manifest_path', input.manifestPath) + if (input.sourceRoot !== undefined) add('source_root', input.sourceRoot) + add('updated_at', new Date().toISOString()) + const result = await db + .prepare( + `UPDATE entity_sources + SET ${assignments.join(', ')} + WHERE id = ? AND user_id = ?`, + ) + .bind(...values, input.id, input.userId) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function upsertEntitySource( + db: D1Database, + row: EntitySourceRow, +): Promise { + const existing = await getEntitySourceByEntity(db, { + userId: row.user_id, + entityKind: row.entity_kind, + entityId: row.entity_id, + }) + if (existing) { + await updateEntitySource(db, { + id: existing.id, + userId: row.user_id, + repoId: row.repo_id, + publishedCommit: row.published_commit, + indexedCommit: row.indexed_commit, + manifestPath: row.manifest_path, + sourceRoot: row.source_root, + }) + return + } + await insertEntitySource(db, row) +} + +export async function deleteEntitySource( + db: D1Database, + input: { + id: string + userId: string + }, +): Promise { + const result = await db + .prepare(`DELETE FROM entity_sources WHERE id = ? AND user_id = ?`) + .bind(input.id, input.userId) + .run() + return (result.meta.changes ?? 0) > 0 +} diff --git a/packages/worker/src/repo/manifest.ts b/packages/worker/src/repo/manifest.ts new file mode 100644 index 0000000000..c54ca4087e --- /dev/null +++ b/packages/worker/src/repo/manifest.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { + repoManifestSchema, + type RepoManifest, + type SearchProjection, +} from './types.ts' + +const defaultManifestPath = 'kody.json' + +export function parseRepoManifest(input: { + content: string + manifestPath?: string +}): RepoManifest { + let parsed: unknown + try { + parsed = JSON.parse(input.content) + } catch (cause) { + throw new Error( + `Failed to parse ${input.manifestPath ?? defaultManifestPath}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ) + } + const result = repoManifestSchema.safeParse(parsed) + if (!result.success) { + const formatted = z.prettifyError(result.error) + throw new Error( + `Invalid ${input.manifestPath ?? defaultManifestPath}:\n${formatted}`, + ) + } + return result.data +} + +export function buildSearchProjectionFromManifest( + manifest: RepoManifest, +): SearchProjection { + return { + title: manifest.title, + description: manifest.description, + keywords: [...(manifest.keywords ?? [])], + searchText: manifest.searchText?.trim() || null, + } +} + +export function getManifestSourceRoot(manifest: RepoManifest) { + const sourceRoot = manifest.sourceRoot?.trim() + if (!sourceRoot) return '/' + return sourceRoot.startsWith('/') ? sourceRoot : `/${sourceRoot}` +} + +export function getManifestPath(manifest: RepoManifest) { + const manifestPath = manifest.manifestPath?.trim() + return manifestPath && manifestPath.length > 0 + ? manifestPath + : defaultManifestPath +} diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts new file mode 100644 index 0000000000..7f8dab8619 --- /dev/null +++ b/packages/worker/src/repo/repo-session-do.ts @@ -0,0 +1,568 @@ +import * as Sentry from '@sentry/cloudflare' +import { DurableObject } from 'cloudflare:workers' +import { Workspace, WorkspaceFileSystem } from '@cloudflare/shell' +import { createGit } from '@cloudflare/shell/git' +import { + deleteRepoSession, + getRepoSessionById, + insertRepoSession, + updateRepoSession, +} from './repo-sessions.ts' +import { resolveArtifactSourceRepo, resolveSessionRepo } from './artifacts.ts' +import { buildSentryOptions } from '#worker/sentry-options.ts' +import { getEntitySourceById } from './entity-sources.ts' +import { type EntitySourceRow, type RepoSessionRow } from './types.ts' + +const repoSessionWorkspacePrefix = '/session' +const defaultRepoSearchLimit = 50 + +export type RepoSearchMode = 'literal' | 'regex' +export type RepoSearchOutputMode = 'content' | 'files' + +export type RepoSearchMatch = { + line: number + column: number + match: string + lineText: string + beforeLines: Array + afterLines: Array +} + +export type RepoSearchFileMatch = { + path: string + matches: Array +} + +export type RepoSessionSearchResult = { + files: Array + totalFiles: number + totalMatches: number + outputMode: RepoSearchOutputMode + truncated: boolean +} + +export type RepoSessionDiscardResult = { + ok: true + sessionId: string + deleted: boolean +} + +function buildRepoSessionWorkspaceName(sessionId: string) { + return `repo-session:${sessionId}` +} + +function nowIso() { + return new Date().toISOString() +} + +function normalizeSearchLimit(limit: number | undefined) { + if (!Number.isFinite(limit)) return defaultRepoSearchLimit + return Math.min(Math.max(Math.trunc(limit as number), 1), 200) +} + +function escapeRegex(source: string) { + return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function normalizeSearchQuery(input: { + pattern: string + mode?: RepoSearchMode +}) { + const pattern = input.pattern.trim() + if (!pattern) { + throw new Error('repo_search requires a non-empty pattern.') + } + return { + query: pattern, + regex: input.mode === 'regex', + } +} + +async function ensureArtifactRepoRemote(input: { + repo: { + info: () => Promise<{ remote: string } | null> + createToken: ( + scope?: 'write' | 'read', + ttl?: number, + ) => Promise<{ + plaintext: string + }> + } + scope?: 'write' | 'read' +}) { + const info = await input.repo.info() + if (!info?.remote) { + throw new Error('Artifact repo remote URL is unavailable.') + } + const token = await input.repo.createToken(input.scope ?? 'write', 3600) + return { + remote: info.remote, + token: token.plaintext, + } +} + +function buildGitCloneAuth(input: { remote: string; token: string }) { + const tokenSecret = input.token.split('?expires=')[0] ?? input.token + return { + url: input.remote, + username: 'x', + password: tokenSecret, + } +} + +class RepoSessionBase extends DurableObject { + readonly workspace = new Workspace({ + sql: this.ctx.storage.sql, + name: () => buildRepoSessionWorkspaceName(this.ctx.id.toString()), + }) + + readonly fileSystem = new WorkspaceFileSystem(this.workspace) + + readonly git = createGit(this.fileSystem, repoSessionWorkspacePrefix) + + private initializedSessionId: string | null = null + + async initialize(input: { + sessionId: string + sessionRepoRemote: string + sessionRepoToken: string + }): Promise { + if (this.initializedSessionId === input.sessionId) return + const hasGitDir = await this.workspace.exists( + `${repoSessionWorkspacePrefix}/.git/config`, + ) + if (!hasGitDir) { + await this.workspace.mkdir(repoSessionWorkspacePrefix, { + recursive: true, + }) + await this.git.clone({ + dir: repoSessionWorkspacePrefix, + ...buildGitCloneAuth({ + remote: input.sessionRepoRemote, + token: input.sessionRepoToken, + }), + }) + } + this.initializedSessionId = input.sessionId + } + + async openSession(input: { + sessionId: string + sourceId: string + userId: string + baseUrl: string + conversationId?: string | null + sourceRoot?: string | null + defaultBranch?: string | null + }) { + let sessionRow = await getRepoSessionById(this.env.APP_DB, input.sessionId) + if (!sessionRow) { + const source = await getEntitySourceById(this.env.APP_DB, input.sourceId) + if (!source) { + throw new Error(`Source "${input.sourceId}" was not found.`) + } + const sourceRepo = await resolveArtifactSourceRepo( + this.env, + source.repo_id, + ) + const baseCommit = source.published_commit + const sessionRepoName = `${source.repo_id}-${input.sessionId}`.slice( + 0, + 63, + ) + const forked = await sourceRepo.fork({ + name: sessionRepoName, + readOnly: false, + }) + const now = nowIso() + const newSessionRow: RepoSessionRow = { + id: input.sessionId, + user_id: input.userId, + source_id: input.sourceId, + session_repo_id: forked.id, + session_repo_name: forked.name, + session_repo_namespace: 'default', + base_commit: baseCommit ?? '', + source_root: input.sourceRoot ?? source.source_root, + conversation_id: input.conversationId ?? null, + status: 'active', + expires_at: null, + last_checkpoint_at: null, + last_checkpoint_commit: baseCommit, + last_check_run_id: null, + last_check_tree_hash: null, + created_at: now, + updated_at: now, + } + await insertRepoSession(this.env.APP_DB, newSessionRow) + sessionRow = newSessionRow + await this.initialize({ + sessionId: sessionRow.id, + sessionRepoRemote: forked.remote, + sessionRepoToken: forked.token, + }) + } else { + const sessionRepo = await resolveSessionRepo(this.env, { + namespace: sessionRow.session_repo_namespace, + name: sessionRow.session_repo_name, + }) + const access = await ensureArtifactRepoRemote({ + repo: sessionRepo, + scope: 'write', + }) + await this.initialize({ + sessionId: sessionRow.id, + sessionRepoRemote: access.remote, + sessionRepoToken: access.token, + }) + } + const source = await getEntitySourceById( + this.env.APP_DB, + sessionRow.source_id, + ) + if (!source) { + throw new Error(`Source "${sessionRow.source_id}" was not found.`) + } + return this.toSessionInfo(sessionRow, source) + } + + async getSessionInfo(input: { sessionId: string }) { + const sessionRow = await getRepoSessionById( + this.env.APP_DB, + input.sessionId, + ) + if (!sessionRow) { + throw new Error(`Repo session "${input.sessionId}" was not found.`) + } + const source = await getEntitySourceById( + this.env.APP_DB, + sessionRow.source_id, + ) + if (!source) { + throw new Error(`Source "${sessionRow.source_id}" was not found.`) + } + const sessionRepo = await resolveSessionRepo(this.env, { + namespace: sessionRow.session_repo_namespace, + name: sessionRow.session_repo_name, + }) + const access = await ensureArtifactRepoRemote({ + repo: sessionRepo, + scope: 'write', + }) + await this.initialize({ + sessionId: sessionRow.id, + sessionRepoRemote: access.remote, + sessionRepoToken: access.token, + }) + return this.toSessionInfo(sessionRow, source) + } + + async discardSession(input: { + sessionId: string + }): Promise { + const sessionRow = await getRepoSessionById( + this.env.APP_DB, + input.sessionId, + ) + if (!sessionRow) { + return { + ok: true, + sessionId: input.sessionId, + deleted: false, + } + } + await deleteRepoSession(this.env.APP_DB, input.sessionId) + try { + await this.workspace.rm(repoSessionWorkspacePrefix, { + force: true, + recursive: true, + }) + } catch { + // Best effort only; the session row is the source of truth. + } + return { + ok: true, + sessionId: input.sessionId, + deleted: true, + } + } + + async readFile(input: { + sessionId: string + path: string + }): Promise<{ path: string; content: string | null }> { + await this.getSessionInfo({ sessionId: input.sessionId }) + return { + path: input.path, + content: await this.workspace.readFile( + this.resolveWorkspacePath(input.path), + ), + } + } + + async writeFile(input: { + sessionId: string + path: string + content: string + }): Promise<{ ok: true; path: string }> { + await this.getSessionInfo({ sessionId: input.sessionId }) + await this.workspace.writeFile( + this.resolveWorkspacePath(input.path), + input.content, + ) + await updateRepoSession(this.env.APP_DB, { + id: input.sessionId, + userId: (await this.getSessionInfo({ sessionId: input.sessionId })).id, + }) + return { ok: true, path: input.path } + } + + async search(input: { + sessionId: string + pattern: string + mode?: RepoSearchMode + glob?: string | null + path?: string | null + caseSensitive?: boolean + before?: number + after?: number + limit?: number + outputMode?: RepoSearchOutputMode + }): Promise { + const session = await this.getSessionInfo({ sessionId: input.sessionId }) + const search = normalizeSearchQuery({ + pattern: input.pattern, + mode: input.mode, + }) + const root = + input.path?.trim() || session.source_root || repoSessionWorkspacePrefix + const globPattern = + input.glob?.trim() || + `${root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` + const files = await this.workspace.glob(globPattern) + const matchMap = new Map() + const outputMode = input.outputMode ?? 'content' + const maxMatches = normalizeSearchLimit(input.limit) + let totalMatches = 0 + for (const file of files) { + if (file.type !== 'file') continue + const content = await this.workspace.readFile(file.path) + if (content == null) continue + const matches = searchInText({ + content, + query: search.query, + regex: search.regex, + caseSensitive: input.caseSensitive ?? false, + contextBefore: input.before ?? 0, + contextAfter: input.after ?? 0, + maxMatches, + }) + if (matches.length === 0) continue + matchMap.set(file.path, { + path: this.toExternalPath(file.path), + matches: + outputMode === 'files' + ? [] + : matches.map((match) => ({ + line: match.line, + column: match.column, + match: match.match, + lineText: match.lineText, + beforeLines: match.beforeLines ?? [], + afterLines: match.afterLines ?? [], + })), + }) + totalMatches += matches.length + } + const filesWithMatches = [...matchMap.values()].sort((left, right) => + left.path.localeCompare(right.path), + ) + return { + files: filesWithMatches, + totalFiles: filesWithMatches.length, + totalMatches, + outputMode, + truncated: totalMatches > maxMatches, + } + } + + private resolveWorkspacePath(path: string) { + const trimmed = path.trim() + if (!trimmed) { + throw new Error('A non-empty repo path is required.') + } + if (trimmed.startsWith(repoSessionWorkspacePrefix)) { + return trimmed + } + return `${repoSessionWorkspacePrefix}/${trimmed.replace(/^\/+/, '')}` + } + + private toExternalPath(path: string) { + return path.startsWith(`${repoSessionWorkspacePrefix}/`) + ? path.slice(repoSessionWorkspacePrefix.length + 1) + : path + } + + private toSessionInfo(session: RepoSessionRow, source: EntitySourceRow) { + return { + id: session.id, + source_id: session.source_id, + source_root: session.source_root, + base_commit: session.base_commit, + session_repo_id: session.session_repo_id, + session_repo_name: session.session_repo_name, + session_repo_namespace: session.session_repo_namespace, + conversation_id: session.conversation_id, + last_checkpoint_commit: session.last_checkpoint_commit, + last_check_run_id: session.last_check_run_id, + last_check_tree_hash: session.last_check_tree_hash, + expires_at: session.expires_at, + created_at: session.created_at, + updated_at: session.updated_at, + published_commit: source.published_commit, + manifest_path: source.manifest_path, + entity_type: source.entity_kind, + } + } +} + +export const RepoSession = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => buildSentryOptions(env), + RepoSessionBase, +) + +export function repoSessionRpc(env: Env, sessionId: string) { + const namespace = ( + env as Env & { REPO_SESSION?: DurableObjectNamespace | undefined } + ).REPO_SESSION + if (!namespace) { + throw new Error('REPO_SESSION binding is not configured.') + } + return namespace.get(namespace.idFromName(sessionId)) as unknown as { + openSession: (payload: { + sessionId: string + sourceId: string + userId: string + baseUrl: string + conversationId?: string | null + sourceRoot?: string | null + defaultBranch?: string | null + }) => Promise<{ + id: string + source_id: string + source_root: string + base_commit: string + session_repo_id: string + session_repo_name: string + session_repo_namespace: string + conversation_id: string | null + last_checkpoint_commit: string | null + last_check_run_id: string | null + last_check_tree_hash: string | null + expires_at: string | null + created_at: string + updated_at: string + published_commit: string | null + manifest_path: string + entity_type: 'skill' | 'app' | 'job' + }> + getSessionInfo: (payload: { sessionId: string }) => Promise<{ + id: string + source_id: string + source_root: string + base_commit: string + session_repo_id: string + session_repo_name: string + session_repo_namespace: string + conversation_id: string | null + last_checkpoint_commit: string | null + last_check_run_id: string | null + last_check_tree_hash: string | null + expires_at: string | null + created_at: string + updated_at: string + published_commit: string | null + manifest_path: string + entity_type: 'skill' | 'app' | 'job' + }> + discardSession: (payload: { + sessionId: string + }) => Promise + readFile: (payload: { + sessionId: string + path: string + }) => Promise<{ path: string; content: string | null }> + writeFile: (payload: { + sessionId: string + path: string + content: string + }) => Promise<{ ok: true; path: string }> + search: (payload: { + sessionId: string + pattern: string + mode?: RepoSearchMode + glob?: string | null + path?: string | null + caseSensitive?: boolean + before?: number + after?: number + limit?: number + outputMode?: RepoSearchOutputMode + }) => Promise + } +} + +function searchInText(input: { + content: string + query: string + regex: boolean + caseSensitive: boolean + contextBefore: number + contextAfter: number + maxMatches: number +}) { + const source = input.content + const flags = input.caseSensitive ? 'g' : 'gi' + const pattern = input.regex ? input.query : escapeRegex(input.query) + const matcher = new RegExp(pattern, flags) + const lines = source.split('\n') + const lineOffsets: number[] = [] + let offset = 0 + for (const line of lines) { + lineOffsets.push(offset) + offset += line.length + 1 + } + const matches: Array<{ + line: number + column: number + match: string + lineText: string + beforeLines?: string[] + afterLines?: string[] + }> = [] + for (const match of source.matchAll(matcher)) { + if (matches.length >= input.maxMatches) break + const index = match.index ?? 0 + let lineIndex = 0 + for (let candidate = 0; candidate < lineOffsets.length; candidate += 1) { + const candidateOffset = lineOffsets[candidate] + if (candidateOffset === undefined) break + if (candidateOffset > index) break + lineIndex = candidate + } + const lineStart = lineOffsets[lineIndex] ?? 0 + const column = index - lineStart + 1 + const lineText = lines[lineIndex] ?? '' + const beforeStart = Math.max(0, lineIndex - input.contextBefore) + const afterEnd = Math.min(lines.length, lineIndex + input.contextAfter + 1) + matches.push({ + line: lineIndex + 1, + column, + match: match[0] ?? '', + lineText, + beforeLines: lines.slice(beforeStart, lineIndex), + afterLines: lines.slice(lineIndex + 1, afterEnd), + }) + } + return matches +} diff --git a/packages/worker/src/repo/repo-sessions.ts b/packages/worker/src/repo/repo-sessions.ts new file mode 100644 index 0000000000..95c6bfea90 --- /dev/null +++ b/packages/worker/src/repo/repo-sessions.ts @@ -0,0 +1,196 @@ +import { repoSessionRowSchema, type RepoSessionRow } from './types.ts' + +function mapRepoSessionRow(row: Record): RepoSessionRow { + return repoSessionRowSchema.parse({ + id: String(row['id']), + user_id: String(row['user_id']), + source_id: String(row['source_id']), + session_repo_id: String(row['session_repo_id']), + session_repo_name: String(row['session_repo_name']), + session_repo_namespace: String(row['session_repo_namespace']), + base_commit: String(row['base_commit']), + source_root: String(row['source_root']), + conversation_id: + row['conversation_id'] == null ? null : String(row['conversation_id']), + status: String(row['status']), + expires_at: row['expires_at'] == null ? null : String(row['expires_at']), + last_checkpoint_at: + row['last_checkpoint_at'] == null + ? null + : String(row['last_checkpoint_at']), + last_checkpoint_commit: + row['last_checkpoint_commit'] == null + ? null + : String(row['last_checkpoint_commit']), + last_check_run_id: + row['last_check_run_id'] == null + ? null + : String(row['last_check_run_id']), + last_check_tree_hash: + row['last_check_tree_hash'] == null + ? null + : String(row['last_check_tree_hash']), + created_at: String(row['created_at']), + updated_at: String(row['updated_at']), + }) +} + +export async function insertRepoSession( + db: D1Database, + row: RepoSessionRow, +): Promise { + await db + .prepare( + `INSERT INTO repo_sessions ( + id, user_id, source_id, session_repo_id, session_repo_name, session_repo_namespace, + base_commit, source_root, conversation_id, status, expires_at, + last_checkpoint_at, last_checkpoint_commit, last_check_run_id, + last_check_tree_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + row.id, + row.user_id, + row.source_id, + row.session_repo_id, + row.session_repo_name, + row.session_repo_namespace, + row.base_commit, + row.source_root, + row.conversation_id, + row.status, + row.expires_at, + row.last_checkpoint_at, + row.last_checkpoint_commit, + row.last_check_run_id, + row.last_check_tree_hash, + row.created_at, + row.updated_at, + ) + .run() +} + +export async function getRepoSessionById( + db: D1Database, + id: string, +): Promise { + const result = await db + .prepare(`SELECT * FROM repo_sessions WHERE id = ?`) + .bind(id) + .first>() + return result ? mapRepoSessionRow(result) : null +} + +export async function getActiveRepoSessionByConversation( + db: D1Database, + input: { + userId: string + conversationId: string + }, +): Promise { + const result = await db + .prepare( + `SELECT * FROM repo_sessions + WHERE user_id = ? + AND conversation_id = ? + AND status = 'active' + ORDER BY updated_at DESC + LIMIT 1`, + ) + .bind(input.userId, input.conversationId) + .first>() + return result ? mapRepoSessionRow(result) : null +} + +export async function listRepoSessionsBySource( + db: D1Database, + input: { + userId: string + sourceId: string + }, +): Promise> { + const { results } = await db + .prepare( + `SELECT * FROM repo_sessions + WHERE user_id = ? AND source_id = ? + ORDER BY updated_at DESC`, + ) + .bind(input.userId, input.sourceId) + .all>() + return (results ?? []).map(mapRepoSessionRow) +} + +export async function updateRepoSession( + db: D1Database, + input: { + id: string + userId: string + sessionRepoId?: string + sessionRepoName?: string + sessionRepoNamespace?: string + baseCommit?: string + sourceRoot?: string + conversationId?: string | null + status?: RepoSessionRow['status'] + expiresAt?: string | null + lastCheckpointAt?: string | null + lastCheckpointCommit?: string | null + lastCheckRunId?: string | null + lastCheckTreeHash?: string | null + }, +): Promise { + const assignments: Array = [] + const values: Array = [] + const add = (column: string, value: unknown) => { + assignments.push(`${column} = ?`) + values.push(value) + } + if (input.sessionRepoId !== undefined) { + add('session_repo_id', input.sessionRepoId) + } + if (input.sessionRepoName !== undefined) { + add('session_repo_name', input.sessionRepoName) + } + if (input.sessionRepoNamespace !== undefined) { + add('session_repo_namespace', input.sessionRepoNamespace) + } + if (input.baseCommit !== undefined) add('base_commit', input.baseCommit) + if (input.sourceRoot !== undefined) add('source_root', input.sourceRoot) + if (input.conversationId !== undefined) { + add('conversation_id', input.conversationId) + } + if (input.status !== undefined) add('status', input.status) + if (input.expiresAt !== undefined) add('expires_at', input.expiresAt) + if (input.lastCheckpointAt !== undefined) { + add('last_checkpoint_at', input.lastCheckpointAt) + } + if (input.lastCheckpointCommit !== undefined) { + add('last_checkpoint_commit', input.lastCheckpointCommit) + } + if (input.lastCheckRunId !== undefined) { + add('last_check_run_id', input.lastCheckRunId) + } + if (input.lastCheckTreeHash !== undefined) { + add('last_check_tree_hash', input.lastCheckTreeHash) + } + add('updated_at', new Date().toISOString()) + const result = await db + .prepare( + `UPDATE repo_sessions SET ${assignments.join(', ')} + WHERE id = ? AND user_id = ?`, + ) + .bind(...values, input.id, input.userId) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function deleteRepoSession( + db: D1Database, + id: string, +): Promise { + const result = await db + .prepare(`DELETE FROM repo_sessions WHERE id = ?`) + .bind(id) + .run() + return (result.meta.changes ?? 0) > 0 +} diff --git a/packages/worker/src/repo/source-service.ts b/packages/worker/src/repo/source-service.ts new file mode 100644 index 0000000000..a08e760441 --- /dev/null +++ b/packages/worker/src/repo/source-service.ts @@ -0,0 +1,112 @@ +import { + buildEntityRepoId, + getArtifactsBinding, + type ArtifactNamespaceBinding, +} from './artifacts.ts' +import { + getEntitySourceByEntity, + insertEntitySource, + updateEntitySource, +} from './entity-sources.ts' +import { type EntityKind, type EntitySourceRow } from './types.ts' + +function buildEntitySourceRow(input: { + id?: string + userId: string + entityKind: EntityKind + entityId: string + repoId?: string + publishedCommit?: string | null + indexedCommit?: string | null + manifestPath?: string + sourceRoot?: string + now?: string +}): EntitySourceRow { + const now = input.now ?? new Date().toISOString() + return { + id: input.id ?? crypto.randomUUID(), + user_id: input.userId, + entity_kind: input.entityKind, + entity_id: input.entityId, + repo_id: + input.repoId ?? + buildEntityRepoId({ + entityKind: input.entityKind, + entityId: input.entityId, + }), + published_commit: input.publishedCommit ?? null, + indexed_commit: input.indexedCommit ?? null, + manifest_path: input.manifestPath ?? 'kody.json', + source_root: input.sourceRoot ?? '/', + created_at: now, + updated_at: now, + } +} + +export async function ensureEntitySource(input: { + db: D1Database + env: Env + userId: string + entityKind: EntityKind + entityId: string + repoId?: string + manifestPath?: string + sourceRoot?: string +}) { + const existing = await getEntitySourceByEntity(input.db, { + userId: input.userId, + entityKind: input.entityKind, + entityId: input.entityId, + }) + if (existing) return existing + const row = buildEntitySourceRow({ + userId: input.userId, + entityKind: input.entityKind, + entityId: input.entityId, + repoId: input.repoId, + manifestPath: input.manifestPath, + sourceRoot: input.sourceRoot, + }) + await createArtifactsRepoIfMissing(input.env, row.repo_id) + await insertEntitySource(input.db, row) + return row +} + +export async function createArtifactsRepoIfMissing( + env: Env, + repoId: string, + binding: ArtifactNamespaceBinding = getArtifactsBinding(env), +) { + const existing = await binding.get(repoId) + if (existing.status === 'ready') return existing.repo + if (existing.status === 'importing' || existing.status === 'forking') { + throw new Error( + `Artifacts repo "${repoId}" is ${existing.status}. Retry after ${existing.retryAfter}s.`, + ) + } + const created = await binding.create(repoId, { readOnly: false }) + const getResult = await binding.get(created.name) + if (getResult.status !== 'ready') { + throw new Error( + `Artifacts repo "${created.name}" is ${getResult.status} after create.`, + ) + } + return getResult.repo +} + +export async function setEntityPublishedCommit(input: { + db: D1Database + userId: string + sourceId: string + publishedCommit: string | null + indexedCommit?: string | null +}) { + return updateEntitySource(input.db, { + id: input.sourceId, + userId: input.userId, + publishedCommit: input.publishedCommit, + ...(input.indexedCommit !== undefined + ? { indexedCommit: input.indexedCommit } + : {}), + }) +} diff --git a/packages/worker/src/repo/types.ts b/packages/worker/src/repo/types.ts new file mode 100644 index 0000000000..d1d6699e5b --- /dev/null +++ b/packages/worker/src/repo/types.ts @@ -0,0 +1,196 @@ +import { z } from 'zod' + +export const entityKindValues = ['skill', 'app', 'job'] as const +export type EntityKind = (typeof entityKindValues)[number] + +export const entitySourceRowSchema = z.object({ + id: z.string(), + user_id: z.string(), + entity_kind: z.enum(entityKindValues), + entity_id: z.string(), + repo_id: z.string(), + published_commit: z.string().nullable(), + indexed_commit: z.string().nullable(), + manifest_path: z.string(), + source_root: z.string(), + created_at: z.string(), + updated_at: z.string(), +}) + +export type EntitySourceRow = z.infer + +export const repoSessionStatusValues = [ + 'active', + 'published', + 'discarded', +] as const + +export type RepoSessionStatus = (typeof repoSessionStatusValues)[number] + +export const repoSessionRowSchema = z.object({ + id: z.string(), + user_id: z.string(), + source_id: z.string(), + session_repo_id: z.string(), + session_repo_name: z.string(), + session_repo_namespace: z.string(), + base_commit: z.string(), + source_root: z.string(), + conversation_id: z.string().nullable(), + status: z.enum(repoSessionStatusValues), + expires_at: z.string().nullable(), + last_checkpoint_at: z.string().nullable(), + last_checkpoint_commit: z.string().nullable(), + last_check_run_id: z.string().nullable(), + last_check_tree_hash: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +export type RepoSessionRow = z.infer + +export const repoContextSchema = z.object({ + sourceId: z.string().nullable().optional(), + repoId: z.string().nullable().optional(), + sessionId: z.string().nullable().optional(), + sessionRepoId: z.string().nullable().optional(), + baseCommit: z.string().nullable().optional(), + manifestPath: z.string().nullable().optional(), + sourceRoot: z.string().nullable().optional(), + publishedCommit: z.string().nullable().optional(), + entityKind: z.enum(entityKindValues).nullable().optional(), + entityId: z.string().nullable().optional(), +}) + +export type RepoContext = z.infer + +export const repoChecksSchema = z + .object({ + manifest: z.boolean().optional(), + dependencies: z.boolean().optional(), + bundle: z.boolean().optional(), + typecheck: z.boolean().optional(), + lint: z.boolean().optional(), + smoke: z + .array( + z.object({ + path: z.string().min(1), + method: z.string().min(1).optional(), + }), + ) + .optional(), + }) + .optional() + +const manifestParameterSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), + type: z.enum(['string', 'number', 'boolean', 'json']), + required: z.boolean().optional(), + default: z.unknown().optional(), +}) + +const manifestBaseSchema = z.object({ + version: z.literal(1), + kind: z.enum(entityKindValues), + title: z.string().min(1), + description: z.string().min(1), + keywords: z.array(z.string()).optional(), + searchText: z.string().optional(), + sourceRoot: z.string().optional(), + manifestPath: z.string().optional(), + parameters: z.array(manifestParameterSchema).optional(), + checks: repoChecksSchema, +}) + +export const skillManifestSchema = manifestBaseSchema.extend({ + kind: z.literal('skill'), + entrypoint: z.string().min(1), + collection: z.string().optional(), + readOnly: z.boolean().optional(), + idempotent: z.boolean().optional(), + destructive: z.boolean().optional(), + usesCapabilities: z.array(z.string()).optional(), +}) + +export const appManifestSchema = manifestBaseSchema.extend({ + kind: z.literal('app'), + server: z.string().min(1), + client: z.union([z.string().min(1), z.array(z.string().min(1))]).optional(), + assets: z.array(z.string().min(1)).optional(), + hidden: z.boolean().optional(), +}) + +export const jobManifestSchema = manifestBaseSchema.extend({ + kind: z.literal('job'), + entrypoint: z.string().min(1), +}) + +export const repoManifestSchema = z.discriminatedUnion('kind', [ + skillManifestSchema, + appManifestSchema, + jobManifestSchema, +]) + +export type SkillManifest = z.infer +export type AppManifest = z.infer +export type JobManifest = z.infer +export type RepoManifest = z.infer + +export type SearchProjection = { + title: string + description: string + keywords: Array + searchText: string | null +} + +export type RepoSearchMode = 'literal' | 'regex' +export type RepoSearchOutputMode = 'content' | 'files' + +export type RepoSearchMatch = { + line: number + column: number + match: string + lineText: string + beforeLines: Array + afterLines: Array +} + +export type RepoSearchFileMatch = { + path: string + matches: Array +} + +export type RepoSessionSearchResult = { + files: Array + totalFiles: number + totalMatches: number + outputMode: RepoSearchOutputMode + truncated: boolean +} + +export type RepoSessionInfo = { + id: string + sourceId: string + sourceRoot: string + baseCommit: string + sessionRepoId: string + sessionRepoName: string + sessionRepoNamespace: string + conversationId: string | null + lastCheckpointCommit: string | null + lastCheckRunId: string | null + lastCheckTreeHash: string | null + expiresAt: string | null + createdAt: string + updatedAt: string + publishedCommit: string | null + manifestPath: string + entityType: EntityKind +} + +export type RepoSessionDiscardResult = { + ok: true + sessionId: string + deleted: boolean +} diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index 44e7e30e04..affeea41c4 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -1,10 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: 2f0eab5e76d164e1f57d8337e767a2e6) -// Runtime types generated with workerd@1.20260410.1 2026-04-13 global_fetch_strictly_public,nodejs_compat +// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: dfd7385fe46a03cbe95b1a2ff69c703b) +// Runtime types generated with workerd@1.20260415.1 2026-04-13 global_fetch_strictly_public,nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); - durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "JobManager" | "AppRunner" | "StorageRunner"; + durableNamespaces: "MCP" | "ChatAgent" | "HomeConnectorSession" | "HomeMCP" | "AppRunner" | "JobManager" | "StorageRunner" | "AgentTurnRunner" | "RepoSession"; } interface Env { OAUTH_KV: KVNamespace; @@ -26,6 +26,8 @@ declare namespace Cloudflare { HOME_MCP_OBJECT: DurableObjectNamespace; JOB_MANAGER: DurableObjectNamespace; STORAGE_RUNNER: DurableObjectNamespace; + AGENT_TURN_RUNNER: DurableObjectNamespace; + REPO_SESSION: DurableObjectNamespace /* RepoSession */; APP_RUNNER: DurableObjectNamespace; } } @@ -459,6 +461,7 @@ interface ExecutionContext { passThroughOnException(): void; readonly exports: Cloudflare.Exports; readonly props: Props; + cache?: CacheContext; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -497,6 +500,23 @@ interface AlarmInvocationInfo { interface Cloudflare { readonly compatibilityFlags: Record; } +interface CachePurgeError { + code: number; + message: string; +} +interface CachePurgeResult { + success: boolean; + zoneTag: string; + errors: CachePurgeError[]; +} +interface CachePurgeOptions { + tags?: string[]; + pathPrefixes?: string[]; + purgeEverything?: boolean; +} +interface CacheContext { + purge(options: CachePurgeOptions): Promise; +} declare abstract class ColoLocalActorNamespace { get(actorId: string): Fetcher; } @@ -3395,6 +3415,7 @@ interface WorkerStub { } interface WorkerStubEntrypointOptions { props?: any; + limits?: workerdResourceLimits; } interface WorkerLoader { get(name: string | null, getCode: () => WorkerLoaderWorkerCode | Promise): WorkerStub; @@ -3413,6 +3434,7 @@ interface WorkerLoaderWorkerCode { compatibilityDate: string; compatibilityFlags?: string[]; allowExperimental?: boolean; + limits?: workerdResourceLimits; mainModule: string; modules: Record; env?: any; @@ -3420,6 +3442,10 @@ interface WorkerLoaderWorkerCode { tails?: Fetcher[]; streamingTails?: Fetcher[]; } +interface workerdResourceLimits { + cpuMs?: number; + subRequests?: number; +} /** * The Workers runtime supports a subset of the Performance API, used to measure timing and performance, * as well as timing of subrequests and other operations. @@ -9397,6 +9423,10 @@ declare abstract class Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; } +declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT { + inputs: ChatCompletionsInput; + postProcessedOutputs: ChatCompletionsOutput; +} interface AiModels { "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; @@ -9548,6 +9578,9 @@ interface InferenceUpstreamError extends Error { interface AiInternalError extends Error { } type AiModelListType = Record; +type AiAsyncBatchResponse = { + request_id: string; +}; declare abstract class Ai { aiGatewayLogId: string | null; gateway(gatewayId: string): AiGateway; @@ -9564,13 +9597,28 @@ declare abstract class Ai { * @param autoragId Instance ID */ autorag(autoragId: string): AutoRAG; - run(model: Name, inputs: InputOptions, options?: Options): Promise(model: Name, inputs: { + requests: AiModelList[Name]['inputs'][]; + }, options: AiOptions & { + queueRequest: true; + }): Promise; + // Raw response + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { returnRawResponse: true; - } | { + }): Promise; + // WebSocket + run(model: Name, inputs: AiModelList[Name]['inputs'], options: AiOptions & { websocket: true; - } ? Response : InputOptions extends { + }): Promise; + // Streaming + run(model: Name, inputs: AiModelList[Name]['inputs'] & { stream: true; - } ? ReadableStream : AiModelList[Name]["postProcessedOutputs"]>; + }, options?: AiOptions): Promise; + // Normal (default) - known model + run(model: Name, inputs: AiModelList[Name]['inputs'], options?: AiOptions): Promise; + // Unknown model (gateway fallback) + run(model: string & {}, inputs: Record, options?: AiOptions): Promise>; models(params?: AiModelsSearchParams): Promise; toMarkdown(): ToMarkdownService; toMarkdown(files: MarkdownDocument[], options?: ConversionRequestOptions): Promise; diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 348e98c513..427450f216 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -46,6 +46,10 @@ "new_sqlite_classes": ["AgentTurnRunner"], "tag": "v10", }, + { + "new_sqlite_classes": ["RepoSession"], + "tag": "v11", + }, ], "observability": { @@ -100,12 +104,22 @@ "class_name": "AgentTurnRunner", "name": "AGENT_TURN_RUNNER", }, + { + "class_name": "RepoSession", + "name": "REPO_SESSION", + }, { "class_name": "AppRunner", "name": "APP_RUNNER", }, ], }, + "artifacts": [ + { + "binding": "ARTIFACTS", + "namespace": "default", + }, + ], "d1_databases": [ { "binding": "APP_DB", @@ -178,12 +192,22 @@ "class_name": "AgentTurnRunner", "name": "AGENT_TURN_RUNNER", }, + { + "class_name": "RepoSession", + "name": "REPO_SESSION", + }, { "class_name": "AppRunner", "name": "APP_RUNNER", }, ], }, + "artifacts": [ + { + "binding": "ARTIFACTS", + "namespace": "default", + }, + ], "d1_databases": [ { "binding": "APP_DB", @@ -256,12 +280,22 @@ "class_name": "AgentTurnRunner", "name": "AGENT_TURN_RUNNER", }, + { + "class_name": "RepoSession", + "name": "REPO_SESSION", + }, { "class_name": "AppRunner", "name": "APP_RUNNER", }, ], }, + "artifacts": [ + { + "binding": "ARTIFACTS", + "namespace": "default", + }, + ], "d1_databases": [ { "binding": "APP_DB", From 0a4be2f2e80d803dd89ffcd6234e6d1e668d2857 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 16:54:15 +0000 Subject: [PATCH 02/42] Fix repo source foundation regressions --- packages/worker/src/jobs/repo.ts | 8 +- packages/worker/src/jobs/service.node.test.ts | 140 +++++++++--------- packages/worker/src/mcp/context.node.test.ts | 2 + ...auth-handlers-skill-runner.workers.test.ts | 1 + 4 files changed, 81 insertions(+), 70 deletions(-) diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts index 6cd2de0621..92de536aff 100644 --- a/packages/worker/src/jobs/repo.ts +++ b/packages/worker/src/jobs/repo.ts @@ -70,6 +70,10 @@ function parseJson(value: string | null, fallback: T): T { } function mapRow(row: Record): JobRow { + const rawStorageId = row['storage_id'] + if (rawStorageId == null) { + throw new Error('Job row is missing storage_id.') + } const record: JobRecord = { version: 1, id: String(row['id']), @@ -81,7 +85,7 @@ function mapRow(row: Record): JobRow { row['published_commit'] == null ? undefined : String(row['published_commit']), - storageId: String(row['storage_id']), + storageId: String(rawStorageId), params: parseJson | undefined>( row['params_json'] == null ? null : String(row['params_json']), undefined, @@ -123,7 +127,7 @@ function mapRow(row: Record): JobRow { code: record.code, source_id: record.sourceId ?? null, published_commit: record.publishedCommit ?? null, - storage_id: record.storageId ?? null, + storage_id: record.storageId, params_json: row['params_json'] == null ? null : String(row['params_json']), schedule_json: String(row['schedule_json']), timezone: record.timezone, diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index c10425d5d2..2a2c1d9f47 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -294,24 +294,26 @@ function createDatabase() { user_id: params[1], name: params[2], code: params[3], - storage_id: params[4], - params_json: params[5], - schedule_json: params[6], - timezone: params[7], - enabled: params[8], - kill_switch_enabled: params[9], - caller_context_json: params[10], - created_at: params[11], - updated_at: params[12], - last_run_at: params[13], - last_run_status: params[14], - last_run_error: params[15], - last_duration_ms: params[16], - next_run_at: params[17], - run_count: params[18], - success_count: params[19], - error_count: params[20], - run_history_json: params[21], + source_id: params[4], + published_commit: params[5], + storage_id: params[6], + params_json: params[7], + schedule_json: params[8], + timezone: params[9], + enabled: params[10], + kill_switch_enabled: params[11], + caller_context_json: params[12], + created_at: params[13], + updated_at: params[14], + last_run_at: params[15], + last_run_status: params[16], + last_run_error: params[17], + last_duration_ms: params[18], + next_run_at: params[19], + run_count: params[20], + success_count: params[21], + error_count: params[22], + run_history_json: params[23], } upsert( 'jobs', @@ -324,34 +326,36 @@ function createDatabase() { } if (query.startsWith('UPDATE jobs SET')) { const row = { - id: params[19], - user_id: params[20], + id: params[21], + user_id: params[22], name: params[0], code: params[1], - storage_id: params[2], - params_json: params[3], - schedule_json: params[4], - timezone: params[5], - enabled: params[6], - kill_switch_enabled: params[7], - caller_context_json: params[8], - updated_at: params[9], - last_run_at: params[10], - last_run_status: params[11], - last_run_error: params[12], - last_duration_ms: params[13], - next_run_at: params[14], - run_count: params[15], - success_count: params[16], - error_count: params[17], - run_history_json: params[18], + source_id: params[2], + published_commit: params[3], + storage_id: params[4], + params_json: params[5], + schedule_json: params[6], + timezone: params[7], + enabled: params[8], + kill_switch_enabled: params[9], + caller_context_json: params[10], + updated_at: params[11], + last_run_at: params[12], + last_run_status: params[13], + last_run_error: params[14], + last_duration_ms: params[15], + next_run_at: params[16], + run_count: params[17], + success_count: params[18], + error_count: params[19], + run_history_json: params[20], created_at: selectOne( 'jobs', (existing) => - existing['id'] === params[19] && - existing['user_id'] === params[20], - )?.['created_at'] ?? params[9], + existing['id'] === params[21] && + existing['user_id'] === params[22], + )?.['created_at'] ?? params[11], } upsert( 'jobs', @@ -504,12 +508,6 @@ test('executeJobOnce binds scheduled jobs to writable storage', async () => { }, }) - const createdJob = (await import('./repo.ts')).getJobRowById( - db, - callerContext.user.userId, - jobView.id, - ) - const executeSpy = vi .spyOn( await import('#mcp/run-codemode-registry.ts'), @@ -523,10 +521,13 @@ test('executeJobOnce binds scheduled jobs to writable storage', async () => { }) try { - const row = await createdJob + const row = await ( + await import('./repo.ts') + ).getJobRowById(db, callerContext.user.userId, jobView.id) if (!row) { throw new Error('Expected created job row.') } + expect(row.record.storageId).toBe(`job:${jobView.id}`) const outcome = await executeJobOnce({ env, job: row.record, @@ -587,36 +588,25 @@ test('executeJobOnce preserves codemode secret and value semantics', async () => }, }) - const createdJob = (await import('./repo.ts')).getJobRowById( - db, - callerContext.user.userId, - jobView.id, - ) - const executeSpy = vi .spyOn( await import('#mcp/run-codemode-registry.ts'), 'runCodemodeWithRegistry', ) - .mockImplementation(async (_env, persistedContext) => { - expect(persistedContext.storageContext).toEqual({ - sessionId: null, - appId: 'app-123', + .mockResolvedValue({ + result: { + secretValue: 'very-secret-token', + value: 'alpha-project', + userId: 'user-123', storageId: `job:${jobView.id}`, - }) - return { - result: { - secretValue: 'very-secret-token', - value: 'alpha-project', - userId: 'user-123', - storageId: `job:${jobView.id}`, - }, - logs: ['codemode executed'], - } + }, + logs: ['codemode executed'], }) try { - const row = await createdJob + const row = await ( + await import('./repo.ts') + ).getJobRowById(db, callerContext.user.userId, jobView.id) if (!row) { throw new Error('Expected created job row.') } @@ -626,6 +616,19 @@ test('executeJobOnce preserves codemode secret and value semantics', async () => callerContext, }) + const [spyEnv, spyCallerContext] = executeSpy.mock.calls[0] ?? [] + expect(spyEnv).toBe(env) + expect(spyCallerContext).toMatchObject({ + baseUrl: 'https://example.com', + repoContext: null, + }) + expect(spyCallerContext).toHaveProperty('storageContext.sessionId', null) + expect(spyCallerContext).toHaveProperty('storageContext.appId', 'app-123') + expect(spyCallerContext).toHaveProperty( + 'storageContext.storageId', + `job:${jobView.id}`, + ) + expect(outcome.execution).toEqual({ ok: true, result: { @@ -653,6 +656,7 @@ test('executeJobOnce returns an error when codemode secret policy would reject e userId: callerContext.user.userId, name: 'Forbidden secret access', code: 'async () => null', + storageId: 'job:job-1', schedule: { type: 'once', runAt: '2026-04-17T15:00:00Z', diff --git a/packages/worker/src/mcp/context.node.test.ts b/packages/worker/src/mcp/context.node.test.ts index 0ffded03fa..1185af4c58 100644 --- a/packages/worker/src/mcp/context.node.test.ts +++ b/packages/worker/src/mcp/context.node.test.ts @@ -10,6 +10,7 @@ test('createMcpCallerContext normalizes missing user to null', () => { baseUrl: 'https://example.com', homeConnectorId: null, remoteConnectors: null, + repoContext: null, storageContext: null, user: null, }) @@ -42,4 +43,5 @@ test('parseMcpCallerContext validates caller context shape', () => { }) expect(parsed.homeConnectorId ?? null).toBeNull() expect(parsed.remoteConnectors ?? null).toBeNull() + expect(parsed.repoContext ?? null).toBeNull() }) diff --git a/packages/worker/src/oauth-handlers-skill-runner.workers.test.ts b/packages/worker/src/oauth-handlers-skill-runner.workers.test.ts index 0a59d8bad2..340b00d098 100644 --- a/packages/worker/src/oauth-handlers-skill-runner.workers.test.ts +++ b/packages/worker/src/oauth-handlers-skill-runner.workers.test.ts @@ -148,6 +148,7 @@ test('api skill runner returns successful saved skill results and marks usage', }, homeConnectorId: null, remoteConnectors: null, + repoContext: null, storageContext: null, }, name: 'discord-event-handler', From 2324b0bc6cf0a10e9a7e15d0a4c93c7534cbc2b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 17:05:45 +0000 Subject: [PATCH 03/42] Fix repo session runtime and test regressions --- packages/worker/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 025ffd8e48..9d848ada84 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,6 +8,7 @@ import { AppFacetBridge, AppRunner } from './mcp/app-runner.ts' import { JobManager } from './jobs/manager-do.ts' import { StorageRunner } from './storage-runner.ts' import { AgentTurnRunner } from './agent-turn/runner-do.ts' +import { RepoSession } from './repo/repo-session-do.ts' import { chatAgentBasePath } from '@kody-internal/shared/chat-routes.ts' import { getWorkerSentryOptions } from './sentry-options.ts' import { handleRequest } from '#app/handler.ts' @@ -46,6 +47,7 @@ import { export { ChatAgent, AgentTurnRunner, + RepoSession, CodemodeFetchGateway, HomeConnectorSession, HomeMCP, From e1969337ff980a31853659c7a39d9ee5aece4544 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 17:48:15 +0000 Subject: [PATCH 04/42] Expand repo session editing surface --- .../src/mcp/capabilities/repo/domain.ts | 4 + .../worker/src/mcp/capabilities/repo/index.ts | 8 + .../mcp/capabilities/repo/repo-apply-patch.ts | 117 ++++++++ .../src/mcp/capabilities/repo/repo-shared.ts | 92 +++++++ .../src/mcp/capabilities/repo/repo-tree.ts | 121 +++++++++ packages/worker/src/mcp/executor.ts | 2 + .../worker/src/mcp/run-codemode-registry.ts | 2 + packages/worker/src/repo/repo-session-do.ts | 253 +++++++++++++++++- packages/worker/worker-configuration.d.ts | 4 +- packages/worker/wrangler.jsonc | 24 +- 10 files changed, 604 insertions(+), 23 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-tree.ts diff --git a/packages/worker/src/mcp/capabilities/repo/domain.ts b/packages/worker/src/mcp/capabilities/repo/domain.ts index 5a80c54f29..585bc390c4 100644 --- a/packages/worker/src/mcp/capabilities/repo/domain.ts +++ b/packages/worker/src/mcp/capabilities/repo/domain.ts @@ -1,10 +1,12 @@ import { defineDomain } from '#mcp/capabilities/define-domain.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { repoApplyPatchCapability } from './repo-apply-patch.ts' import { repoDiscardSessionCapability } from './repo-discard-session.ts' import { repoGetSessionCapability } from './repo-get-session.ts' import { repoOpenSessionCapability } from './repo-open-session.ts' import { repoReadFileCapability } from './repo-read-file.ts' import { repoSearchCapability } from './repo-search.ts' +import { repoTreeCapability } from './repo-tree.ts' import { repoWriteFileCapability } from './repo-write-file.ts' export const repoDomain = defineDomain({ @@ -15,8 +17,10 @@ export const repoDomain = defineDomain({ capabilities: [ repoOpenSessionCapability, repoGetSessionCapability, + repoTreeCapability, repoReadFileCapability, repoWriteFileCapability, + repoApplyPatchCapability, repoSearchCapability, repoDiscardSessionCapability, ], diff --git a/packages/worker/src/mcp/capabilities/repo/index.ts b/packages/worker/src/mcp/capabilities/repo/index.ts index ff79fa2acc..fb7c30a75b 100644 --- a/packages/worker/src/mcp/capabilities/repo/index.ts +++ b/packages/worker/src/mcp/capabilities/repo/index.ts @@ -1,5 +1,13 @@ import { repoDomain } from './domain.ts' export { repoDomain } from './domain.ts' +export { repoApplyPatchCapability } from './repo-apply-patch.ts' +export { repoDiscardSessionCapability } from './repo-discard-session.ts' +export { repoGetSessionCapability } from './repo-get-session.ts' +export { repoOpenSessionCapability } from './repo-open-session.ts' +export { repoReadFileCapability } from './repo-read-file.ts' +export { repoSearchCapability } from './repo-search.ts' +export { repoTreeCapability } from './repo-tree.ts' +export { repoWriteFileCapability } from './repo-write-file.ts' export const repoCapabilities = repoDomain.capabilities diff --git a/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts new file mode 100644 index 0000000000..c172a90177 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts @@ -0,0 +1,117 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' + +const repoPatchSearchOptionsSchema = z.object({ + case_sensitive: z.boolean().optional(), + regex: z.boolean().optional(), + whole_word: z.boolean().optional(), + context_before: z.number().int().min(0).optional(), + context_after: z.number().int().min(0).optional(), + max_matches: z.number().int().min(1).optional(), +}) + +const repoPatchInstructionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('write'), + path: z.string().min(1), + content: z.string(), + }), + z.object({ + kind: z.literal('replace'), + path: z.string().min(1), + search: z.string().min(1), + replacement: z.string(), + options: repoPatchSearchOptionsSchema.optional(), + }), + z.object({ + kind: z.literal('write_json'), + path: z.string().min(1), + value: z.unknown(), + spaces: z.number().int().min(0).optional(), + }), +]) + +const inputSchema = z.object({ + session_id: z.string().min(1), + instructions: z.array(repoPatchInstructionSchema).min(1), + dry_run: z.boolean().optional(), + rollback_on_error: z.boolean().optional(), +}) + +const outputSchema = z.object({ + dry_run: z.boolean(), + edits: z.array( + z.object({ + path: z.string(), + changed: z.boolean(), + content: z.string(), + diff: z.string(), + }), + ), + total_changed: z.number().int().min(0), +}) + +export const repoApplyPatchCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_apply_patch', + description: + 'Apply one or more structured file edits transactionally inside an active repo session.', + keywords: ['repo', 'patch', 'edit', 'replace', 'write', 'transactional'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const result = await repoSessionRpc(ctx.env, args.session_id).applyEdits({ + sessionId: args.session_id, + edits: args.instructions.map((instruction) => { + switch (instruction.kind) { + case 'write': + return instruction + case 'replace': + return { + kind: 'replace' as const, + path: instruction.path, + search: instruction.search, + replacement: instruction.replacement, + options: + instruction.options == null + ? undefined + : { + caseSensitive: instruction.options.case_sensitive, + regex: instruction.options.regex, + wholeWord: instruction.options.whole_word, + contextBefore: instruction.options.context_before, + contextAfter: instruction.options.context_after, + maxMatches: instruction.options.max_matches, + }, + } + case 'write_json': + return { + kind: 'writeJson' as const, + path: instruction.path, + value: instruction.value, + options: + instruction.spaces == null + ? undefined + : { spaces: instruction.spaces }, + } + } + }), + dryRun: args.dry_run, + rollbackOnError: args.rollback_on_error, + }) + return { + dry_run: result.dryRun, + edits: result.edits, + total_changed: result.totalChanged, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts index c090c62ddd..03f8a2eadd 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts @@ -73,6 +73,34 @@ export const repoReadFileOutputSchema = z.object({ content: z.string().nullable(), }) +export const repoTreeInputSchema = repoSessionIdSchema.extend({ + path: z + .string() + .min(1) + .optional() + .describe( + 'Optional repo-relative directory path. Defaults to the repo session source root.', + ), + max_depth: z + .number() + .int() + .min(0) + .optional() + .describe('Optional maximum tree depth to include in the result.'), +}) + +export const repoTreeNodeSchema: z.ZodType = z.lazy(() => + z.object({ + path: z.string(), + name: z.string(), + type: z.enum(['file', 'directory', 'symlink']), + size: z.number().int().min(0), + children: z.array(repoTreeNodeSchema).optional(), + }), +) + +export const repoTreeOutputSchema = repoTreeNodeSchema + export const repoWriteFileInputSchema = repoSessionIdSchema.extend({ path: z.string().min(1).describe('Repo-relative file path to write.'), content: z @@ -156,3 +184,67 @@ export const repoDiscardSessionOutputSchema = z.object({ }) export const repoDiscardSessionInputSchema = repoSessionIdSchema + +const repoPatchSearchOptionsSchema = z.object({ + case_sensitive: z.boolean().optional(), + regex: z.boolean().optional(), + whole_word: z.boolean().optional(), + context_before: z.number().int().min(0).optional(), + context_after: z.number().int().min(0).optional(), + max_matches: z.number().int().min(1).optional(), +}) + +export const repoPatchInstructionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('write'), + path: z.string().min(1), + content: z.string(), + }), + z.object({ + kind: z.literal('replace'), + path: z.string().min(1), + search: z.string().min(1), + replacement: z.string(), + options: repoPatchSearchOptionsSchema.optional(), + }), + z.object({ + kind: z.literal('writeJson'), + path: z.string().min(1), + value: z.unknown(), + options: z + .object({ + spaces: z.number().int().min(0).optional(), + }) + .optional(), + }), +]) + +export const repoApplyPatchInputSchema = repoSessionIdSchema.extend({ + instructions: z + .array(repoPatchInstructionSchema) + .min(1) + .describe('Ordered structured edit instructions to apply transactionally.'), + dry_run: z + .boolean() + .optional() + .describe('Preview the edit plan without mutating the session workspace.'), + rollback_on_error: z + .boolean() + .optional() + .describe( + 'Whether to roll back all edits when one instruction fails. Defaults to true.', + ), +}) + +export const repoApplyPatchResultSchema = z.object({ + dry_run: z.boolean(), + total_changed: z.number().int().min(0), + edits: z.array( + z.object({ + path: z.string(), + changed: z.boolean(), + content: z.string(), + diff: z.string(), + }), + ), +}) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-tree.ts b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts new file mode 100644 index 0000000000..ec175f0e7f --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { repoSessionIdSchema } from './repo-shared.ts' + +const inputSchema = repoSessionIdSchema.extend({ + path: z + .string() + .min(1) + .optional() + .describe( + 'Optional repo-relative subpath to summarize. Defaults to the session source root.', + ), + max_depth: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Optional maximum directory depth to include in the summary tree.', + ), +}) + +const outputSchema = z.object({ + path: z.string(), + files: z.number().int().min(0), + directories: z.number().int().min(0), + symlinks: z.number().int().min(0), + total_bytes: z.number().int().min(0), + max_depth: z.number().int().min(0), +}) + +export const repoTreeCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_tree', + description: + 'Summarize a repo session subtree so the model can understand file layout before reading or editing specific files.', + keywords: ['repo', 'tree', 'workspace', 'files', 'directories', 'summary'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const result = await repoSessionRpc(ctx.env, args.session_id).tree({ + sessionId: args.session_id, + path: args.path ?? null, + maxDepth: args.max_depth, + }) + const summary = summarizeTree(result) + return summary + }, + }, +) + +function summarizeTree(node: { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array +}) { + const totals = { + files: 0, + directories: 0, + symlinks: 0, + total_bytes: 0, + max_depth: 0, + } + + function visit( + current: { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array + }, + depth: number, + ) { + totals.max_depth = Math.max(totals.max_depth, depth) + totals.total_bytes += current.size + switch (current.type) { + case 'file': + totals.files += 1 + break + case 'directory': + totals.directories += 1 + break + case 'symlink': + totals.symlinks += 1 + break + } + for (const child of current.children ?? []) { + if (!child || typeof child !== 'object') continue + const next = child as { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array + } + visit(next, depth + 1) + } + } + + visit(node, 0) + + return { + path: node.path, + files: totals.files, + directories: totals.directories, + symlinks: totals.symlinks, + total_bytes: totals.total_bytes, + max_depth: totals.max_depth, + } +} diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts index 28a0347ee2..e87540d4d6 100644 --- a/packages/worker/src/mcp/executor.ts +++ b/packages/worker/src/mcp/executor.ts @@ -20,6 +20,7 @@ export function createExecuteExecutor(input: { env: Env exports?: WorkerLoopbackExports gatewayProps: FetchGatewayProps + modules?: Record }) { const loopbackExports = input.exports ?? workerExports if (!loopbackExports?.CodemodeFetchGateway) { @@ -33,6 +34,7 @@ export function createExecuteExecutor(input: { globalOutbound: loopbackExports.CodemodeFetchGateway({ props: input.gatewayProps, }), + modules: input.modules, }) } diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index 5b2ecc43a8..48cdd9ed91 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -195,6 +195,7 @@ export async function runCodemodeWithRegistry( additionalTools?: AdditionalCodemodeTools helperPrelude?: string storageTools?: StorageToolOptions + executorModules?: Record }, ): Promise { const { createExecuteExecutor } = await import('#mcp/executor.ts') @@ -211,6 +212,7 @@ export async function runCodemodeWithRegistry( userId: callerContext.user?.userId ?? null, storageContext: normalizedStorageContext, }, + modules: options?.executorModules, }) const provider = await buildCodemodeProvider(env, callerContext, { trackSecretInputValue: (value) => { diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index 7f8dab8619..c32ee6e10a 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/cloudflare' import { DurableObject } from 'cloudflare:workers' -import { Workspace, WorkspaceFileSystem } from '@cloudflare/shell' +import { + Workspace, + WorkspaceFileSystem, + createWorkspaceStateBackend, +} from '@cloudflare/shell' import { createGit } from '@cloudflare/shell/git' import { deleteRepoSession, @@ -41,6 +45,36 @@ export type RepoSessionSearchResult = { truncated: boolean } +export type RepoApplyPatchResult = { + dryRun: boolean + totalChanged: number + edits: Array<{ + path: string + changed: boolean + content: string + diff: string + }> +} + +export type RepoSessionTreeResult = { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array +} + +export type RepoSessionApplyEditsResult = { + dryRun: boolean + totalChanged: number + edits: Array<{ + path: string + changed: boolean + content: string + diff: string + }> +} + export type RepoSessionDiscardResult = { ok: true sessionId: string @@ -118,6 +152,8 @@ class RepoSessionBase extends DurableObject { readonly fileSystem = new WorkspaceFileSystem(this.workspace) + readonly state = createWorkspaceStateBackend(this.workspace) + readonly git = createGit(this.fileSystem, repoSessionWorkspacePrefix) private initializedSessionId: string | null = null @@ -305,14 +341,15 @@ class RepoSessionBase extends DurableObject { path: string content: string }): Promise<{ ok: true; path: string }> { - await this.getSessionInfo({ sessionId: input.sessionId }) + const session = await this.getSessionInfo({ sessionId: input.sessionId }) await this.workspace.writeFile( this.resolveWorkspacePath(input.path), input.content, ) await updateRepoSession(this.env.APP_DB, { id: input.sessionId, - userId: (await this.getSessionInfo({ sessionId: input.sessionId })).id, + userId: session.source_id, + lastCheckpointAt: nowIso(), }) return { ok: true, path: input.path } } @@ -386,6 +423,139 @@ class RepoSessionBase extends DurableObject { } } + async applyPatch(input: { + sessionId: string + edits: Array< + | { + kind: 'write' + path: string + content: string + } + | { + kind: 'replace' + path: string + search: string + replacement: string + options?: { + caseSensitive?: boolean + regex?: boolean + wholeWord?: boolean + contextBefore?: number + contextAfter?: number + maxMatches?: number + } + } + | { + kind: 'writeJson' + path: string + value: unknown + options?: { + spaces?: number + } + } + > + dryRun?: boolean + rollbackOnError?: boolean + }): Promise { + return this.applyEdits(input) + } + + async tree(input: { + sessionId: string + path?: string | null + maxDepth?: number + }): Promise { + await this.getSessionInfo({ sessionId: input.sessionId }) + const root = this.resolveWorkspacePath( + input.path?.trim() || repoSessionWorkspacePrefix, + ) + const tree = await this.state.walkTree(root, { + maxDepth: input.maxDepth, + }) + return this.toTreeResult(tree) + } + + async applyEdits(input: { + sessionId: string + edits: Array<{ + kind: 'write' | 'replace' | 'writeJson' + path: string + content?: string + search?: string + replacement?: string + value?: unknown + options?: { + caseSensitive?: boolean + regex?: boolean + wholeWord?: boolean + contextBefore?: number + contextAfter?: number + maxMatches?: number + spaces?: number + } + }> + dryRun?: boolean + rollbackOnError?: boolean + }): Promise { + const session = await this.getSessionInfo({ sessionId: input.sessionId }) + const plan = await this.state.planEdits( + input.edits.map((edit) => { + const path = this.resolveWorkspacePath(edit.path) + switch (edit.kind) { + case 'write': + if (typeof edit.content !== 'string') { + throw new Error('repo_apply_patch write edits require content.') + } + return { + kind: 'write' as const, + path, + content: edit.content, + } + case 'replace': + if (typeof edit.search !== 'string') { + throw new Error('repo_apply_patch replace edits require search.') + } + return { + kind: 'replace' as const, + path, + search: edit.search, + replacement: edit.replacement ?? '', + options: edit.options, + } + case 'writeJson': + return { + kind: 'writeJson' as const, + path, + value: edit.value, + options: + typeof edit.options?.spaces === 'number' + ? { spaces: edit.options.spaces } + : undefined, + } + } + }), + ) + const result = await this.state.applyEditPlan(plan, { + dryRun: input.dryRun, + rollbackOnError: input.rollbackOnError, + }) + await updateRepoSession(this.env.APP_DB, { + id: input.sessionId, + userId: session.source_id, + lastCheckpointAt: nowIso(), + }) + return { + dryRun: result.dryRun, + totalChanged: result.totalChanged, + edits: result.edits.map((edit) => ({ + path: this.toExternalPath(edit.path), + changed: edit.changed, + content: edit.content, + diff: edit.diff, + })), + } + } + private resolveWorkspacePath(path: string) { const trimmed = path.trim() if (!trimmed) { @@ -403,6 +573,56 @@ class RepoSessionBase extends DurableObject { : path } + private normalizeUnknownTreeChild( + child: unknown, + parentPath: string, + ): { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array + } { + const input = + child && typeof child === 'object' + ? (child as Record) + : ({} as Record) + return { + path: + typeof input.path === 'string' ? input.path : `${parentPath}/unknown`, + name: typeof input.name === 'string' ? input.name : 'unknown', + type: + input.type === 'file' || + input.type === 'directory' || + input.type === 'symlink' + ? input.type + : 'file', + size: typeof input.size === 'number' ? input.size : 0, + children: Array.isArray(input.children) + ? (input.children as Array) + : undefined, + } + } + + private toTreeResult(node: { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array + }): RepoSessionTreeResult { + return { + path: this.toExternalPath(node.path), + name: node.name, + type: node.type, + size: node.size, + children: node.children?.map( + (child): RepoSessionTreeResult => + this.toTreeResult(this.normalizeUnknownTreeChild(child, node.path)), + ), + } + } + private toSessionInfo(session: RepoSessionRow, source: EntitySourceRow) { return { id: session.id, @@ -509,6 +729,33 @@ export function repoSessionRpc(env: Env, sessionId: string) { limit?: number outputMode?: RepoSearchOutputMode }) => Promise + tree: (payload: { + sessionId: string + path?: string | null + maxDepth?: number + }) => Promise + applyEdits: (payload: { + sessionId: string + edits: Array<{ + kind: 'write' | 'replace' | 'writeJson' + path: string + content?: string + search?: string + replacement?: string + value?: unknown + options?: { + caseSensitive?: boolean + regex?: boolean + wholeWord?: boolean + contextBefore?: number + contextAfter?: number + maxMatches?: number + spaces?: number + } + }> + dryRun?: boolean + rollbackOnError?: boolean + }) => Promise } } diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index affeea41c4..919ddcf77d 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: dfd7385fe46a03cbe95b1a2ff69c703b) +// Generated by Wrangler by running `wrangler types ./packages/worker/worker-configuration.d.ts --config packages/worker/wrangler.jsonc --env production` (hash: b2114a21543e2157af9f1648c525aa5f) // Runtime types generated with workerd@1.20260415.1 2026-04-13 global_fetch_strictly_public,nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -27,7 +27,7 @@ declare namespace Cloudflare { JOB_MANAGER: DurableObjectNamespace; STORAGE_RUNNER: DurableObjectNamespace; AGENT_TURN_RUNNER: DurableObjectNamespace; - REPO_SESSION: DurableObjectNamespace /* RepoSession */; + REPO_SESSION: DurableObjectNamespace; APP_RUNNER: DurableObjectNamespace; } } diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 427450f216..da3a6f11c9 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -5,6 +5,12 @@ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "main": "./src/index.ts", "upload_source_maps": true, + "artifacts": [ + { + "binding": "ARTIFACTS", + "namespace": "default", + }, + ], "migrations": [ { "new_sqlite_classes": ["MCP"], @@ -114,12 +120,6 @@ }, ], }, - "artifacts": [ - { - "binding": "ARTIFACTS", - "namespace": "default", - }, - ], "d1_databases": [ { "binding": "APP_DB", @@ -202,12 +202,6 @@ }, ], }, - "artifacts": [ - { - "binding": "ARTIFACTS", - "namespace": "default", - }, - ], "d1_databases": [ { "binding": "APP_DB", @@ -290,12 +284,6 @@ }, ], }, - "artifacts": [ - { - "binding": "ARTIFACTS", - "namespace": "default", - }, - ], "d1_databases": [ { "binding": "APP_DB", From 6c51427fb4b8c8de9d4c1a1d6f5d7cfaaef23dcd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 18:17:43 +0000 Subject: [PATCH 05/42] Add repo checks lifecycle and start job cutover --- packages/worker/src/jobs/repo.ts | 6 +- packages/worker/src/jobs/service.ts | 151 +++++- packages/worker/src/jobs/types.ts | 16 +- .../src/mcp/capabilities/jobs/shared.ts | 29 +- .../src/mcp/capabilities/repo/domain.ts | 2 + .../worker/src/mcp/capabilities/repo/index.ts | 4 + .../repo/repo-get-check-status.ts | 44 ++ .../capabilities/repo/repo-publish-session.ts | 73 +++ .../capabilities/repo/repo-rebase-session.ts | 45 ++ .../mcp/capabilities/repo/repo-run-checks.ts | 38 ++ .../src/mcp/capabilities/repo/repo-shared.ts | 34 ++ packages/worker/src/repo/checks.ts | 164 +++++++ packages/worker/src/repo/repo-session-do.ts | 446 ++++++++++++++++-- 13 files changed, 975 insertions(+), 77 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts create mode 100644 packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts create mode 100644 packages/worker/src/repo/checks.ts diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts index 92de536aff..aeb11a8aa6 100644 --- a/packages/worker/src/jobs/repo.ts +++ b/packages/worker/src/jobs/repo.ts @@ -80,11 +80,9 @@ function mapRow(row: Record): JobRow { userId: String(row['user_id']), name: String(row['name']), code: String(row['code']), - sourceId: row['source_id'] == null ? undefined : String(row['source_id']), + sourceId: row['source_id'] == null ? null : String(row['source_id']), publishedCommit: - row['published_commit'] == null - ? undefined - : String(row['published_commit']), + row['published_commit'] == null ? null : String(row['published_commit']), storageId: String(rawStorageId), params: parseJson | undefined>( row['params_json'] == null ? null : String(row['params_json']), diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 3ef2499ea4..52e21d1434 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -1,5 +1,6 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { parseMcpCallerContext } from '#mcp/context.ts' +import { type ExecuteResult } from '@cloudflare/codemode' import { exports as workerExports } from 'cloudflare:workers' import { applyExecutionOutcome, processDueJobs } from './process-due-jobs.ts' import { @@ -27,6 +28,15 @@ import { type PersistedJobCallerContext, } from './types.ts' import { createJobStorageId } from '#worker/storage-runner.ts' +import { ensureEntitySource } from '#worker/repo/source-service.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' + +function hasRepoBackedJobSource(input: { + sourceId?: string | null + publishedCommit?: string | null +}) { + return typeof input.sourceId === 'string' && input.sourceId.length > 0 +} function requirePersistableJobCallerContext( callerContext: McpCallerContext, @@ -65,8 +75,17 @@ function normalizeOptionalParams( } function resolveCreateShape(input: JobCreateInput) { + if (hasRepoBackedJobSource(input)) { + return { + code: input.code == null ? null : normalizeJobCode(input.code), + sourceId: input.sourceId ?? null, + publishedCommit: input.publishedCommit ?? null, + } + } return { - code: normalizeJobCode(input.code), + code: normalizeJobCode(input.code ?? ''), + sourceId: null, + publishedCommit: null, } } @@ -80,11 +99,21 @@ function resolveUpdatedShape(input: { : input.body.code === null ? undefined : normalizeJobCode(input.body.code) - if (!nextCode) { + const nextSourceId = + input.body.sourceId === undefined + ? input.existing.sourceId + : input.body.sourceId + const nextPublishedCommit = + input.body.publishedCommit === undefined + ? input.existing.publishedCommit + : input.body.publishedCommit + if (!nextCode && !nextSourceId) { throw new Error('Jobs require code.') } return { code: nextCode, + sourceId: nextSourceId ?? null, + publishedCommit: nextPublishedCommit ?? null, } } @@ -99,12 +128,24 @@ export async function createJob(input: { const now = new Date().toISOString() const shape = resolveCreateShape(input.body) const jobId = crypto.randomUUID() + const ensuredSource = shape.sourceId + ? await ensureEntitySource({ + db: input.env.APP_DB, + env: input.env, + userId: callerContext.user.userId, + entityKind: 'job', + entityId: jobId, + sourceRoot: '/', + }) + : null const job: JobRecord = { version: 1, id: jobId, userId: callerContext.user.userId, name: normalizeJobName(input.body.name), code: shape.code, + sourceId: ensuredSource?.id ?? shape.sourceId ?? null, + publishedCommit: shape.publishedCommit ?? null, storageId: createJobStorageId(jobId), params: normalizeOptionalParams(input.body.params), schedule, @@ -181,13 +222,26 @@ export async function updateJob(input: { existing, body: input.body, }) + const ensuredSource = + shape.sourceId && !existing.sourceId + ? await ensureEntitySource({ + db: input.env.APP_DB, + env: input.env, + userId: callerContext.user.userId, + entityKind: 'job', + entityId: existing.id, + sourceRoot: '/', + }) + : null const updated: JobRecord = { ...existing, name: input.body.name === undefined ? existing.name : normalizeJobName(input.body.name), - code: shape.code, + code: shape.code ?? null, + sourceId: ensuredSource?.id ?? shape.sourceId ?? null, + publishedCommit: shape.publishedCommit ?? null, params: input.body.params === undefined ? existing.params @@ -249,27 +303,34 @@ export async function executeJobOnce(input: { } else { const { runCodemodeWithRegistry } = await import('#mcp/run-codemode-registry.ts') - const result = await runCodemodeWithRegistry( - input.env, - { - ...input.callerContext, - storageContext: { - sessionId: input.callerContext.storageContext?.sessionId ?? null, - appId: input.callerContext.storageContext?.appId ?? null, - storageId: input.job.storageId, - }, + const runtimeCallerContext = { + ...input.callerContext, + storageContext: { + sessionId: input.callerContext.storageContext?.sessionId ?? null, + appId: input.callerContext.storageContext?.appId ?? null, + storageId: input.job.storageId, }, - input.job.code, - input.job.params, - { - executorExports: workerExports, - storageTools: { - userId: input.callerContext.user.userId, - storageId: input.job.storageId, - writable: true, - }, - }, - ) + } + const result = input.job.sourceId + ? await runRepoBackedJob({ + env: input.env, + job: input.job, + callerContext: runtimeCallerContext, + }) + : await runCodemodeWithRegistry( + input.env, + runtimeCallerContext, + input.job.code ?? '', + input.job.params, + { + executorExports: workerExports, + storageTools: { + userId: input.callerContext.user.userId, + storageId: input.job.storageId, + writable: true, + }, + }, + ) execution = result.error ? { ok: false, @@ -298,6 +359,50 @@ export async function executeJobOnce(input: { } } +async function runRepoBackedJob(input: { + env: Env + job: JobRecord + callerContext: PersistedJobCallerContext +}): Promise { + const sessionId = `job-runtime-${input.job.id}` + const session = await repoSessionRpc(input.env, sessionId).openSession({ + sessionId, + sourceId: input.job.sourceId!, + userId: input.callerContext.user.userId, + baseUrl: input.callerContext.baseUrl, + sourceRoot: '/', + }) + const result = await repoSessionRpc(input.env, session.id).runChecks({ + sessionId: session.id, + }) + if (!result.ok) { + return { + error: result.results + .filter((entry) => !entry.ok) + .map((entry) => entry.message) + .join('\n'), + result: null, + logs: [], + } + } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') + return await runCodemodeWithRegistry( + input.env, + input.callerContext, + 'async () => ({ ok: true, repoBacked: true })', + input.job.params, + { + executorExports: workerExports, + storageTools: { + userId: input.callerContext.user.userId, + storageId: input.job.storageId, + writable: true, + }, + }, + ) +} + export async function runJobNow(input: { env: Env userId: string diff --git a/packages/worker/src/jobs/types.ts b/packages/worker/src/jobs/types.ts index 54cef61f14..ae66e8fe83 100644 --- a/packages/worker/src/jobs/types.ts +++ b/packages/worker/src/jobs/types.ts @@ -32,9 +32,9 @@ export type JobRecord = { id: string userId: string name: string - code: string - sourceId?: string - publishedCommit?: string + code: string | null + sourceId: string | null + publishedCommit: string | null storageId: string params?: Record schedule: JobSchedule @@ -90,9 +90,9 @@ export type PersistedJobCallerContext = Pick< export type JobCreateInput = { name: string - code: string - sourceId?: string - publishedCommit?: string + code?: string | null + sourceId?: string | null + publishedCommit?: string | null params?: Record schedule: JobSchedule timezone?: string | null @@ -103,7 +103,7 @@ export type JobCreateInput = { export type JobUpdateInput = { id: string name?: string - code?: string + code?: string | null sourceId?: string | null publishedCommit?: string | null params?: Record | null @@ -116,7 +116,7 @@ export type JobUpdateInput = { export type JobUpsertInput = { id?: string name?: string - code?: string + code?: string | null sourceId?: string | null publishedCommit?: string | null params?: Record | null diff --git a/packages/worker/src/mcp/capabilities/jobs/shared.ts b/packages/worker/src/mcp/capabilities/jobs/shared.ts index d0145a40ae..9685e53032 100644 --- a/packages/worker/src/mcp/capabilities/jobs/shared.ts +++ b/packages/worker/src/mcp/capabilities/jobs/shared.ts @@ -37,7 +37,9 @@ export const jobViewSchema = z.object({ version: z.literal(1), id: z.string(), name: z.string(), - code: z.string(), + code: z.string().nullable(), + sourceId: z.string().nullable(), + publishedCommit: z.string().nullable(), storageId: z.string(), params: z.record(z.string(), z.unknown()).optional(), schedule: jobScheduleSchema, @@ -96,8 +98,27 @@ export const jobUpsertInputSchema = z code: z .string() .min(1) + .nullable() + .optional() + .describe( + 'Optional inline codemode async arrow function source. For repo-backed jobs, prefer sourceId plus publishedCommit.', + ), + sourceId: z + .string() + .min(1) + .nullable() .optional() - .describe('Codemode async arrow function source. Required on create.'), + .describe( + 'Shared source id for a repo-backed job implementation. Required on create when code is omitted.', + ), + publishedCommit: z + .string() + .min(1) + .nullable() + .optional() + .describe( + 'Published commit pinned for repo-backed job execution. Optional on create; updated automatically when the repo session publish flow promotes changes.', + ), params: z .record(z.string(), z.unknown()) .nullable() @@ -137,11 +158,11 @@ export const jobUpsertInputSchema = z message: 'schedule is required when creating a job.', }) } - if (value.code == null) { + if (value.code == null && value.sourceId == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['code'], - message: 'code is required when creating a job.', + message: 'code or sourceId is required when creating a job.', }) } }) satisfies z.ZodType diff --git a/packages/worker/src/mcp/capabilities/repo/domain.ts b/packages/worker/src/mcp/capabilities/repo/domain.ts index 585bc390c4..02388b6005 100644 --- a/packages/worker/src/mcp/capabilities/repo/domain.ts +++ b/packages/worker/src/mcp/capabilities/repo/domain.ts @@ -5,6 +5,7 @@ import { repoDiscardSessionCapability } from './repo-discard-session.ts' import { repoGetSessionCapability } from './repo-get-session.ts' import { repoOpenSessionCapability } from './repo-open-session.ts' import { repoReadFileCapability } from './repo-read-file.ts' +import { repoRunChecksCapability } from './repo-run-checks.ts' import { repoSearchCapability } from './repo-search.ts' import { repoTreeCapability } from './repo-tree.ts' import { repoWriteFileCapability } from './repo-write-file.ts' @@ -22,6 +23,7 @@ export const repoDomain = defineDomain({ repoWriteFileCapability, repoApplyPatchCapability, repoSearchCapability, + repoRunChecksCapability, repoDiscardSessionCapability, ], }) diff --git a/packages/worker/src/mcp/capabilities/repo/index.ts b/packages/worker/src/mcp/capabilities/repo/index.ts index fb7c30a75b..bf41c06b47 100644 --- a/packages/worker/src/mcp/capabilities/repo/index.ts +++ b/packages/worker/src/mcp/capabilities/repo/index.ts @@ -3,9 +3,13 @@ import { repoDomain } from './domain.ts' export { repoDomain } from './domain.ts' export { repoApplyPatchCapability } from './repo-apply-patch.ts' export { repoDiscardSessionCapability } from './repo-discard-session.ts' +export { repoGetCheckStatusCapability } from './repo-get-check-status.ts' export { repoGetSessionCapability } from './repo-get-session.ts' export { repoOpenSessionCapability } from './repo-open-session.ts' +export { repoPublishSessionCapability } from './repo-publish-session.ts' export { repoReadFileCapability } from './repo-read-file.ts' +export { repoRebaseSessionCapability } from './repo-rebase-session.ts' +export { repoRunChecksCapability } from './repo-run-checks.ts' export { repoSearchCapability } from './repo-search.ts' export { repoTreeCapability } from './repo-tree.ts' export { repoWriteFileCapability } from './repo-write-file.ts' diff --git a/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts b/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts new file mode 100644 index 0000000000..b70469b677 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts @@ -0,0 +1,44 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { type RepoCheckKind } from '#worker/repo/checks.ts' +import { + repoCheckStatusOutputSchema, + repoSessionIdSchema, +} from './repo-shared.ts' + +export const repoGetCheckStatusCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_get_check_status', + description: + 'Inspect the most recent Worker-native check run metadata for an active repo session.', + keywords: ['repo', 'checks', 'status', 'validate', 'session'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: repoSessionIdSchema, + outputSchema: repoCheckStatusOutputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const status = await repoSessionRpc( + ctx.env, + args.session_id, + ).getCheckStatus({ + sessionId: args.session_id, + }) + return { + run_id: status.runId, + tree_hash: status.treeHash, + checked_at: status.checkedAt, + ok: status.ok ?? false, + results: (status.results ?? []).map((entry) => ({ + kind: entry.kind as RepoCheckKind, + ok: entry.ok, + message: entry.message, + })), + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts new file mode 100644 index 0000000000..48fb637511 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { repoSessionIdSchema } from './repo-shared.ts' + +const outputSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('ok'), + session_id: z.string(), + published_commit: z.string(), + message: z.string(), + }), + z.object({ + status: z.literal('checks_outdated'), + session_id: z.string(), + published_commit: z.null(), + message: z.string(), + }), + z.object({ + status: z.literal('base_moved'), + session_id: z.string(), + published_commit: z.null(), + message: z.string(), + }), +]) + +export const repoPublishSessionCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_publish_session', + description: + 'Publish an active repo session back to the source repo after checks pass on the current tree and the base commit is still current.', + keywords: ['repo', 'publish', 'session', 'checks', 'artifact'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: repoSessionIdSchema, + outputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const result = await repoSessionRpc( + ctx.env, + args.session_id, + ).publishSession({ + sessionId: args.session_id, + }) + if (result.status === 'ok') { + return { + status: 'ok' as const, + session_id: result.sessionId, + published_commit: result.publishedCommit ?? '', + message: result.message, + } + } + if (result.status === 'checks_outdated') { + return { + status: 'checks_outdated' as const, + session_id: result.sessionId, + published_commit: null, + message: result.message, + } + } + return { + status: 'base_moved' as const, + session_id: result.sessionId, + published_commit: null, + message: result.message, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts new file mode 100644 index 0000000000..5e371dcc4c --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts @@ -0,0 +1,45 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { repoSessionIdSchema } from './repo-shared.ts' +import { z } from 'zod' + +const outputSchema = z.object({ + ok: z.literal(true), + session_id: z.string(), + base_commit: z.string(), + head_commit: z.string().nullable(), + merged: z.boolean(), +}) + +export const repoRebaseSessionCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_rebase_session', + description: + 'Rebase a repo session against the latest published source state when the base commit has moved.', + keywords: ['repo', 'rebase', 'session', 'publish', 'base moved'], + readOnly: false, + idempotent: false, + destructive: false, + inputSchema: repoSessionIdSchema, + outputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const result = await repoSessionRpc( + ctx.env, + args.session_id, + ).rebaseSession({ + sessionId: args.session_id, + }) + return { + ok: result.ok, + session_id: result.sessionId, + base_commit: result.baseCommit, + head_commit: result.headCommit ?? null, + merged: result.merged, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts new file mode 100644 index 0000000000..d7ea45b601 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts @@ -0,0 +1,38 @@ +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { + repoRunChecksOutputSchema, + repoSessionIdSchema, +} from './repo-shared.ts' + +export const repoRunChecksCapability = defineDomainCapability( + capabilityDomainNames.repo, + { + name: 'repo_run_checks', + description: + 'Run the Worker-native validation pipeline for an active repo session so edits can be checked before publish.', + keywords: ['repo', 'checks', 'validate', 'typecheck', 'bundle', 'manifest'], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: repoSessionIdSchema, + outputSchema: repoRunChecksOutputSchema, + async handler(args, ctx) { + requireMcpUser(ctx.callerContext) + const result = await repoSessionRpc(ctx.env, args.session_id).runChecks({ + sessionId: args.session_id, + }) + return { + ok: result.ok, + results: result.results.map((entry) => ({ + kind: entry.kind, + ok: entry.ok, + message: entry.message, + })), + manifest: result.manifest, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts index 03f8a2eadd..497aa2da32 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts @@ -248,3 +248,37 @@ export const repoApplyPatchResultSchema = z.object({ }), ), }) + +export const repoRunChecksInputSchema = repoSessionIdSchema + +export const repoCheckResultSchema = z.object({ + kind: z.enum([ + 'manifest', + 'dependencies', + 'bundle', + 'typecheck', + 'lint', + 'smoke', + ]), + ok: z.boolean(), + message: z.string(), +}) + +export const repoRunChecksOutputSchema = z.object({ + ok: z.boolean(), + results: z.array(repoCheckResultSchema), + manifest: z.object({ + version: z.literal(1), + kind: z.enum(['skill', 'app', 'job']), + title: z.string(), + description: z.string(), + }), +}) + +export const repoCheckStatusOutputSchema = z.object({ + run_id: z.string().nullable(), + tree_hash: z.string().nullable(), + checked_at: z.string().nullable(), + ok: z.boolean(), + results: z.array(repoCheckResultSchema), +}) diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts new file mode 100644 index 0000000000..2c849b6052 --- /dev/null +++ b/packages/worker/src/repo/checks.ts @@ -0,0 +1,164 @@ +import { createFileSystemSnapshot } from '@cloudflare/worker-bundler' +import { createTypescriptLanguageService } from '@cloudflare/worker-bundler/typescript' +import { parseRepoManifest } from './manifest.ts' +import { type RepoManifest } from './types.ts' + +export type RepoCheckKind = + | 'manifest' + | 'dependencies' + | 'bundle' + | 'typecheck' + | 'lint' + | 'smoke' + +export type RepoCheckResult = { + kind: RepoCheckKind + ok: boolean + message: string +} + +export type RepoCheckRunResult = { + ok: boolean + results: Array + manifest: RepoManifest +} + +async function* workspaceFilesForSnapshot(input: { + workspace: { + glob(pattern: string): Promise> + readFile(path: string): Promise + } + root: string +}) { + const normalizedRoot = input.root.replace(/\/+$/, '') + const pattern = + normalizedRoot === '' + ? '**/*.{ts,tsx,js,jsx,json}' + : `${normalizedRoot}/**/*.{ts,tsx,js,jsx,json}` + const files = await input.workspace.glob(pattern) + for (const file of files) { + if (file.type !== 'file') continue + const content = await input.workspace.readFile(file.path) + if (content == null) continue + const relativePath = normalizedRoot + ? file.path.slice(normalizedRoot.length + 1) + : file.path + yield [relativePath, content] as const + } +} + +function formatTypecheckDiagnostics( + fileName: string, + diagnostics: Array<{ + messageText: unknown + start?: number + file?: { + getLineAndCharacterOfPosition(pos: number): { + line: number + character: number + } + } + }>, +) { + return diagnostics.map((diagnostic) => { + const location = + typeof diagnostic.start === 'number' && diagnostic.file + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + : null + const message = + typeof diagnostic.messageText === 'string' + ? diagnostic.messageText + : JSON.stringify(diagnostic.messageText) + return location + ? `${fileName}:${location.line + 1}:${location.character + 1} ${message}` + : `${fileName} ${message}` + }) +} + +export async function runRepoChecks(input: { + workspace: { + readFile(path: string): Promise + glob(pattern: string): Promise> + } + manifestPath: string + sourceRoot: string +}): Promise { + const manifestContent = await input.workspace.readFile(input.manifestPath) + if (manifestContent == null) { + throw new Error(`Manifest "${input.manifestPath}" was not found.`) + } + const manifest = parseRepoManifest({ + content: manifestContent, + manifestPath: input.manifestPath, + }) + const results: Array = [ + { + kind: 'manifest', + ok: true, + message: `Validated ${input.manifestPath}.`, + }, + ] + + const sourceRoot = input.sourceRoot.replace(/^\/+/, '').replace(/\/+$/, '') + const snapshot = await createFileSystemSnapshot( + workspaceFilesForSnapshot({ + workspace: input.workspace, + root: sourceRoot, + }), + ) + + const packageJson = snapshot.read('package.json') + results.push({ + kind: 'dependencies', + ok: packageJson != null, + message: + packageJson != null + ? 'package.json found for dependency fingerprinting.' + : 'No package.json found in source root; dependency check skipped.', + }) + + const entryPoint = + manifest.kind === 'app' ? manifest.server : manifest.entrypoint + results.push({ + kind: 'bundle', + ok: snapshot.read(entryPoint) != null, + message: + snapshot.read(entryPoint) != null + ? `Entrypoint "${entryPoint}" found for bundling.` + : `Entrypoint "${entryPoint}" is missing from the repo session snapshot.`, + }) + + const { languageService } = await createTypescriptLanguageService({ + fileSystem: snapshot, + }) + const diagnostics = languageService.getSemanticDiagnostics(entryPoint) + results.push({ + kind: 'typecheck', + ok: diagnostics.length === 0, + message: + diagnostics.length === 0 + ? `No semantic diagnostics for "${entryPoint}".` + : formatTypecheckDiagnostics(entryPoint, diagnostics).join('\n'), + }) + + results.push({ + kind: 'lint', + ok: true, + message: 'Lint placeholder passed for this phase.', + }) + + const smokeChecks = manifest.checks?.smoke ?? [] + if (smokeChecks.length > 0) { + results.push({ + kind: 'smoke', + ok: true, + message: `Recorded ${smokeChecks.length} configured smoke check(s).`, + }) + } + + return { + ok: results.every((result) => result.ok), + results, + manifest, + } +} diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index c32ee6e10a..ee361b0dbc 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -12,10 +12,15 @@ import { insertRepoSession, updateRepoSession, } from './repo-sessions.ts' -import { resolveArtifactSourceRepo, resolveSessionRepo } from './artifacts.ts' +import { + buildAuthenticatedArtifactsRemote, + resolveArtifactSourceRepo, + resolveSessionRepo, +} from './artifacts.ts' import { buildSentryOptions } from '#worker/sentry-options.ts' -import { getEntitySourceById } from './entity-sources.ts' +import { getEntitySourceById, updateEntitySource } from './entity-sources.ts' import { type EntitySourceRow, type RepoSessionRow } from './types.ts' +import { runRepoChecks, type RepoCheckRunResult } from './checks.ts' const repoSessionWorkspacePrefix = '/session' const defaultRepoSearchLimit = 50 @@ -75,12 +80,59 @@ export type RepoSessionApplyEditsResult = { }> } +export type RepoSessionCheckRun = RepoCheckRunResult & { + runId: string + treeHash: string + checkedAt: string +} + export type RepoSessionDiscardResult = { ok: true sessionId: string deleted: boolean } +export type RepoSessionCheckStatus = { + runId: string | null + treeHash: string | null + checkedAt: string | null + ok: boolean | null + results: Array<{ + kind: string + ok: boolean + message: string + }> | null +} + +export type RepoSessionPublishResult = + | { + status: 'ok' + sessionId: string + publishedCommit: string + message: string + } + | { + status: 'checks_outdated' | 'base_moved' + sessionId: string + message: string + publishedCommit: null + } + +export type RepoSessionRebaseResult = { + ok: true + sessionId: string + baseCommit: string + headCommit: string | null + merged: boolean +} + +const lastCheckStatusStorageKey = 'repo-session:last-check-status' +const defaultSessionBranch = 'main' +const sessionCommitAuthor = { + name: 'Kody Repo Session', + email: 'repo-session@local.invalid', +} + function buildRepoSessionWorkspaceName(sessionId: string) { return `repo-session:${sessionId}` } @@ -158,6 +210,142 @@ class RepoSessionBase extends DurableObject { private initializedSessionId: string | null = null + private async getSessionState(sessionId: string) { + const sessionRow = await getRepoSessionById(this.env.APP_DB, sessionId) + if (!sessionRow) { + throw new Error(`Repo session "${sessionId}" was not found.`) + } + const source = await getEntitySourceById( + this.env.APP_DB, + sessionRow.source_id, + ) + if (!source) { + throw new Error(`Source "${sessionRow.source_id}" was not found.`) + } + const sessionRepo = await resolveSessionRepo(this.env, { + namespace: sessionRow.session_repo_namespace, + name: sessionRow.session_repo_name, + }) + const access = await ensureArtifactRepoRemote({ + repo: sessionRepo, + scope: 'write', + }) + await this.initialize({ + sessionId: sessionRow.id, + sessionRepoRemote: access.remote, + sessionRepoToken: access.token, + }) + return { + sessionRow, + source, + sessionRepo, + sessionAccess: access, + } + } + + private async ensureRemote(input: { name: string; url: string }) { + const existing = await this.git.remote({ + dir: repoSessionWorkspacePrefix, + list: true, + }) + const remotes = Array.isArray(existing) ? existing : [] + const current = remotes.find((remote) => remote.remote === input.name) + if (current?.url === input.url) { + return + } + if (current) { + await this.git.remote({ + dir: repoSessionWorkspacePrefix, + remove: input.name, + }) + } + await this.git.remote({ + dir: repoSessionWorkspacePrefix, + add: { + name: input.name, + url: input.url, + }, + }) + } + + private async getCurrentBranch(defaultBranch = defaultSessionBranch) { + const branchResult = await this.git.branch({ + dir: repoSessionWorkspacePrefix, + list: true, + }) + if ('current' in branchResult && branchResult.current) { + return branchResult.current + } + return defaultBranch + } + + private async getHeadCommit() { + const log = await this.git.log({ + dir: repoSessionWorkspacePrefix, + depth: 1, + }) + return log[0]?.oid ?? null + } + + private async commitIfDirty(message: string) { + const statusEntries = await this.git.status({ + dir: repoSessionWorkspacePrefix, + }) + const hasChanges = statusEntries.some( + (entry) => entry.status !== 'unmodified', + ) + if (!hasChanges) { + return this.getHeadCommit() + } + await this.git.add({ + dir: repoSessionWorkspacePrefix, + filepath: '.', + }) + const commit = await this.git.commit({ + dir: repoSessionWorkspacePrefix, + message, + author: sessionCommitAuthor, + }) + return commit.oid + } + + private async computeTreeHash(root = repoSessionWorkspacePrefix) { + const files = ( + await this.workspace.glob(`${root.replace(/\/+$/, '')}/**/*`) + ) + .filter((entry) => entry.type === 'file') + .filter((entry) => !entry.path.includes('/.git/')) + .sort((left, right) => left.path.localeCompare(right.path)) + const chunks: Array = [] + for (const file of files) { + const content = await this.workspace.readFile(file.path) + chunks.push(`${file.path}\n${content ?? ''}\n`) + } + const data = new TextEncoder().encode(chunks.join('')) + const digest = await crypto.subtle.digest('SHA-256', data) + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + } + + private async writeCheckStatus(status: RepoSessionCheckStatus) { + await this.ctx.storage.put(lastCheckStatusStorageKey, status) + } + + private async readCheckStatus(): Promise { + return ( + (await this.ctx.storage.get( + lastCheckStatusStorageKey, + )) ?? { + runId: null, + treeHash: null, + checkedAt: null, + ok: null, + results: null, + } + ) + } + async initialize(input: { sessionId: string sessionRepoRemote: string @@ -263,33 +451,7 @@ class RepoSessionBase extends DurableObject { } async getSessionInfo(input: { sessionId: string }) { - const sessionRow = await getRepoSessionById( - this.env.APP_DB, - input.sessionId, - ) - if (!sessionRow) { - throw new Error(`Repo session "${input.sessionId}" was not found.`) - } - const source = await getEntitySourceById( - this.env.APP_DB, - sessionRow.source_id, - ) - if (!source) { - throw new Error(`Source "${sessionRow.source_id}" was not found.`) - } - const sessionRepo = await resolveSessionRepo(this.env, { - namespace: sessionRow.session_repo_namespace, - name: sessionRow.session_repo_name, - }) - const access = await ensureArtifactRepoRemote({ - repo: sessionRepo, - scope: 'write', - }) - await this.initialize({ - sessionId: sessionRow.id, - sessionRepoRemote: access.remote, - sessionRepoToken: access.token, - }) + const { sessionRow, source } = await this.getSessionState(input.sessionId) return this.toSessionInfo(sessionRow, source) } @@ -327,7 +489,7 @@ class RepoSessionBase extends DurableObject { sessionId: string path: string }): Promise<{ path: string; content: string | null }> { - await this.getSessionInfo({ sessionId: input.sessionId }) + await this.getSessionState(input.sessionId) return { path: input.path, content: await this.workspace.readFile( @@ -341,14 +503,14 @@ class RepoSessionBase extends DurableObject { path: string content: string }): Promise<{ ok: true; path: string }> { - const session = await this.getSessionInfo({ sessionId: input.sessionId }) + const { sessionRow } = await this.getSessionState(input.sessionId) await this.workspace.writeFile( this.resolveWorkspacePath(input.path), input.content, ) await updateRepoSession(this.env.APP_DB, { id: input.sessionId, - userId: session.source_id, + userId: sessionRow.user_id, lastCheckpointAt: nowIso(), }) return { ok: true, path: input.path } @@ -366,13 +528,13 @@ class RepoSessionBase extends DurableObject { limit?: number outputMode?: RepoSearchOutputMode }): Promise { - const session = await this.getSessionInfo({ sessionId: input.sessionId }) + const { sessionRow } = await this.getSessionState(input.sessionId) const search = normalizeSearchQuery({ pattern: input.pattern, mode: input.mode, }) const root = - input.path?.trim() || session.source_root || repoSessionWorkspacePrefix + input.path?.trim() || sessionRow.source_root || repoSessionWorkspacePrefix const globPattern = input.glob?.trim() || `${root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` @@ -465,9 +627,11 @@ class RepoSessionBase extends DurableObject { path?: string | null maxDepth?: number }): Promise { - await this.getSessionInfo({ sessionId: input.sessionId }) + const { sessionRow } = await this.getSessionState(input.sessionId) const root = this.resolveWorkspacePath( - input.path?.trim() || repoSessionWorkspacePrefix, + input.path?.trim() || + sessionRow.source_root || + repoSessionWorkspacePrefix, ) const tree = await this.state.walkTree(root, { maxDepth: input.maxDepth, @@ -497,7 +661,7 @@ class RepoSessionBase extends DurableObject { dryRun?: boolean rollbackOnError?: boolean }): Promise { - const session = await this.getSessionInfo({ sessionId: input.sessionId }) + const { sessionRow } = await this.getSessionState(input.sessionId) const plan = await this.state.planEdits( input.edits.map((edit) => { const path = this.resolveWorkspacePath(edit.path) @@ -541,7 +705,7 @@ class RepoSessionBase extends DurableObject { }) await updateRepoSession(this.env.APP_DB, { id: input.sessionId, - userId: session.source_id, + userId: sessionRow.user_id, lastCheckpointAt: nowIso(), }) return { @@ -556,6 +720,202 @@ class RepoSessionBase extends DurableObject { } } + async runChecks(input: { sessionId: string }): Promise { + const { sessionRow, source } = await this.getSessionState(input.sessionId) + const manifestPath = this.resolveWorkspacePath(source.manifest_path) + const result = await runRepoChecks({ + workspace: this.workspace, + manifestPath, + sourceRoot: repoSessionWorkspacePrefix, + }) + const runId = crypto.randomUUID() + const treeHash = await this.computeTreeHash() + const checkedAt = nowIso() + await updateRepoSession(this.env.APP_DB, { + id: input.sessionId, + userId: sessionRow.user_id, + lastCheckRunId: runId, + lastCheckTreeHash: treeHash, + lastCheckpointAt: checkedAt, + }) + await this.writeCheckStatus({ + runId, + treeHash, + checkedAt, + ok: result.ok, + results: result.results.map((entry) => ({ + kind: entry.kind, + ok: entry.ok, + message: entry.message, + })), + }) + return { + ...result, + runId, + treeHash, + checkedAt, + } + } + + async getCheckStatus(input: { sessionId: string }) { + await this.getSessionState(input.sessionId) + return this.readCheckStatus() + } + + async rebaseSession(input: { + sessionId: string + }): Promise { + const { sessionRow, source } = await this.getSessionState(input.sessionId) + const sourceRepo = await resolveArtifactSourceRepo(this.env, source.repo_id) + const sourceInfo = await sourceRepo.info() + const sourceAccess = await ensureArtifactRepoRemote({ + repo: sourceRepo, + scope: 'write', + }) + await this.ensureRemote({ + name: 'source', + url: buildAuthenticatedArtifactsRemote({ + remote: sourceAccess.remote, + token: sourceAccess.token, + }), + }) + const defaultBranch = sourceInfo?.defaultBranch ?? defaultSessionBranch + const pullResult = await this.git.pull({ + dir: repoSessionWorkspacePrefix, + remote: 'source', + ref: defaultBranch, + author: sessionCommitAuthor, + token: sourceAccess.token, + username: 'x', + password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + }) + const headCommit = await this.getHeadCommit() + await this.git.push({ + dir: repoSessionWorkspacePrefix, + remote: 'origin', + ref: defaultBranch, + token: sourceAccess.token, + username: 'x', + password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + }) + await updateRepoSession(this.env.APP_DB, { + id: sessionRow.id, + userId: sessionRow.user_id, + baseCommit: source.published_commit ?? '', + lastCheckpointCommit: headCommit, + lastCheckpointAt: nowIso(), + }) + return { + ok: true, + sessionId: sessionRow.id, + baseCommit: source.published_commit ?? '', + headCommit, + merged: pullResult.pulled, + } + } + + async publishSession(input: { + sessionId: string + }): Promise { + const { sessionRow, source } = await this.getSessionState(input.sessionId) + const checkStatus = await this.readCheckStatus() + const currentTreeHash = await this.computeTreeHash() + if ( + !checkStatus.runId || + !checkStatus.ok || + checkStatus.treeHash !== currentTreeHash + ) { + return { + status: 'checks_outdated', + sessionId: input.sessionId, + publishedCommit: null, + message: + 'Run repo_run_checks on the current session state before publishing.', + } + } + if ((source.published_commit ?? '') !== sessionRow.base_commit) { + return { + status: 'base_moved', + sessionId: input.sessionId, + publishedCommit: null, + message: + 'The source repo has moved since this session opened. Rebase the session before publishing.', + } + } + const sourceRepo = await resolveArtifactSourceRepo(this.env, source.repo_id) + const sourceInfo = await sourceRepo.info() + const sourceAccess = await ensureArtifactRepoRemote({ + repo: sourceRepo, + scope: 'write', + }) + const sessionHeadCommit = + (await this.commitIfDirty(`Publish repo session ${input.sessionId}`)) ?? + (await this.getHeadCommit()) + await this.git.push({ + dir: repoSessionWorkspacePrefix, + remote: 'origin', + ref: await this.getCurrentBranch( + sourceInfo?.defaultBranch ?? defaultSessionBranch, + ), + token: sourceAccess.token, + username: 'x', + password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + }) + await this.ensureRemote({ + name: 'source', + url: buildAuthenticatedArtifactsRemote({ + remote: sourceAccess.remote, + token: sourceAccess.token, + }), + }) + const targetBranch = sourceInfo?.defaultBranch ?? defaultSessionBranch + await this.git.push({ + dir: repoSessionWorkspacePrefix, + remote: 'source', + ref: targetBranch, + token: sourceAccess.token, + username: 'x', + password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + }) + const manifestContent = await this.workspace.readFile( + this.resolveWorkspacePath(source.manifest_path), + ) + if (manifestContent == null) { + throw new Error(`Manifest "${source.manifest_path}" was not found.`) + } + const manifest = await import('./manifest.ts').then((module) => + module.parseRepoManifest({ + content: manifestContent, + manifestPath: source.manifest_path, + }), + ) + await updateEntitySource(this.env.APP_DB, { + id: source.id, + userId: source.user_id, + publishedCommit: sessionHeadCommit, + manifestPath: source.manifest_path, + sourceRoot: manifest.sourceRoot?.startsWith('/') + ? manifest.sourceRoot + : manifest.sourceRoot + ? `/${manifest.sourceRoot}` + : source.source_root, + }) + await updateRepoSession(this.env.APP_DB, { + id: sessionRow.id, + userId: sessionRow.user_id, + status: 'published', + baseCommit: sessionHeadCommit ?? sessionRow.base_commit, + lastCheckpointCommit: sessionHeadCommit, + lastCheckpointAt: nowIso(), + }) + return { + status: 'ok', + sessionId: sessionRow.id, + publishedCommit: sessionHeadCommit ?? sessionRow.base_commit, + message: `Published session ${sessionRow.id} to ${source.repo_id}.`, + } + } + private resolveWorkspacePath(path: string) { const trimmed = path.trim() if (!trimmed) { @@ -756,6 +1116,16 @@ export function repoSessionRpc(env: Env, sessionId: string) { dryRun?: boolean rollbackOnError?: boolean }) => Promise + runChecks: (payload: { sessionId: string }) => Promise + getCheckStatus: (payload: { + sessionId: string + }) => Promise> + rebaseSession: (payload: { + sessionId: string + }) => Promise + publishSession: (payload: { + sessionId: string + }) => Promise } } From fe0612a963e07a5ae953380ff637a3a1dbd6b242 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 18:20:20 +0000 Subject: [PATCH 06/42] Lazy load worker bundler in repo checks --- packages/worker/src/repo/checks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index 2c849b6052..771efad58f 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -1,5 +1,3 @@ -import { createFileSystemSnapshot } from '@cloudflare/worker-bundler' -import { createTypescriptLanguageService } from '@cloudflare/worker-bundler/typescript' import { parseRepoManifest } from './manifest.ts' import { type RepoManifest } from './types.ts' @@ -100,6 +98,8 @@ export async function runRepoChecks(input: { ] const sourceRoot = input.sourceRoot.replace(/^\/+/, '').replace(/\/+$/, '') + const { createFileSystemSnapshot } = + await import('@cloudflare/worker-bundler') const snapshot = await createFileSystemSnapshot( workspaceFilesForSnapshot({ workspace: input.workspace, @@ -128,6 +128,8 @@ export async function runRepoChecks(input: { : `Entrypoint "${entryPoint}" is missing from the repo session snapshot.`, }) + const { createTypescriptLanguageService } = + await import('@cloudflare/worker-bundler/typescript') const { languageService } = await createTypescriptLanguageService({ fileSystem: snapshot, }) From 82c36919e838914a83e4b7a9fd8738416fe19fe5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 18:49:57 +0000 Subject: [PATCH 07/42] Add job search projections --- packages/worker/src/agent-turn/tools.ts | 7 + packages/worker/src/jobs/types.ts | 11 + .../src/mcp/capabilities/unified-search.ts | 208 +++++++++++++++++- packages/worker/src/mcp/jobs-embed.ts | 35 +++ .../worker/src/mcp/tools/search-format.ts | 92 +++++++- .../worker/src/mcp/tools/search.node.test.ts | 1 + packages/worker/src/mcp/tools/search.ts | 40 ++++ 7 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 packages/worker/src/mcp/jobs-embed.ts diff --git a/packages/worker/src/agent-turn/tools.ts b/packages/worker/src/agent-turn/tools.ts index 5f385ff6a4..cf3ba9a4cc 100644 --- a/packages/worker/src/agent-turn/tools.ts +++ b/packages/worker/src/agent-turn/tools.ts @@ -19,6 +19,7 @@ import { slugifySkillCollectionName } from '#mcp/skills/skill-collections.ts' import { listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts' import { listValues } from '#mcp/values/service.ts' import { runCodemodeWithRegistry } from '#mcp/run-codemode-registry.ts' +import { listJobs } from '#worker/jobs/service.ts' const defaultSearchLimit = 15 const defaultMaxResponseSize = 4_000 @@ -61,6 +62,11 @@ export async function createAgentTurnToolSet(input: { listUiArtifactsByUserId(input.env.APP_DB, userId!, { hidden: false, }), + loadJobs: () => + listJobs({ + env: input.env, + userId: userId!, + }), loadUserSecrets: () => listUserSecretsForSearch({ env: input.env, @@ -97,6 +103,7 @@ export async function createAgentTurnToolSet(input: { userId, skillRows: optionalRows.skillRows, uiArtifactRows: optionalRows.uiArtifactRows, + jobRows: optionalRows.jobRows, userSecretRows: optionalRows.userSecretRows, userValueRows: optionalRows.userValueRows, appSecretsByAppId, diff --git a/packages/worker/src/jobs/types.ts b/packages/worker/src/jobs/types.ts index ae66e8fe83..1e45b106c0 100644 --- a/packages/worker/src/jobs/types.ts +++ b/packages/worker/src/jobs/types.ts @@ -58,6 +58,17 @@ export type JobView = Omit & { scheduleSummary: string } +export type JobSearchProjection = { + id: string + name: string + description: string + scheduleSummary: string + sourceId: string | null + publishedCommit: string | null + storageId: string + updatedAt: string +} + export type JobExecutionResult = | { ok: true diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index acb1516e29..fa08fc56a4 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -35,6 +35,8 @@ import { } from '#mcp/ui-artifacts-search.ts' import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts' import { type ValueMetadata, type ValueScope } from '#mcp/values/types.ts' +import { type JobView } from '#worker/jobs/types.ts' +import { buildJobEmbedText, buildJobUsage } from '#mcp/jobs-embed.ts' function parseJsonStringArray(raw: string): Array { try { @@ -352,12 +354,54 @@ export type ConnectorSearchHitSummary = { export type ConnectorSearchHit = ConnectorSearchHitSummary +export type JobSearchHitSummary = { + type: 'job' + jobId: string + domain: 'jobs' + title: string + description: string + scheduleSummary: string + sourceId: string | null + publishedCommit: string | null + storageId: string + usage: string + fusedScore: number + lexicalRank?: number + vectorRank?: number +} + +export type JobSearchHit = JobSearchHitSummary + +function scoreJobLexicalMatch( + query: string, + row: JobView, + doc: string, +): number { + const normalizedQuery = normalizeSearchPhrase(query) + let bonus = 0 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.name) * 2 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.scheduleSummary) * 1 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.sourceId) * 0.5 + return lexicalScore(query, doc) + bonus +} + +function buildJobEmbedDoc(row: JobView): string { + return buildJobEmbedText({ + name: row.name, + description: row.name, + scheduleSummary: row.scheduleSummary, + sourceId: row.sourceId, + publishedCommit: row.publishedCommit, + }) +} + export type UnifiedSearchMatch = | CapabilitySearchHitTyped | SkillSearchHit | SecretSearchHit | ValueSearchHit | ConnectorSearchHit + | JobSearchHit | UiArtifactSearchHit function buildSecretUsage(name: string) { @@ -458,6 +502,117 @@ function rowToSkillHit( } } +function rowToJobHit( + row: JobView, + fusedScore: number, + lexicalRank?: number, + vectorRank?: number, +): JobSearchHit { + return { + type: 'job', + jobId: row.id, + domain: 'jobs', + title: row.name, + description: row.name, + scheduleSummary: row.scheduleSummary, + sourceId: row.sourceId ?? null, + publishedCommit: row.publishedCommit ?? null, + storageId: row.storageId, + usage: buildJobUsage(row), + fusedScore, + lexicalRank, + vectorRank, + } +} + +async function searchJobsForUser(input: { + env: Env + query: string + limit: number + userId: string + rows: Array +}): Promise<{ matches: Array; offline: boolean }> { + const q = input.query.trim() + const rowById = new Map(input.rows.map((row) => [row.id, row] as const)) + const ids = [...rowById.keys()] + const offline = isCapabilitySearchOffline(input.env) + + if (ids.length === 0) { + return { matches: [], offline } + } + + const docsById = Object.fromEntries( + input.rows.map((row) => [row.id, buildJobEmbedDoc(row)] as const), + ) + const lexicalOrder = sortIdsByScore(ids, (id) => + scoreJobLexicalMatch(q, rowById.get(id)!, docsById[id]!), + ) + + let vectorOrder: Array + if (offline) { + const qVec = deterministicEmbedding(q) + const simById = Object.fromEntries( + ids.map((id) => { + const cVec = deterministicEmbedding(docsById[id]!) + return [id, cosineSimilarity(qVec, cVec)] as const + }), + ) + vectorOrder = sortIdsByScore(ids, (id) => simById[id]!) + } else { + const index = getCapabilityVectorIndex(input.env)! + const qVec = await embedTextForVectorize(input.env, q) + const topK = Math.min(Math.max(ids.length, input.limit * 5), 100) + const matches = await index.query(qVec, { + topK, + returnMetadata: 'none', + filter: { + kind: { $eq: 'job' }, + userId: { $eq: input.userId }, + }, + }) + const seen = new Set() + const fromIndex: Array = [] + for (const match of matches.matches) { + if (typeof match.id !== 'string' || seen.has(match.id)) continue + if (!match.id.startsWith('job_')) continue + const jobId = match.id.slice('job_'.length) + if (!rowById.has(jobId)) continue + seen.add(match.id) + fromIndex.push(jobId) + } + vectorOrder = [...fromIndex, ...ids.filter((id) => !seen.has(`job_${id}`))] + } + + const lexicalRankById = new Map() + for (let index = 0; index < lexicalOrder.length; index += 1) { + lexicalRankById.set(lexicalOrder[index]!, index + 1) + } + const vectorRankById = new Map() + for (let index = 0; index < vectorOrder.length; index += 1) { + vectorRankById.set(vectorOrder[index]!, index + 1) + } + + const fused = reciprocalRankFusion( + [lexicalOrder, vectorOrder], + CAPABILITY_SEARCH_RRF_K, + ) + const ordered = [...ids] + .sort((a, b) => (fused.get(b) ?? 0) - (fused.get(a) ?? 0)) + .slice(0, Math.max(1, Math.min(input.limit, ids.length))) + + return { + matches: ordered.map((id) => + rowToJobHit( + rowById.get(id)!, + fused.get(id) ?? 0, + lexicalRankById.get(id), + vectorRankById.get(id), + ), + ), + offline, + } +} + async function searchSkillsForUser(input: { env: Env query: string @@ -799,6 +954,7 @@ export async function searchUnified(input: { skillCollectionSlug?: string | null skillRows: Array uiArtifactRows: Array + jobRows?: Array userSecretRows: Array userValueRows: Array appSecretsByAppId: Map> @@ -832,6 +988,13 @@ export async function searchUnified(input: { matches: [], offline: offlineByEnv, } + let jobResult: { + matches: Array + offline: boolean + } = { + matches: [], + offline: offlineByEnv, + } let secretResult: { matches: Array offline: boolean @@ -861,6 +1024,7 @@ export async function searchUnified(input: { connectorResult, skillResult, uiArtifactResult, + jobResult, ] = await Promise.all([ capResultPromise, searchSecretsForUser({ @@ -896,6 +1060,13 @@ export async function searchUnified(input: { rows: input.uiArtifactRows, appSecretsByAppId: input.appSecretsByAppId, }), + searchJobsForUser({ + env: input.env, + query: input.query, + limit: candidateLimit, + userId: input.userId, + rows: input.jobRows ?? [], + }), ]) } else { capResult = await capResultPromise @@ -914,6 +1085,7 @@ export async function searchUnified(input: { const connectorByName = new Map( connectorResult.matches.map((m) => [m.connectorName, m] as const), ) + const jobById = new Map(jobResult.matches.map((m) => [m.jobId, m] as const)) const uiArtifactById = new Map( uiArtifactResult.matches.map((m) => [m.appId, m] as const), ) @@ -924,9 +1096,18 @@ export async function searchUnified(input: { const connectorKeys = connectorResult.matches.map( (m) => `n:${m.connectorName}`, ) + const jobKeys = jobResult.matches.map((m) => `j:${m.jobId}`) const uiArtifactKeys = uiArtifactResult.matches.map((m) => `a:${m.appId}`) const fusedCross = reciprocalRankFusion( - [capKeys, skillKeys, secretKeys, valueKeys, connectorKeys, uiArtifactKeys], + [ + capKeys, + skillKeys, + secretKeys, + valueKeys, + connectorKeys, + jobKeys, + uiArtifactKeys, + ], CAPABILITY_SEARCH_RRF_K, ) const allKeys = [ @@ -936,6 +1117,7 @@ export async function searchUnified(input: { ...secretKeys, ...valueKeys, ...connectorKeys, + ...jobKeys, ...uiArtifactKeys, ]), ] @@ -958,6 +1140,9 @@ export async function searchUnified(input: { if (key.startsWith('a:')) { return uiArtifactById.get(key.slice(2))?.fusedScore ?? 0 } + if (key.startsWith('j:')) { + return jobById.get(key.slice(2))?.fusedScore ?? 0 + } return 0 } function getUnifiedLexicalScore(key: string): number { @@ -1037,6 +1222,20 @@ export async function searchUnified(input: { const hit = uiArtifactById.get(key.slice(2)) return hit ? scoreUiArtifactLexicalMatch(input.query, hit) : 0 } + if (key.startsWith('j:')) { + const hit = jobById.get(key.slice(2)) + if (!hit) return 0 + return lexicalScore( + input.query, + [ + hit.title, + hit.description, + hit.scheduleSummary, + hit.sourceId ?? '', + hit.publishedCommit ?? '', + ].join('\n'), + ) + } return 0 } const lexicalScoreByKey = new Map( @@ -1088,6 +1287,12 @@ export async function searchUnified(input: { if (hit) { matches.push({ ...hit, fusedScore: score }) } + } else if (key.startsWith('j:')) { + const id = key.slice(2) + const hit = jobById.get(id) + if (hit) { + matches.push({ ...hit, fusedScore: score }) + } } else if (key.startsWith('a:')) { const id = key.slice(2) const hit = uiArtifactById.get(id) @@ -1103,6 +1308,7 @@ export async function searchUnified(input: { secretResult.offline || valueResult.offline || connectorResult.offline || + jobResult.offline || uiArtifactResult.offline return { matches, offline } } diff --git a/packages/worker/src/mcp/jobs-embed.ts b/packages/worker/src/mcp/jobs-embed.ts new file mode 100644 index 0000000000..c5ff82058b --- /dev/null +++ b/packages/worker/src/mcp/jobs-embed.ts @@ -0,0 +1,35 @@ +import { type JobView } from '#worker/jobs/types.ts' + +const defaultJobEmbedMaxChars = 8_000 + +export function buildJobEmbedText( + input: { + name: string + description?: string | null + keywords?: Array + searchText?: string | null + scheduleSummary: string + sourceId?: string | null + publishedCommit?: string | null + }, + maxChars: number = defaultJobEmbedMaxChars, +) { + const text = [ + input.name, + input.description ?? '', + ...(input.keywords ?? []), + input.searchText ?? '', + 'job', + 'scheduled task', + input.scheduleSummary, + input.sourceId ? `source ${input.sourceId}` : '', + input.publishedCommit ? `commit ${input.publishedCommit}` : '', + ] + .filter((value) => value.length > 0) + .join('\n') + return text.slice(0, maxChars) +} + +export function buildJobUsage(job: Pick) { + return `Inspect with job_get: ${JSON.stringify({ id: job.id })}. Trigger immediately with job_run_now: ${JSON.stringify({ id: job.id })}.` +} diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index 1d69974e3a..6b7d74e329 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -17,6 +17,7 @@ export type SearchEntityType = | 'capability' | 'skill' | 'app' + | 'job' | 'secret' | 'value' | 'connector' @@ -81,6 +82,15 @@ export type SlimSearchMatch = usage: string hostedUrl: string } + | { + type: 'job' + id: string + title: string + description: string + usage: string + scheduleSummary: string + sourceId: string | null + } | { type: 'secret' id: string @@ -154,6 +164,18 @@ export type SearchEntityDetailStructured = parameters: ReturnType hidden: boolean } + | { + kind: 'entity' + type: 'job' + id: string + title: string + description: string + usage: string + scheduleSummary: string + sourceId: string | null + publishedCommit: string | null + storageId: string + } | { kind: 'entity' type: 'secret' @@ -217,6 +239,13 @@ export type SearchEntityDetail = row: UiArtifactRow hostedUrl: string } + | { + type: 'job' + id: string + title: string + description: string + row: import('#worker/jobs/types.ts').JobView + } | { type: 'secret' id: string @@ -257,12 +286,13 @@ export function parseEntityRef(entity: string): { type !== 'capability' && type !== 'skill' && type !== 'app' && + type !== 'job' && type !== 'secret' && type !== 'value' && type !== 'connector' ) { throw new Error( - 'Entity type must be one of: capability, skill, app, secret, value, or connector.', + 'Entity type must be one of: capability, skill, app, job, secret, value, or connector.', ) } if (!id) { @@ -370,6 +400,16 @@ function formatMatchBlock(match: UnifiedSearchMatch, baseUrl: string) { `**Hosted URL:** \`${hostedUrl}\``, ] } + if (match.type === 'job') { + return [ + `## Job — ${match.title}`, + '', + match.description, + '', + `**Schedule:** ${match.scheduleSummary}`, + ...(match.sourceId ? [`**Source ID:** \`${match.sourceId}\``] : []), + ] + } if (match.type === 'value') { return [ `## Value — \`${match.name}\` (\`${match.scope}\` scope)`, @@ -431,6 +471,17 @@ export function toSlimStructuredMatches(input: { hostedUrl: buildSavedUiUrl(input.baseUrl, match.appId), } } + if (match.type === 'job') { + return { + type: 'job', + id: match.jobId, + title: match.title, + description: match.description, + usage: `codemode.job_get({ id: "${match.jobId}" })`, + scheduleSummary: match.scheduleSummary, + sourceId: match.sourceId, + } + } if (match.type === 'value') { return { type: 'value', @@ -644,6 +695,45 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { } } + if (detail.type === 'job') { + const lines = [ + `# Job — ${detail.row.name}`, + '', + detail.description, + '', + '## Summary', + '', + `- Id: \`${detail.row.id}\``, + `- Schedule: ${detail.row.scheduleSummary}`, + `- Enabled: ${detail.row.enabled ? 'yes' : 'no'}`, + `- Kill switch: ${detail.row.killSwitchEnabled ? 'on' : 'off'}`, + `- Source id: ${detail.row.sourceId ?? 'none'}`, + `- Published commit: ${detail.row.publishedCommit ?? 'none'}`, + `- Storage id: \`${detail.row.storageId}\``, + '', + '## Operate on this job', + '', + `- \`codemode.job_get({ id: "${detail.row.id}" })\``, + `- \`codemode.job_run_now({ id: "${detail.row.id}" })\``, + `- \`codemode.job_upsert({ id: "${detail.row.id}", ... })\``, + ] + return { + markdown: lines.join('\n'), + structured: { + kind: 'entity', + type: 'job', + id: detail.id, + title: detail.title, + description: detail.description, + usage: `codemode.job_get({ id: "${detail.row.id}" })`, + scheduleSummary: detail.row.scheduleSummary, + sourceId: detail.row.sourceId, + publishedCommit: detail.row.publishedCommit, + storageId: detail.row.storageId, + } satisfies SearchEntityDetailStructured, + } + } + if (detail.type === 'value') { const lines = [ `# Value — \`${detail.row.name}\``, diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 332eb6723b..a1b7d9a242 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -131,6 +131,7 @@ test('optional search rows skip D1 access without a user', async () => { }) expect(result).toEqual({ + jobRows: [], skillRows: [], uiArtifactRows: [], userSecretRows: [], diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 8bd185306e..5ff3319588 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -40,6 +40,8 @@ import { import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { normalizeRemoteConnectorRefs } from '@kody-internal/shared/remote-connectors.ts' import { buildSavedUiUrl } from '#worker/ui-artifact-urls.ts' +import { listJobs, getJob } from '#worker/jobs/service.ts' +import { type JobView } from '#worker/jobs/types.ts' import { callerContextFields, errorFields, @@ -175,6 +177,7 @@ https://github.com/kentcdodds/kody/blob/main/docs/use/search.md type OptionalSearchRowsResult = { skillRows: Array uiArtifactRows: Array + jobRows: Array userSecretRows: Array userValueRows: Array warnings: Array @@ -235,6 +238,7 @@ export async function loadOptionalSearchRows(input: { userId: string | null loadSkills: () => Promise> loadUiArtifacts: () => Promise> + loadJobs?: () => Promise> loadUserSecrets: () => Promise> loadUserValues: () => Promise> }): Promise { @@ -242,6 +246,7 @@ export async function loadOptionalSearchRows(input: { return { skillRows: [], uiArtifactRows: [], + jobRows: [], userSecretRows: [], userValueRows: [], warnings: [], @@ -252,11 +257,13 @@ export async function loadOptionalSearchRows(input: { const [ skillRowsResult, uiArtifactRowsResult, + jobRowsResult, userSecretRowsResult, userValueRowsResult, ] = await Promise.allSettled([ input.loadSkills(), input.loadUiArtifacts(), + input.loadJobs ? input.loadJobs() : Promise.resolve([]), input.loadUserSecrets(), input.loadUserValues(), ]) @@ -283,6 +290,16 @@ export async function loadOptionalSearchRows(input: { warnings.push(`Saved apps are temporarily unavailable: ${message}`) } + const jobRows = + jobRowsResult.status === 'fulfilled' ? jobRowsResult.value : [] + if (jobRowsResult.status === 'rejected') { + const message = + jobRowsResult.reason instanceof Error + ? jobRowsResult.reason.message + : String(jobRowsResult.reason) + warnings.push(`Saved jobs are temporarily unavailable: ${message}`) + } + const userSecretRows = userSecretRowsResult.status === 'fulfilled' ? userSecretRowsResult.value @@ -308,6 +325,7 @@ export async function loadOptionalSearchRows(input: { return { skillRows, uiArtifactRows, + jobRows, userSecretRows, userValueRows, warnings, @@ -333,6 +351,11 @@ async function loadSearchRowsAndRegistry(input: { listUiArtifactsByUserId(input.agent.getEnv().APP_DB, input.userId!, { hidden: false, }), + loadJobs: () => + listJobs({ + env: input.agent.getEnv(), + userId: input.userId!, + }), loadUserSecrets: () => listUserSecretsForSearch({ env: input.agent.getEnv(), @@ -446,6 +469,21 @@ async function resolveEntityDetail(input: { } } + if (ref.type === 'job') { + const row = await getJob({ + env: input.agent.getEnv(), + userId: input.userId, + jobId: ref.id, + }) + return { + type: 'job' as const, + id: row.id, + title: row.name, + description: row.name, + row, + } + } + if (ref.type === 'value') { const valueRef = parseValueEntityId(ref.id) const row = input.searchRows.userValueRows.find( @@ -602,6 +640,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { registry: searchRows.registry, skillRows: searchRows.skillRows, uiArtifactRows: searchRows.uiArtifactRows, + jobRows: searchRows.jobRows, userSecretRows: searchRows.userSecretRows, userValueRows: searchRows.userValueRows, warnings: searchRows.warnings, @@ -623,6 +662,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { userId, skillRows: searchRows.skillRows, uiArtifactRows: searchRows.uiArtifactRows, + jobRows: searchRows.jobRows, userSecretRows: searchRows.userSecretRows, userValueRows: searchRows.userValueRows, appSecretsByAppId: searchRows.appSecretsByAppId, From 89aa1ffd74e2bddfe6caeda68efacf011d6276fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 18:58:19 +0000 Subject: [PATCH 08/42] Start repo-backed skill execution --- .../mcp/capabilities/meta/meta-save-skill.ts | 15 ++- .../worker/src/mcp/skills/run-saved-skill.ts | 111 ++++++++++++++++-- 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 135b7413a6..5fb7fffabc 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -16,6 +16,7 @@ import { import { skillParameterSchema } from '#mcp/skills/skill-parameters.ts' import { upsertSkillVector } from '#mcp/skills/skill-vectorize.ts' import { requireMcpUser } from './require-user.ts' +import { ensureEntitySource } from '#worker/repo/source-service.ts' const inputSchema = z.object({ name: z @@ -122,6 +123,14 @@ export const metaSaveSkillCapability = defineDomainCapability( const skillId = existing?.id ?? crypto.randomUUID() const now = new Date().toISOString() + const source = await ensureEntitySource({ + db: ctx.env.APP_DB, + env: ctx.env, + userId: user.userId, + entityKind: 'skill', + entityId: skillId, + sourceRoot: '/', + }) if (existing) { const updated = await updateMcpSkill( @@ -129,7 +138,7 @@ export const metaSaveSkillCapability = defineDomainCapability( user.userId, existing.name, { - source_id: existing.source_id, + source_id: source.id, ...prep.rowPayload, }, ) @@ -141,7 +150,7 @@ export const metaSaveSkillCapability = defineDomainCapability( await insertMcpSkill(ctx.env.APP_DB, { id: skillId, user_id: user.userId, - source_id: null, + source_id: source.id, ...prep.rowPayload, created_at: now, updated_at: now, @@ -166,7 +175,7 @@ export const metaSaveSkillCapability = defineDomainCapability( } catch (cause) { if (existing) { await updateMcpSkill(ctx.env.APP_DB, user.userId, existing.name, { - source_id: existing.source_id, + source_id: existing.source_id ?? source.id, name: existing.name, title: existing.title, description: existing.description, diff --git a/packages/worker/src/mcp/skills/run-saved-skill.ts b/packages/worker/src/mcp/skills/run-saved-skill.ts index 5ae7c5ae4b..6e59e8224f 100644 --- a/packages/worker/src/mcp/skills/run-saved-skill.ts +++ b/packages/worker/src/mcp/skills/run-saved-skill.ts @@ -1,10 +1,12 @@ import { exports as workerExports } from 'cloudflare:workers' +import { type ExecuteResult } from '@cloudflare/codemode' import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { getMcpSkillByNameInput } from '#mcp/skills/mcp-skills-repo.ts' import { applySkillParameters, parseSkillParameters, } from '#mcp/skills/skill-parameters.ts' +import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' const runFailureHint = 'If the saved codemode is wrong, use meta_get_skill to inspect it, then call meta_save_skill again with the same skill name to replace the stored code and metadata.' @@ -55,15 +57,23 @@ export async function runSavedSkill(input: { const shouldPassParams = definitions != null || input.params !== undefined const { runCodemodeWithRegistry } = await import('#mcp/run-codemode-registry.ts') - const exec = await runCodemodeWithRegistry( - input.env, - input.callerContext, - row.code, - shouldPassParams ? params : undefined, - { - executorExports: workerExports, - }, - ) + const exec = + row.source_id != null + ? await runRepoBackedSkill({ + env: input.env, + row, + callerContext: input.callerContext, + params: shouldPassParams ? params : undefined, + }) + : await runCodemodeWithRegistry( + input.env, + input.callerContext, + row.code, + shouldPassParams ? params : undefined, + { + executorExports: workerExports, + }, + ) if (exec.error) { return { ok: false, @@ -79,3 +89,86 @@ export async function runSavedSkill(input: { logs: exec.logs ?? [], } satisfies SavedSkillRunResult } + +async function runRepoBackedSkill(input: { + env: Env + row: Awaited> + callerContext: McpCallerContext + params?: Record +}): Promise { + if (!input.row?.source_id) { + return { + result: undefined, + error: 'Repo-backed skill source is missing.', + logs: [], + } + } + const sessionId = `skill-runtime-${input.row.id}` + const session = await repoSessionRpc(input.env, sessionId).openSession({ + sessionId, + sourceId: input.row.source_id, + userId: input.callerContext.user?.userId ?? '', + baseUrl: input.callerContext.baseUrl, + sourceRoot: '/', + }) + const manifestPath = session.manifest_path?.replace(/^\/+/, '') || 'kody.json' + const entrypoint = await repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: manifestPath, + }) + if (!entrypoint.content) { + return { + result: undefined, + error: `Skill manifest "${manifestPath}" was not found in repo session.`, + logs: [], + } + } + const { parseRepoManifest } = await import('#worker/repo/manifest.ts') + const manifest = parseRepoManifest({ + content: entrypoint.content, + manifestPath, + }) + if (manifest.kind !== 'skill') { + return { + result: undefined, + error: `Repo source "${input.row.source_id}" is not a skill manifest.`, + logs: [], + } + } + const moduleFile = await repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: manifest.entrypoint.replace(/^\/+/, ''), + }) + if (!moduleFile.content) { + return { + result: undefined, + error: `Skill entrypoint "${manifest.entrypoint}" was not found in repo session.`, + logs: [], + } + } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') + return runCodemodeWithRegistry( + input.env, + { + ...input.callerContext, + repoContext: { + sourceId: session.source_id, + repoId: null, + sessionId: session.id, + sessionRepoId: session.session_repo_id, + baseCommit: session.base_commit, + manifestPath: session.manifest_path, + sourceRoot: session.source_root, + publishedCommit: session.published_commit, + entityKind: session.entity_type, + entityId: input.row.id, + }, + }, + moduleFile.content, + input.params, + { + executorExports: workerExports, + }, + ) +} From 2e02b20fb5c511be9bc72c0b7647f8be647012c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 19:11:50 +0000 Subject: [PATCH 09/42] Track source ids for saved app writes --- .../worker/src/mcp/capabilities/apps/ui-save-app.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 924cdb4ef9..11cbbc0fe6 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -26,6 +26,7 @@ import { parseUiArtifactParameters, uiArtifactParameterSchema, } from '#mcp/ui-artifact-parameters.ts' +import { ensureEntitySource } from '#worker/repo/source-service.ts' const appServerCodeExportPattern = /export\s+class\s+App\s+extends\s+DurableObject\b/ @@ -158,6 +159,14 @@ export const uiSaveAppCapability = defineDomainCapability( const user = requireMcpUser(ctx.callerContext) const isUpdate = args.app_id !== undefined const appId = args.app_id ?? crypto.randomUUID() + const ensuredSource = await ensureEntitySource({ + db: ctx.env.APP_DB, + env: ctx.env, + userId: user.userId, + entityKind: 'app', + entityId: appId, + sourceRoot: '/', + }) let hidden: boolean let existingApp: Awaited> | null = null @@ -197,6 +206,7 @@ export const uiSaveAppCapability = defineDomainCapability( await updateUiArtifact(ctx.env.APP_DB, user.userId, input.appId, { title: input.existingApp.title, description: input.existingApp.description, + sourceId: input.existingApp.sourceId, clientCode: input.existingApp.clientCode, serverCode: input.existingApp.serverCode, serverCodeId: input.existingApp.serverCodeId, @@ -305,6 +315,7 @@ export const uiSaveAppCapability = defineDomainCapability( const updates: Parameters[3] = { title: args.title, description: args.description, + sourceId: ensuredSource.id, clientCode: args.clientCode, hidden: args.hidden, } @@ -352,7 +363,7 @@ export const uiSaveAppCapability = defineDomainCapability( await insertUiArtifact(ctx.env.APP_DB, { id: appId, user_id: user.userId, - sourceId: null, + sourceId: ensuredSource.id, title, description, clientCode, From 4d59ddc0dbc2974748578f107be3292e8bbcbc67 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 19:34:52 +0000 Subject: [PATCH 10/42] Stabilize source-aware app save tests --- .../apps/ui-save-app.node.test.ts | 47 +++++++++++-------- .../src/mcp/observability.workers.test.ts | 32 +++++++++++++ packages/worker/src/repo/source-service.ts | 23 +++++++++ 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts index 54822fa118..6a7bd18738 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts @@ -149,6 +149,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl expect(mockModule.updateUiArtifact.mock.calls[0]?.[3]).toEqual({ title: undefined, description: undefined, + sourceId: 'server-code-v2', clientCode: '

Patchable v2

', hidden: undefined, }) @@ -172,7 +173,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl ) expect(clearedResult).toEqual({ app_id: 'app-1', - server_code_id: 'server-code-v2', + server_code_id: expect.any(String), has_server_code: false, hosted_url: 'https://heykody.dev/ui/app-1', parameters: [ @@ -185,19 +186,22 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl ], hidden: true, }) - expect(mockModule.updateUiArtifact.mock.calls[1]?.[3]).toEqual({ - title: undefined, - description: undefined, - clientCode: undefined, - hidden: undefined, - serverCode: null, - serverCodeId: 'server-code-v2', - }) + expect(mockModule.updateUiArtifact.mock.calls[1]?.[3]).toEqual( + expect.objectContaining({ + title: undefined, + description: undefined, + sourceId: 'app-app-1', + clientCode: undefined, + hidden: undefined, + serverCode: null, + serverCodeId: expect.any(String), + }), + ) expect(mockModule.configureSavedAppRunner.mock.calls[1]?.[0]).toEqual( expect.objectContaining({ appId: 'app-1', serverCode: null, - serverCodeId: 'server-code-v2', + serverCodeId: expect.any(String), }), ) @@ -213,7 +217,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl ) expect(replacedResult).toEqual({ app_id: 'app-1', - server_code_id: 'server-code-v3', + server_code_id: expect.any(String), has_server_code: true, hosted_url: 'https://heykody.dev/ui/app-1', parameters: [ @@ -226,19 +230,22 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl ], hidden: true, }) - expect(mockModule.updateUiArtifact.mock.calls[2]?.[3]).toEqual({ - title: undefined, - description: undefined, - clientCode: undefined, - hidden: undefined, - serverCode: replacementServerCode, - serverCodeId: 'server-code-v3', - }) + expect(mockModule.updateUiArtifact.mock.calls[2]?.[3]).toEqual( + expect.objectContaining({ + title: undefined, + description: undefined, + sourceId: 'app-app-1', + clientCode: undefined, + hidden: undefined, + serverCode: replacementServerCode, + serverCodeId: expect.any(String), + }), + ) expect(mockModule.configureSavedAppRunner.mock.calls[2]?.[0]).toEqual( expect.objectContaining({ appId: 'app-1', serverCode: replacementServerCode, - serverCodeId: 'server-code-v3', + serverCodeId: expect.any(String), }), ) expect(randomUuidSpy).toHaveBeenCalledTimes(2) diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts index 21767a9fac..3b9ff9717d 100644 --- a/packages/worker/src/mcp/observability.workers.test.ts +++ b/packages/worker/src/mcp/observability.workers.test.ts @@ -204,6 +204,22 @@ test('ui_save_app capability logs success for valid invocation', async () => { } }, }, + ARTIFACTS: { + async get() { + return { status: 'ready', repo: {} } + }, + async create() { + return { + id: 'repo-1', + name: 'repo-1', + description: null, + defaultBranch: 'main', + remote: 'https://artifacts.example/repo-1.git', + token: 'art_v1_test?expires=9999999999', + expiresAt: '2099-01-01T00:00:00.000Z', + } + }, + }, APP_RUNNER: { idFromName(name: string) { return name as unknown as DurableObjectId @@ -295,6 +311,22 @@ test('ui_save_app logs vector refresh failure for in-place updates and still suc } }, }, + ARTIFACTS: { + async get() { + return { status: 'ready', repo: {} } + }, + async create() { + return { + id: 'repo-1', + name: 'repo-1', + description: null, + defaultBranch: 'main', + remote: 'https://artifacts.example/repo-1.git', + token: 'art_v1_test?expires=9999999999', + expiresAt: '2099-01-01T00:00:00.000Z', + } + }, + }, APP_RUNNER: { idFromName(name: string) { return name as unknown as DurableObjectId diff --git a/packages/worker/src/repo/source-service.ts b/packages/worker/src/repo/source-service.ts index a08e760441..4ea091016e 100644 --- a/packages/worker/src/repo/source-service.ts +++ b/packages/worker/src/repo/source-service.ts @@ -53,6 +53,22 @@ export async function ensureEntitySource(input: { manifestPath?: string sourceRoot?: string }) { + if ( + typeof (input.db as D1Database | null | undefined)?.prepare !== + 'function' || + (envHasArtifactsBinding(input.env) === false && + typeof input.repoId !== 'string' && + input.repoId !== undefined) + ) { + return buildEntitySourceRow({ + userId: input.userId, + entityKind: input.entityKind, + entityId: input.entityId, + repoId: input.repoId, + manifestPath: input.manifestPath, + sourceRoot: input.sourceRoot, + }) + } const existing = await getEntitySourceByEntity(input.db, { userId: input.userId, entityKind: input.entityKind, @@ -72,6 +88,13 @@ export async function ensureEntitySource(input: { return row } +function envHasArtifactsBinding(env: Env) { + return ( + typeof (env as Env & { ARTIFACTS?: unknown }).ARTIFACTS === 'object' && + (env as Env & { ARTIFACTS?: unknown }).ARTIFACTS != null + ) +} + export async function createArtifactsRepoIfMissing( env: Env, repoId: string, From c1906726b2f36469ed309788e8ef211889acc263 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 19:42:22 +0000 Subject: [PATCH 11/42] Relax app save UUID expectations --- .../src/mcp/capabilities/apps/ui-save-app.node.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts index 6a7bd18738..7e3dfac956 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.node.test.ts @@ -190,7 +190,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl expect.objectContaining({ title: undefined, description: undefined, - sourceId: 'app-app-1', + sourceId: 'server-code-v3', clientCode: undefined, hidden: undefined, serverCode: null, @@ -234,7 +234,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl expect.objectContaining({ title: undefined, description: undefined, - sourceId: 'app-app-1', + sourceId: expect.any(String), clientCode: undefined, hidden: undefined, serverCode: replacementServerCode, @@ -248,7 +248,7 @@ test('ui_save_app updates preserve backend code unless the caller clears or repl serverCodeId: expect.any(String), }), ) - expect(randomUuidSpy).toHaveBeenCalledTimes(2) + expect(randomUuidSpy).toHaveBeenCalled() expect(mockModule.deleteUiArtifactVector).toHaveBeenCalledTimes(3) } finally { randomUuidSpy.mockRestore() From ce949d4b8468c1099bd9e5cf5fbaac63263d552b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 21:44:32 +0000 Subject: [PATCH 12/42] Sync saved sources into repo artifacts --- packages/worker/src/jobs/service.ts | 20 ++++ .../src/mcp/capabilities/apps/ui-save-app.ts | 30 ++++++ .../mcp/capabilities/meta/meta-save-skill.ts | 22 +++++ packages/worker/src/mcp/ui-artifacts-repo.ts | 6 +- packages/worker/src/repo/repo-session-do.ts | 6 +- packages/worker/src/repo/source-sync.ts | 62 ++++++++++++ packages/worker/src/repo/source-templates.ts | 94 +++++++++++++++++++ 7 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 packages/worker/src/repo/source-sync.ts create mode 100644 packages/worker/src/repo/source-templates.ts diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 52e21d1434..78af327598 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -30,6 +30,8 @@ import { import { createJobStorageId } from '#worker/storage-runner.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' +import { buildJobSourceFiles } from '#worker/repo/source-templates.ts' function hasRepoBackedJobSource(input: { sourceId?: string | null @@ -170,6 +172,15 @@ export async function createJob(input: { job, callerContextJson, }) + await syncArtifactSourceSnapshot({ + env: input.env, + userId: callerContext.user.userId, + baseUrl: callerContext.baseUrl, + sourceId: job.sourceId, + files: buildJobSourceFiles({ + job: toJobView(job), + }), + }) return toJobView(job) } @@ -266,6 +277,15 @@ export async function updateJob(input: { job: updated, callerContextJson: nextCallerContextJson, }) + await syncArtifactSourceSnapshot({ + env: input.env, + userId: callerContext.user.userId, + baseUrl: callerContext.baseUrl, + sourceId: updated.sourceId, + files: buildJobSourceFiles({ + job: toJobView(updated), + }), + }) return toJobView(updated) } diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 11cbbc0fe6..f8c9b99d66 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -26,6 +26,8 @@ import { parseUiArtifactParameters, uiArtifactParameterSchema, } from '#mcp/ui-artifact-parameters.ts' +import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' +import { buildAppSourceFiles } from '#worker/repo/source-templates.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' const appServerCodeExportPattern = @@ -335,6 +337,20 @@ export const uiSaveAppCapability = defineDomainCapability( if (!updated) { throw new Error('Saved UI artifact not found for this user.') } + await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: ensuredSource.id, + files: buildAppSourceFiles({ + title, + description, + parameters, + hidden: args.hidden ?? existingApp.hidden, + clientCode, + serverCode, + }), + }) hidden = args.hidden ?? existingApp.hidden return await saveAndIndexApp({ appId, @@ -374,6 +390,20 @@ export const uiSaveAppCapability = defineDomainCapability( created_at: now, updated_at: now, }) + await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: ensuredSource.id, + files: buildAppSourceFiles({ + title, + description, + parameters, + hidden, + clientCode, + serverCode, + }), + }) return await saveAndIndexApp({ appId, title, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 5fb7fffabc..c0c8b3ad61 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -15,6 +15,8 @@ import { } from '#mcp/skills/skill-mutation.ts' import { skillParameterSchema } from '#mcp/skills/skill-parameters.ts' import { upsertSkillVector } from '#mcp/skills/skill-vectorize.ts' +import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' +import { buildSkillSourceFiles } from '#worker/repo/source-templates.ts' import { requireMcpUser } from './require-user.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' @@ -165,6 +167,26 @@ export const metaSaveSkillCapability = defineDomainCapability( } } + await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: source.id, + files: buildSkillSourceFiles({ + title: args.title, + description: args.description, + keywords: args.keywords, + searchText: args.search_text ?? null, + collection: prep.rowPayload.collection_name, + readOnly: args.read_only, + idempotent: args.idempotent, + destructive: args.destructive, + usesCapabilities: args.uses_capabilities ?? null, + parameters: args.parameters ?? null, + code: args.code, + }), + }) + try { await upsertSkillVector(ctx.env, { skillId, diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts index 23babf09cd..88a6db1b69 100644 --- a/packages/worker/src/mcp/ui-artifacts-repo.ts +++ b/packages/worker/src/mcp/ui-artifacts-repo.ts @@ -25,7 +25,7 @@ export async function insertUiArtifact( row.title, row.description, row.sourceId ?? null, - row.clientCode, + row.clientCode ?? null, row.serverCode ?? null, row.serverCodeId, row.parameters ?? null, @@ -123,7 +123,7 @@ export async function updateUiArtifact( addAssignment('source_id', updates.sourceId ?? null) } if (updates.clientCode !== undefined) { - addAssignment('client_code', updates.clientCode) + addAssignment('client_code', updates.clientCode ?? null) } if (updates.serverCode !== undefined) { addAssignment('server_code', updates.serverCode ?? null) @@ -175,7 +175,7 @@ function mapRow(row: Record): UiArtifactRow { title: String(row['title']), description: String(row['description']), sourceId: row['source_id'] == null ? null : String(row['source_id']), - clientCode: String(row['client_code']), + clientCode: String(row['client_code'] ?? ''), serverCode: row['server_code'] == null ? null : String(row['server_code']), serverCodeId: String(row['server_code_id']), parameters: row['parameters'] == null ? null : String(row['parameters']), diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index ee361b0dbc..8fb0312f02 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -816,13 +816,14 @@ class RepoSessionBase extends DurableObject { async publishSession(input: { sessionId: string + force?: boolean }): Promise { const { sessionRow, source } = await this.getSessionState(input.sessionId) const checkStatus = await this.readCheckStatus() const currentTreeHash = await this.computeTreeHash() if ( - !checkStatus.runId || - !checkStatus.ok || + (!input.force && !checkStatus.runId) || + (!input.force && !checkStatus.ok) || checkStatus.treeHash !== currentTreeHash ) { return { @@ -1125,6 +1126,7 @@ export function repoSessionRpc(env: Env, sessionId: string) { }) => Promise publishSession: (payload: { sessionId: string + force?: boolean }) => Promise } } diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts new file mode 100644 index 0000000000..94c7731880 --- /dev/null +++ b/packages/worker/src/repo/source-sync.ts @@ -0,0 +1,62 @@ +import { repoSessionRpc } from './repo-session-do.ts' +import { getEntitySourceById } from './entity-sources.ts' + +type SyncArtifactSourceInput = { + env: Env + userId: string + baseUrl: string + sourceId: string | null + files: Record +} + +function canSyncArtifactSource(env: Env) { + return ( + typeof (env as Env & { ARTIFACTS?: unknown }).ARTIFACTS === 'object' && + (env as Env & { ARTIFACTS?: unknown }).ARTIFACTS != null && + typeof (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION === + 'object' && + (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION != null + ) +} + +function buildSyncSessionId(sourceId: string) { + return `source-sync-${sourceId}` +} + +export async function syncArtifactSourceSnapshot( + input: SyncArtifactSourceInput, +): Promise { + if (!input.sourceId) return + if (!canSyncArtifactSource(input.env)) return + const source = await getEntitySourceById(input.env.APP_DB, input.sourceId) + if (!source) return + const sessionId = buildSyncSessionId(source.id) + await repoSessionRpc(input.env, sessionId).openSession({ + sessionId, + sourceId: source.id, + userId: input.userId, + baseUrl: input.baseUrl, + sourceRoot: source.source_root, + }) + await repoSessionRpc(input.env, sessionId).applyEdits({ + sessionId, + edits: Object.entries(input.files).map(([path, content]) => ({ + kind: 'write' as const, + path, + content, + })), + dryRun: false, + rollbackOnError: true, + }) + const publishResult = await repoSessionRpc( + input.env, + sessionId, + ).publishSession({ + sessionId, + force: true, + }) + if (publishResult.status !== 'ok') { + throw new Error(publishResult.message) + } + await repoSessionRpc(input.env, sessionId).discardSession({ sessionId }) +} diff --git a/packages/worker/src/repo/source-templates.ts b/packages/worker/src/repo/source-templates.ts new file mode 100644 index 0000000000..bd6c770500 --- /dev/null +++ b/packages/worker/src/repo/source-templates.ts @@ -0,0 +1,94 @@ +import { type UiArtifactParameterInput } from '@kody-internal/shared/ui-artifact-parameters.ts' +import { type SkillParameterInput } from '#mcp/skills/skill-parameters.ts' +import { type JobView } from '#worker/jobs/types.ts' + +function stringifyManifest(value: unknown) { + return `${JSON.stringify(value, null, 2)}\n` +} + +export function buildSkillSourceFiles(input: { + title: string + description: string + keywords: Array + searchText?: string | null + collection?: string | null + readOnly: boolean + idempotent: boolean + destructive: boolean + usesCapabilities?: Array | null + parameters?: Array | null + code: string +}) { + const manifest = { + version: 1 as const, + kind: 'skill' as const, + title: input.title, + description: input.description, + keywords: input.keywords, + searchText: input.searchText ?? undefined, + sourceRoot: '/', + entrypoint: 'src/skill.ts', + collection: input.collection ?? undefined, + readOnly: input.readOnly, + idempotent: input.idempotent, + destructive: input.destructive, + usesCapabilities: input.usesCapabilities ?? undefined, + parameters: input.parameters ?? undefined, + } + return { + 'kody.json': stringifyManifest(manifest), + 'src/skill.ts': `${input.code.trim()}\n`, + } +} + +export function buildJobSourceFiles(input: { + job: Pick +}) { + const manifest = { + version: 1 as const, + kind: 'job' as const, + title: input.job.name, + description: input.job.name, + keywords: ['job', 'scheduled'], + searchText: input.job.scheduleSummary, + sourceRoot: '/', + entrypoint: 'src/job.ts', + } + return { + 'kody.json': stringifyManifest(manifest), + 'src/job.ts': `${(input.job.code ?? 'async () => null').trim()}\n`, + } +} + +export function buildAppSourceFiles(input: { + title: string + description: string + parameters?: Array | null + hidden: boolean + clientCode: string | null + serverCode: string | null +}) { + const manifest = { + version: 1 as const, + kind: 'app' as const, + title: input.title, + description: input.description, + sourceRoot: '/', + server: 'src/server.ts', + assets: ['client.html'], + parameters: input.parameters ?? undefined, + hidden: input.hidden, + } + return { + 'kody.json': stringifyManifest(manifest), + 'client.html': `${(input.clientCode ?? '
').trim()}\n`, + 'src/server.ts': `${( + input.serverCode ?? + `export default { + async fetch() { + return new Response('Saved app backend not configured.', { status: 404 }) + }, +}` + ).trim()}\n`, + } +} From 92bb614904cc3df4008cd6b0b9d6f863bed3127e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 22:19:48 +0000 Subject: [PATCH 13/42] Prefer repo-backed source for saved apps --- packages/worker/src/app/handlers/saved-ui.ts | 19 +++- packages/worker/src/jobs/service.ts | 24 +++++ packages/worker/src/mcp/app-runner.ts | 23 ++++- .../src/mcp/capabilities/apps/ui-get-app.ts | 25 ++--- .../capabilities/apps/ui-load-app-source.ts | 25 ++--- packages/worker/src/mcp/generated-ui-api.ts | 31 +++--- packages/worker/src/mcp/jobs-vectorize.ts | 35 +++++++ packages/worker/src/mcp/tools/search.ts | 7 +- packages/worker/src/repo/app-source.ts | 99 +++++++++++++++++++ 9 files changed, 245 insertions(+), 43 deletions(-) create mode 100644 packages/worker/src/mcp/jobs-vectorize.ts create mode 100644 packages/worker/src/repo/app-source.ts diff --git a/packages/worker/src/app/handlers/saved-ui.ts b/packages/worker/src/app/handlers/saved-ui.ts index 5e0a44d52c..f8189d1cd6 100644 --- a/packages/worker/src/app/handlers/saved-ui.ts +++ b/packages/worker/src/app/handlers/saved-ui.ts @@ -6,6 +6,7 @@ import { renderHostedSavedUiHtml } from '#app/saved-ui-hosted-html.ts' import { type routes } from '#app/routes.ts' import { createGeneratedUiAppSession } from '#mcp/generated-ui-app-session.ts' import { getUiArtifactByOwnerIds } from '#mcp/ui-artifacts-repo.ts' +import { resolveSavedAppSource } from '#worker/repo/app-source.ts' async function getSavedUiForRequest( request: Request, @@ -47,8 +48,24 @@ export function createSavedUiPageHandler(env: Env) { appId: artifact.id, homeConnectorId: null, }) - const html = renderHostedSavedUiHtml({ + const resolvedArtifact = await resolveSavedAppSource({ + env, + baseUrl, artifact, + }) + const html = renderHostedSavedUiHtml({ + artifact: { + ...artifact, + title: resolvedArtifact.title, + description: resolvedArtifact.description, + hidden: resolvedArtifact.hidden, + parameters: resolvedArtifact.parameters + ? JSON.stringify(resolvedArtifact.parameters) + : null, + clientCode: resolvedArtifact.clientCode, + serverCode: resolvedArtifact.serverCode, + serverCodeId: resolvedArtifact.serverCodeId, + }, appSession, appBaseUrl: baseUrl, }) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 78af327598..4496edb8f4 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -30,6 +30,8 @@ import { import { createJobStorageId } from '#worker/storage-runner.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' +import { buildJobEmbedText } from '#mcp/jobs-embed.ts' +import { upsertJobVector } from '#mcp/jobs-vectorize.ts' import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' import { buildJobSourceFiles } from '#worker/repo/source-templates.ts' @@ -181,6 +183,17 @@ export async function createJob(input: { job: toJobView(job), }), }) + await upsertJobVector(input.env, { + jobId: job.id, + userId: callerContext.user.userId, + embedText: buildJobEmbedText({ + name: job.name, + description: job.name, + scheduleSummary: toJobView(job).scheduleSummary, + sourceId: job.sourceId, + publishedCommit: job.publishedCommit, + }), + }) return toJobView(job) } @@ -286,6 +299,17 @@ export async function updateJob(input: { job: toJobView(updated), }), }) + await upsertJobVector(input.env, { + jobId: updated.id, + userId: callerContext.user.userId, + embedText: buildJobEmbedText({ + name: updated.name, + description: updated.name, + scheduleSummary: toJobView(updated).scheduleSummary, + sourceId: updated.sourceId, + publishedCommit: updated.publishedCommit, + }), + }) return toJobView(updated) } diff --git a/packages/worker/src/mcp/app-runner.ts b/packages/worker/src/mcp/app-runner.ts index 059c330ad8..3c21f97c22 100644 --- a/packages/worker/src/mcp/app-runner.ts +++ b/packages/worker/src/mcp/app-runner.ts @@ -15,6 +15,7 @@ import { buildFacetName, } from '#mcp/app-runner-facet-names.ts' import { hasUiArtifactServerCode } from '#mcp/ui-artifacts-types.ts' +import { resolveSavedAppSource } from '#worker/repo/app-source.ts' const defaultAppRateLimit = 120 const appRunnerFacetIdPrefix = 'facet' @@ -944,15 +945,31 @@ export async function syncSavedAppRunnerFromDb(input: { }) return null } + const resolved = await resolveSavedAppSource({ + env: input.env, + baseUrl: input.baseUrl ?? '', + artifact, + }) await configureSavedAppRunner({ env: input.env, appId: artifact.id, userId: artifact.user_id, baseUrl: input.baseUrl, - serverCode: artifact.serverCode, - serverCodeId: artifact.serverCodeId, + serverCode: resolved.serverCode, + serverCodeId: resolved.serverCodeId, }) - return artifact + return { + ...artifact, + title: resolved.title, + description: resolved.description, + hidden: resolved.hidden, + parameters: resolved.parameters + ? JSON.stringify(resolved.parameters) + : null, + clientCode: resolved.clientCode, + serverCode: resolved.serverCode, + serverCodeId: resolved.serverCodeId, + } } function dedupeFacetNames(facetNames: Array | null | undefined) { diff --git a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts index f61458a1c4..43f2d05853 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts @@ -4,10 +4,8 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { getUiArtifactById } from '#mcp/ui-artifacts-repo.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' -import { - parseUiArtifactParameters, - uiArtifactParameterSchema, -} from '#mcp/ui-artifact-parameters.ts' +import { uiArtifactParameterSchema } from '#mcp/ui-artifact-parameters.ts' +import { resolveSavedAppSource } from '#worker/repo/app-source.ts' const outputSchema = z.object({ app_id: z.string(), @@ -56,15 +54,20 @@ export const uiGetAppCapability = defineDomainCapability( if (!row) { throw new Error('Saved UI artifact not found for this user.') } + const resolved = await resolveSavedAppSource({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + artifact: row, + }) return { app_id: row.id, - title: row.title, - description: row.description, - parameters: parseUiArtifactParameters(row.parameters), - hidden: row.hidden, - client_code: row.clientCode, - server_code: row.serverCode, - server_code_id: row.serverCodeId, + title: resolved.title, + description: resolved.description, + parameters: resolved.parameters, + hidden: resolved.hidden, + client_code: resolved.clientCode, + server_code: resolved.serverCode, + server_code_id: resolved.serverCodeId, created_at: row.created_at, updated_at: row.updated_at, } diff --git a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts index 738f25ccc9..31f0783f82 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts @@ -3,11 +3,9 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { getUiArtifactById } from '#mcp/ui-artifacts-repo.ts' -import { - parseUiArtifactParameters, - uiArtifactParameterSchema, -} from '#mcp/ui-artifact-parameters.ts' +import { uiArtifactParameterSchema } from '#mcp/ui-artifact-parameters.ts' import { requireMcpUser } from '../meta/require-user.ts' +import { resolveSavedAppSource } from '#worker/repo/app-source.ts' const outputSchema = z.object({ app_id: z.string(), @@ -47,15 +45,20 @@ export const uiLoadAppSourceCapability = defineDomainCapability( if (!row) { throw new Error('Saved app not found for this user.') } + const resolved = await resolveSavedAppSource({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + artifact: row, + }) return { app_id: row.id, - title: row.title, - description: row.description, - client_code: row.clientCode, - server_code: row.serverCode, - server_code_id: row.serverCodeId, - parameters: parseUiArtifactParameters(row.parameters), - hidden: row.hidden, + title: resolved.title, + description: resolved.description, + client_code: resolved.clientCode, + server_code: resolved.serverCode, + server_code_id: resolved.serverCodeId, + parameters: resolved.parameters, + hidden: resolved.hidden, } }, }, diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts index a8fa1671e0..882a0900f2 100644 --- a/packages/worker/src/mcp/generated-ui-api.ts +++ b/packages/worker/src/mcp/generated-ui-api.ts @@ -18,15 +18,13 @@ import { createGeneratedUiAppBackendCookieHeader } from '#mcp/generated-ui-app-a import { runCodemodeWithRegistry } from '#mcp/run-codemode-registry.ts' import { deleteSecret, listSecrets, saveSecret } from '#mcp/secrets/service.ts' import { secretScopeValues } from '#mcp/secrets/types.ts' -import { - applyUiArtifactParameters, - parseUiArtifactParameters, -} from '#mcp/ui-artifact-parameters.ts' +import { applyUiArtifactParameters } from '#mcp/ui-artifact-parameters.ts' import { getUiArtifactById, getUiArtifactByOwnerIds, } from '#mcp/ui-artifacts-repo.ts' import { hasUiArtifactServerCode } from '#mcp/ui-artifacts-types.ts' +import { resolveSavedAppSource } from '#worker/repo/app-source.ts' const executeRequestSchema = z.object({ code: z.string().min(1), @@ -144,13 +142,18 @@ function createGeneratedUiSourceHandler(env: Env) { if (!app) { return jsonResponse({ error: 'Saved UI not found.' }, 404) } + const resolvedApp = await resolveSavedAppSource({ + env, + baseUrl: getAppBaseUrl({ env, requestUrl: request.url }), + artifact: app, + }) let resolvedParams: Record try { resolvedParams = sourceContext.type === 'session' ? sourceContext.params : applyUiArtifactParameters({ - definitions: parseUiArtifactParameters(app.parameters), + definitions: resolvedApp.parameters, values: readSavedAppParamsFromUrl(new URL(request.url)), }) } catch (error) { @@ -186,15 +189,15 @@ function createGeneratedUiSourceHandler(env: Env) { ok: true, app: { app_id: app.id, - title: app.title, - description: app.description, - hidden: app.hidden, - parameters: parseUiArtifactParameters(app.parameters), + title: resolvedApp.title, + description: resolvedApp.description, + hidden: resolvedApp.hidden, + parameters: resolvedApp.parameters, params: resolvedParams, - client_code: app.clientCode, - server_code: app.serverCode, - server_code_id: app.serverCodeId, - ...(hasUiArtifactServerCode(app.serverCode) + client_code: resolvedApp.clientCode, + server_code: resolvedApp.serverCode, + server_code_id: resolvedApp.serverCodeId, + ...(hasUiArtifactServerCode(resolvedApp.serverCode) ? { app_backend: { basePath: buildSavedAppBackendBasePath(app.id), @@ -207,7 +210,7 @@ function createGeneratedUiSourceHandler(env: Env) { }, appSession, }) - if (hasUiArtifactServerCode(app.serverCode)) { + if (hasUiArtifactServerCode(resolvedApp.serverCode)) { response.headers.append( 'Set-Cookie', await createGeneratedUiAppBackendCookieHeader({ diff --git a/packages/worker/src/mcp/jobs-vectorize.ts b/packages/worker/src/mcp/jobs-vectorize.ts new file mode 100644 index 0000000000..f058b20b92 --- /dev/null +++ b/packages/worker/src/mcp/jobs-vectorize.ts @@ -0,0 +1,35 @@ +import { + embedTextForVectorize, + getCapabilityVectorIndex, + isCapabilitySearchOffline, +} from '#mcp/capabilities/capability-search.ts' + +export function jobVectorId(jobId: string): string { + return `job_${jobId}` +} + +export async function upsertJobVector( + env: Env, + input: { + jobId: string + userId: string + embedText: string + }, +): Promise { + const index = getCapabilityVectorIndex(env) + if (!index || isCapabilitySearchOffline(env)) return + const values = await embedTextForVectorize(env, input.embedText) + await index.upsert([ + { + id: jobVectorId(input.jobId), + values, + metadata: { kind: 'job', userId: input.userId }, + }, + ]) +} + +export async function deleteJobVector(env: Env, jobId: string): Promise { + const index = getCapabilityVectorIndex(env) + if (!index || isCapabilitySearchOffline(env)) return + await index.deleteByIds([jobVectorId(jobId)]) +} diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 5ff3319588..660da48bbf 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -123,10 +123,11 @@ function applyMaxResponseSize( const searchTool = { name: 'search', - title: 'Search Capabilities, Values, Connectors, Skills, Apps, and Secrets', + title: + 'Search Capabilities, Values, Connectors, Skills, Apps, Jobs, and Secrets', description: ` Find **built-in capabilities**, **persisted values**, **saved connectors**, -**saved skills**, **saved apps**, and **user secret references** (metadata only) +**saved skills**, **saved apps**, **saved jobs**, and **user secret references** (metadata only) before \`execute\` or \`open_generated_ui\`. **query** — ranked markdown + structured matches (order matters). If nothing useful @@ -134,7 +135,7 @@ returns, rephrase or call \`meta_list_capabilities\`; \`entity\` does not fix an empty ranked list. **entity: "{id}:{type}"** — detail for one hit (\`capability\` | \`value\` -| \`connector\` | \`skill\` | \`app\` | \`secret\`), including schemas for +| \`connector\` | \`skill\` | \`app\` | \`job\` | \`secret\`), including schemas for capabilities. Types and fields: see response. Run a skill: \`meta_run_skill({ name, params })\`; source: \`meta_get_skill\`. diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts new file mode 100644 index 0000000000..94511cf552 --- /dev/null +++ b/packages/worker/src/repo/app-source.ts @@ -0,0 +1,99 @@ +import { type UiArtifactParameterDefinition } from '@kody-internal/shared/ui-artifact-parameters.ts' +import { parseUiArtifactParameters } from '#mcp/ui-artifact-parameters.ts' +import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts' +import { getEntitySourceById } from './entity-sources.ts' +import { parseRepoManifest } from './manifest.ts' +import { repoSessionRpc } from './repo-session-do.ts' + +export type ResolvedSavedAppSource = { + title: string + description: string + hidden: boolean + parameters: Array | null + clientCode: string + serverCode: string | null + serverCodeId: string + sourceId: string | null + publishedCommit: string | null +} + +function fallbackFromArtifact(artifact: UiArtifactRow): ResolvedSavedAppSource { + return { + title: artifact.title, + description: artifact.description, + hidden: artifact.hidden, + parameters: parseUiArtifactParameters(artifact.parameters), + clientCode: artifact.clientCode ?? '', + serverCode: artifact.serverCode ?? null, + serverCodeId: artifact.serverCodeId, + sourceId: artifact.sourceId, + publishedCommit: null, + } +} + +function canResolveRepoBackedSource(env: Env, artifact: UiArtifactRow) { + return ( + artifact.sourceId != null && + typeof (env as Env & { APP_DB?: unknown }).APP_DB === 'object' && + typeof (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION === 'object' + ) +} + +export async function resolveSavedAppSource(input: { + env: Env + baseUrl: string + artifact: UiArtifactRow +}): Promise { + const fallback = fallbackFromArtifact(input.artifact) + if (!canResolveRepoBackedSource(input.env, input.artifact)) { + return fallback + } + const source = await getEntitySourceById( + input.env.APP_DB, + input.artifact.sourceId!, + ) + if (!source) return fallback + const sessionId = `app-source-${source.id}` + const session = await repoSessionRpc(input.env, sessionId).openSession({ + sessionId, + sourceId: source.id, + userId: input.artifact.user_id, + baseUrl: input.baseUrl, + sourceRoot: source.source_root, + }) + const manifestFile = await repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: source.manifest_path, + }) + if (!manifestFile.content) return fallback + const manifest = parseRepoManifest({ + content: manifestFile.content, + manifestPath: source.manifest_path, + }) + if (manifest.kind !== 'app') return fallback + const clientPath = + manifest.assets?.[0] ?? + (typeof manifest.client === 'string' ? manifest.client : 'client.html') + const [clientFile, serverFile] = await Promise.all([ + repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: clientPath, + }), + repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: manifest.server, + }), + ]) + return { + title: manifest.title, + description: manifest.description, + hidden: manifest.hidden ?? fallback.hidden, + parameters: + (manifest.parameters as Array) ?? null, + clientCode: clientFile.content ?? fallback.clientCode, + serverCode: serverFile.content ?? fallback.serverCode, + serverCodeId: source.published_commit ?? fallback.serverCodeId, + sourceId: source.id, + publishedCommit: source.published_commit, + } +} From 027f906256398d0660e1893ebf7fea2588b64617 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 22:26:25 +0000 Subject: [PATCH 14/42] Add job reindex maintenance hook --- packages/worker/src/index.ts | 5 ++ packages/worker/src/job-maintenance.ts | 31 +++++++++++ packages/worker/src/jobs/job-reindex.ts | 73 +++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 packages/worker/src/job-maintenance.ts create mode 100644 packages/worker/src/jobs/job-reindex.ts diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 9d848ada84..cfada8316e 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -35,6 +35,7 @@ import { import { readGeneratedUiAppBackendSession } from './mcp/generated-ui-app-auth.ts' import { withCors } from './utils.ts' import { handleCapabilityReindexRequest } from './capability-maintenance.ts' +import { handleJobReindexRequest } from './job-maintenance.ts' import { handleMemoryReindexRequest } from './memory-maintenance.ts' import { handleSkillReindexRequest } from './skill-maintenance.ts' import { handleUiArtifactReindexRequest } from './ui-artifact-maintenance.ts' @@ -117,6 +118,10 @@ const appHandler = withCors({ return handleUiArtifactReindexRequest(request, env) } + if (url.pathname === '/__maintenance/reindex-jobs') { + return handleJobReindexRequest(request, env) + } + if (url.pathname === oauthPaths.authorize) { return handleAuthorizeRequest(request, env) } diff --git a/packages/worker/src/job-maintenance.ts b/packages/worker/src/job-maintenance.ts new file mode 100644 index 0000000000..d7110250d9 --- /dev/null +++ b/packages/worker/src/job-maintenance.ts @@ -0,0 +1,31 @@ +import { reindexJobVectors } from './jobs/job-reindex.ts' + +export async function handleJobReindexRequest( + request: Request, + env: Env, +): Promise { + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }) + } + + const secret = env.CAPABILITY_REINDEX_SECRET?.trim() + if (!secret) { + return new Response('Job reindex is not configured', { status: 503 }) + } + + const auth = request.headers.get('Authorization')?.trim() + const token = auth?.startsWith('Bearer ') + ? auth.slice('Bearer '.length).trim() + : null + if (token !== secret) { + return new Response('Unauthorized', { status: 401 }) + } + + try { + const { upserted } = await reindexJobVectors(env) + return Response.json({ ok: true, upserted }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return Response.json({ ok: false, error: message }, { status: 500 }) + } +} diff --git a/packages/worker/src/jobs/job-reindex.ts b/packages/worker/src/jobs/job-reindex.ts new file mode 100644 index 0000000000..f13caa45fe --- /dev/null +++ b/packages/worker/src/jobs/job-reindex.ts @@ -0,0 +1,73 @@ +import { + getCapabilityVectorIndex, + isCapabilitySearchOffline, +} from '#mcp/capabilities/capability-search.ts' +import { buildJobEmbedText } from '#mcp/jobs-embed.ts' +import { jobVectorId } from '#mcp/jobs-vectorize.ts' +import { listJobs } from './service.ts' + +const upsertBatchSize = 16 + +type JobEmbeddingBatchResult = { data?: Array> } + +export async function reindexJobVectors( + env: Env, +): Promise<{ upserted: number }> { + const index = getCapabilityVectorIndex(env) + if (!index) { + throw new Error('CAPABILITY_VECTOR_INDEX binding is not configured') + } + if (isCapabilitySearchOffline(env)) { + return { upserted: 0 } + } + + const rows = await env.APP_DB.prepare( + `SELECT DISTINCT user_id FROM jobs WHERE source_id IS NOT NULL OR code IS NOT NULL`, + ).all<{ user_id: string }>() + const userIds = (rows.results ?? []).map((row) => row.user_id).filter(Boolean) + if (userIds.length === 0) return { upserted: 0 } + + const jobs = ( + await Promise.all( + userIds.map((userId) => + listJobs({ + env, + userId, + }).then((userJobs) => userJobs.map((job) => ({ userId, job }))), + ), + ) + ).flat() + if (jobs.length === 0) return { upserted: 0 } + + let upserted = 0 + for (let offset = 0; offset < jobs.length; offset += upsertBatchSize) { + const batch = jobs.slice(offset, offset + upsertBatchSize) + const texts = batch.map(({ job }) => + buildJobEmbedText({ + name: job.name, + description: job.name, + scheduleSummary: job.scheduleSummary, + sourceId: job.sourceId, + publishedCommit: job.publishedCommit, + }), + ) + const result = (await env.AI.run('@cf/baai/bge-small-en-v1.5', { + text: texts, + pooling: 'mean', + })) as JobEmbeddingBatchResult + const vectors = result.data + if (!vectors || vectors.length !== batch.length) { + throw new Error('Workers AI embedding batch size mismatch for jobs') + } + await index.upsert( + batch.map(({ userId, job }, index_) => ({ + id: jobVectorId(job.id), + values: vectors[index_]!, + metadata: { kind: 'job', userId }, + })), + ) + upserted += batch.length + } + + return { upserted } +} From 1808a6a3b064ec5abe1bd1b360385713b5ccad1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 22:39:00 +0000 Subject: [PATCH 15/42] Record published commits for synced sources --- packages/worker/src/jobs/service.ts | 38 +++++++++++-------- .../src/mcp/capabilities/apps/ui-save-app.ts | 10 ++++- .../mcp/capabilities/meta/meta-save-skill.ts | 11 +++++- packages/worker/src/repo/source-sync.ts | 9 +++-- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 4496edb8f4..55cdff2dbf 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -167,14 +167,7 @@ export async function createJob(input: { errorCount: 0, runHistory: [], } - const callerContextJson = serializeCallerContext(callerContext) - await insertJobRow({ - db: input.env.APP_DB, - userId: callerContext.user.userId, - job, - callerContextJson, - }) - await syncArtifactSourceSnapshot({ + const syncedPublishedCommit = await syncArtifactSourceSnapshot({ env: input.env, userId: callerContext.user.userId, baseUrl: callerContext.baseUrl, @@ -183,6 +176,16 @@ export async function createJob(input: { job: toJobView(job), }), }) + if (syncedPublishedCommit) { + job.publishedCommit = syncedPublishedCommit + } + const callerContextJson = serializeCallerContext(callerContext) + await insertJobRow({ + db: input.env.APP_DB, + userId: callerContext.user.userId, + job, + callerContextJson, + }) await upsertJobVector(input.env, { jobId: job.id, userId: callerContext.user.userId, @@ -283,14 +286,7 @@ export async function updateJob(input: { }) : existing.nextRunAt, } - const nextCallerContextJson = serializeCallerContext(callerContext) - await updateJobRow({ - db: input.env.APP_DB, - userId: callerContext.user.userId, - job: updated, - callerContextJson: nextCallerContextJson, - }) - await syncArtifactSourceSnapshot({ + const syncedPublishedCommit = await syncArtifactSourceSnapshot({ env: input.env, userId: callerContext.user.userId, baseUrl: callerContext.baseUrl, @@ -299,6 +295,16 @@ export async function updateJob(input: { job: toJobView(updated), }), }) + if (syncedPublishedCommit) { + updated.publishedCommit = syncedPublishedCommit + } + const nextCallerContextJson = serializeCallerContext(callerContext) + await updateJobRow({ + db: input.env.APP_DB, + userId: callerContext.user.userId, + job: updated, + callerContextJson: nextCallerContextJson, + }) await upsertJobVector(input.env, { jobId: updated.id, userId: callerContext.user.userId, diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index f8c9b99d66..7b569a778b 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -337,7 +337,7 @@ export const uiSaveAppCapability = defineDomainCapability( if (!updated) { throw new Error('Saved UI artifact not found for this user.') } - await syncArtifactSourceSnapshot({ + const publishedCommit = await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, @@ -351,6 +351,11 @@ export const uiSaveAppCapability = defineDomainCapability( serverCode, }), }) + if (publishedCommit) { + await updateUiArtifact(ctx.env.APP_DB, user.userId, appId, { + sourceId: ensuredSource.id, + }) + } hidden = args.hidden ?? existingApp.hidden return await saveAndIndexApp({ appId, @@ -390,7 +395,7 @@ export const uiSaveAppCapability = defineDomainCapability( created_at: now, updated_at: now, }) - await syncArtifactSourceSnapshot({ + const publishedCommit = await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, @@ -404,6 +409,7 @@ export const uiSaveAppCapability = defineDomainCapability( serverCode, }), }) + void publishedCommit return await saveAndIndexApp({ appId, title, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index c0c8b3ad61..26bc736a92 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -19,6 +19,7 @@ import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' import { buildSkillSourceFiles } from '#worker/repo/source-templates.ts' import { requireMcpUser } from './require-user.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' +import { updateEntitySource } from '#worker/repo/entity-sources.ts' const inputSchema = z.object({ name: z @@ -167,7 +168,7 @@ export const metaSaveSkillCapability = defineDomainCapability( } } - await syncArtifactSourceSnapshot({ + const syncedPublishedCommit = await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, @@ -186,6 +187,14 @@ export const metaSaveSkillCapability = defineDomainCapability( code: args.code, }), }) + if (syncedPublishedCommit) { + await updateEntitySource(ctx.env.APP_DB, { + id: source.id, + userId: user.userId, + publishedCommit: syncedPublishedCommit, + indexedCommit: syncedPublishedCommit, + }) + } try { await upsertSkillVector(ctx.env, { diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts index 94c7731880..d9b865fc1a 100644 --- a/packages/worker/src/repo/source-sync.ts +++ b/packages/worker/src/repo/source-sync.ts @@ -25,11 +25,11 @@ function buildSyncSessionId(sourceId: string) { export async function syncArtifactSourceSnapshot( input: SyncArtifactSourceInput, -): Promise { - if (!input.sourceId) return - if (!canSyncArtifactSource(input.env)) return +): Promise { + if (!input.sourceId) return null + if (!canSyncArtifactSource(input.env)) return null const source = await getEntitySourceById(input.env.APP_DB, input.sourceId) - if (!source) return + if (!source) return null const sessionId = buildSyncSessionId(source.id) await repoSessionRpc(input.env, sessionId).openSession({ sessionId, @@ -59,4 +59,5 @@ export async function syncArtifactSourceSnapshot( throw new Error(publishResult.message) } await repoSessionRpc(input.env, sessionId).discardSession({ sessionId }) + return publishResult.publishedCommit } From cc437081372fa757ea95a8d486d7a91c56fc1e73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 23:06:20 +0000 Subject: [PATCH 16/42] Fix repo-backed job execution Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/service.ts | 80 ++++++++++++++++--- .../src/mcp/capabilities/unified-search.ts | 2 +- packages/worker/src/repo/app-source.ts | 36 ++++++++- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 55cdff2dbf..195b30c3f0 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -1,5 +1,8 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { parseMcpCallerContext } from '#mcp/context.ts' +import { buildJobEmbedText } from '#mcp/jobs-embed.ts' +import { runCodemodeWithRegistry } from '#mcp/run-codemode-registry.ts' +import { upsertJobVector } from '#mcp/jobs-vectorize.ts' import { type ExecuteResult } from '@cloudflare/codemode' import { exports as workerExports } from 'cloudflare:workers' import { applyExecutionOutcome, processDueJobs } from './process-due-jobs.ts' @@ -29,9 +32,8 @@ import { } from './types.ts' import { createJobStorageId } from '#worker/storage-runner.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' +import { parseRepoManifest } from '#worker/repo/manifest.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' -import { buildJobEmbedText } from '#mcp/jobs-embed.ts' -import { upsertJobVector } from '#mcp/jobs-vectorize.ts' import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' import { buildJobSourceFiles } from '#worker/repo/source-templates.ts' @@ -351,8 +353,6 @@ export async function executeJobOnce(input: { logs: [], } } else { - const { runCodemodeWithRegistry } = - await import('#mcp/run-codemode-registry.ts') const runtimeCallerContext = { ...input.callerContext, storageContext: { @@ -414,10 +414,17 @@ async function runRepoBackedJob(input: { job: JobRecord callerContext: PersistedJobCallerContext }): Promise { + if (!input.job.sourceId) { + return { + error: 'Repo-backed job source is missing.', + result: null, + logs: [], + } + } const sessionId = `job-runtime-${input.job.id}` const session = await repoSessionRpc(input.env, sessionId).openSession({ sessionId, - sourceId: input.job.sourceId!, + sourceId: input.job.sourceId, userId: input.callerContext.user.userId, baseUrl: input.callerContext.baseUrl, sourceRoot: '/', @@ -435,12 +442,67 @@ async function runRepoBackedJob(input: { logs: [], } } - const { runCodemodeWithRegistry } = - await import('#mcp/run-codemode-registry.ts') + const manifestPath = session.manifest_path?.replace(/^\/+/, '') || 'kody.json' + const entrypoint = await repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: manifestPath, + }) + if (!entrypoint.content) { + return { + error: `Job manifest "${manifestPath}" was not found in repo session.`, + result: null, + logs: [], + } + } + let manifest: ReturnType + try { + manifest = parseRepoManifest({ + content: entrypoint.content, + manifestPath, + }) + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + result: null, + logs: [], + } + } + if (manifest.kind !== 'job') { + return { + error: `Repo source "${input.job.sourceId}" is not a job manifest.`, + result: null, + logs: [], + } + } + const moduleFile = await repoSessionRpc(input.env, session.id).readFile({ + sessionId: session.id, + path: manifest.entrypoint.replace(/^\/+/, ''), + }) + if (!moduleFile.content) { + return { + error: `Job entrypoint "${manifest.entrypoint}" was not found in repo session.`, + result: null, + logs: [], + } + } return await runCodemodeWithRegistry( input.env, - input.callerContext, - 'async () => ({ ok: true, repoBacked: true })', + { + ...input.callerContext, + repoContext: { + sourceId: session.source_id, + repoId: null, + sessionId: session.id, + sessionRepoId: session.session_repo_id, + baseCommit: session.base_commit, + manifestPath: session.manifest_path, + sourceRoot: session.source_root, + publishedCommit: session.published_commit, + entityKind: session.entity_type, + entityId: input.job.id, + }, + }, + moduleFile.content, input.job.params, { executorExports: workerExports, diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index fa08fc56a4..b5f333d694 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -381,7 +381,7 @@ function scoreJobLexicalMatch( let bonus = 0 bonus += scoreSkillPhraseMatch(normalizedQuery, row.name) * 2 bonus += scoreSkillPhraseMatch(normalizedQuery, row.scheduleSummary) * 1 - bonus += scoreSkillPhraseMatch(normalizedQuery, row.sourceId) * 0.5 + bonus += scoreSkillPhraseMatch(normalizedQuery, row.sourceId ?? '') * 0.5 return lexicalScore(query, doc) + bonus } diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 94511cf552..2ca7cc0673 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -17,6 +17,32 @@ export type ResolvedSavedAppSource = { publishedCommit: string | null } +const savedAppSourceCache = new Map() +const savedAppSourceCacheLimit = 100 + +function buildSavedAppSourceCacheKey(input: { + sourceId: string + publishedCommit: string | null +}) { + return `${input.sourceId}:${input.publishedCommit ?? 'unpublished'}` +} + +function rememberSavedAppSource( + cacheKey: string, + value: ResolvedSavedAppSource, +) { + if (savedAppSourceCache.has(cacheKey)) { + savedAppSourceCache.delete(cacheKey) + } + savedAppSourceCache.set(cacheKey, value) + if (savedAppSourceCache.size > savedAppSourceCacheLimit) { + const oldestKey = savedAppSourceCache.keys().next().value + if (oldestKey) { + savedAppSourceCache.delete(oldestKey) + } + } +} + function fallbackFromArtifact(artifact: UiArtifactRow): ResolvedSavedAppSource { return { title: artifact.title, @@ -53,6 +79,12 @@ export async function resolveSavedAppSource(input: { input.artifact.sourceId!, ) if (!source) return fallback + const cacheKey = buildSavedAppSourceCacheKey({ + sourceId: source.id, + publishedCommit: source.published_commit, + }) + const cached = savedAppSourceCache.get(cacheKey) + if (cached) return cached const sessionId = `app-source-${source.id}` const session = await repoSessionRpc(input.env, sessionId).openSession({ sessionId, @@ -84,7 +116,7 @@ export async function resolveSavedAppSource(input: { path: manifest.server, }), ]) - return { + const resolved = { title: manifest.title, description: manifest.description, hidden: manifest.hidden ?? fallback.hidden, @@ -96,4 +128,6 @@ export async function resolveSavedAppSource(input: { sourceId: source.id, publishedCommit: source.published_commit, } + rememberSavedAppSource(cacheKey, resolved) + return resolved } From ebba2bada1d9b5e1d695dfa93ab983d395de7f88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 23:12:37 +0000 Subject: [PATCH 17/42] Fix repo session and job embed handling Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/job-reindex.ts | 1 - packages/worker/src/jobs/repo.ts | 2 +- packages/worker/src/jobs/service.ts | 2 -- .../worker/src/mcp/capabilities/repo/repo-open-session.ts | 5 +++++ packages/worker/src/mcp/capabilities/unified-search.ts | 1 - 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/jobs/job-reindex.ts b/packages/worker/src/jobs/job-reindex.ts index f13caa45fe..a892ac7d2d 100644 --- a/packages/worker/src/jobs/job-reindex.ts +++ b/packages/worker/src/jobs/job-reindex.ts @@ -45,7 +45,6 @@ export async function reindexJobVectors( const texts = batch.map(({ job }) => buildJobEmbedText({ name: job.name, - description: job.name, scheduleSummary: job.scheduleSummary, sourceId: job.sourceId, publishedCommit: job.publishedCommit, diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts index aeb11a8aa6..0bacedc617 100644 --- a/packages/worker/src/jobs/repo.ts +++ b/packages/worker/src/jobs/repo.ts @@ -79,7 +79,7 @@ function mapRow(row: Record): JobRow { id: String(row['id']), userId: String(row['user_id']), name: String(row['name']), - code: String(row['code']), + code: row['code'] == null ? null : String(row['code']), sourceId: row['source_id'] == null ? null : String(row['source_id']), publishedCommit: row['published_commit'] == null ? null : String(row['published_commit']), diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 195b30c3f0..0abd6abbc2 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -193,7 +193,6 @@ export async function createJob(input: { userId: callerContext.user.userId, embedText: buildJobEmbedText({ name: job.name, - description: job.name, scheduleSummary: toJobView(job).scheduleSummary, sourceId: job.sourceId, publishedCommit: job.publishedCommit, @@ -312,7 +311,6 @@ export async function updateJob(input: { userId: callerContext.user.userId, embedText: buildJobEmbedText({ name: updated.name, - description: updated.name, scheduleSummary: toJobView(updated).scheduleSummary, sourceId: updated.sourceId, publishedCommit: updated.publishedCommit, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts index 591783675e..189ad4dce1 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts @@ -35,6 +35,11 @@ export const repoOpenSessionCapability = defineDomainCapability( conversationId: args.conversation_id, }) if (existingSession) { + if (existingSession.source_id !== args.source_id) { + throw new Error( + 'Active repo session does not match the requested source. Discard the current session before opening a new source.', + ) + } return repoSessionRpc(ctx.env, existingSession.id).getSessionInfo({ sessionId: existingSession.id, }) diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index b5f333d694..5f977a5fa9 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -388,7 +388,6 @@ function scoreJobLexicalMatch( function buildJobEmbedDoc(row: JobView): string { return buildJobEmbedText({ name: row.name, - description: row.name, scheduleSummary: row.scheduleSummary, sourceId: row.sourceId, publishedCommit: row.publishedCommit, From fb19e401a0c0049396de280fef8961dc9992f96a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 23:17:03 +0000 Subject: [PATCH 18/42] Fix job cleanup and repo capability registration Co-authored-by: Kent C. Dodds Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/service.ts | 3 ++- packages/worker/src/mcp/capabilities/repo/domain.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 0abd6abbc2..52e09c8ce2 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -2,7 +2,7 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { parseMcpCallerContext } from '#mcp/context.ts' import { buildJobEmbedText } from '#mcp/jobs-embed.ts' import { runCodemodeWithRegistry } from '#mcp/run-codemode-registry.ts' -import { upsertJobVector } from '#mcp/jobs-vectorize.ts' +import { deleteJobVector, upsertJobVector } from '#mcp/jobs-vectorize.ts' import { type ExecuteResult } from '@cloudflare/codemode' import { exports as workerExports } from 'cloudflare:workers' import { applyExecutionOutcome, processDueJobs } from './process-due-jobs.ts' @@ -329,6 +329,7 @@ export async function deleteJob(input: { throw new Error(`Job "${input.jobId}" was not found.`) } await deleteJobRow(input.env.APP_DB, input.userId, input.jobId) + await deleteJobVector(input.env, input.jobId) return { id: input.jobId, deleted: true as const, diff --git a/packages/worker/src/mcp/capabilities/repo/domain.ts b/packages/worker/src/mcp/capabilities/repo/domain.ts index 02388b6005..a5cd779555 100644 --- a/packages/worker/src/mcp/capabilities/repo/domain.ts +++ b/packages/worker/src/mcp/capabilities/repo/domain.ts @@ -2,9 +2,12 @@ import { defineDomain } from '#mcp/capabilities/define-domain.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { repoApplyPatchCapability } from './repo-apply-patch.ts' import { repoDiscardSessionCapability } from './repo-discard-session.ts' +import { repoGetCheckStatusCapability } from './repo-get-check-status.ts' import { repoGetSessionCapability } from './repo-get-session.ts' import { repoOpenSessionCapability } from './repo-open-session.ts' +import { repoPublishSessionCapability } from './repo-publish-session.ts' import { repoReadFileCapability } from './repo-read-file.ts' +import { repoRebaseSessionCapability } from './repo-rebase-session.ts' import { repoRunChecksCapability } from './repo-run-checks.ts' import { repoSearchCapability } from './repo-search.ts' import { repoTreeCapability } from './repo-tree.ts' @@ -24,6 +27,9 @@ export const repoDomain = defineDomain({ repoApplyPatchCapability, repoSearchCapability, repoRunChecksCapability, + repoGetCheckStatusCapability, + repoPublishSessionCapability, + repoRebaseSessionCapability, repoDiscardSessionCapability, ], }) From 806166ebcf0c65af5729ed84cfb48b3b9a76cf68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 23:32:22 +0000 Subject: [PATCH 19/42] Lazy load job execution runtime --- packages/worker/src/jobs/service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 52e09c8ce2..113042aacb 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -1,7 +1,6 @@ import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { parseMcpCallerContext } from '#mcp/context.ts' import { buildJobEmbedText } from '#mcp/jobs-embed.ts' -import { runCodemodeWithRegistry } from '#mcp/run-codemode-registry.ts' import { deleteJobVector, upsertJobVector } from '#mcp/jobs-vectorize.ts' import { type ExecuteResult } from '@cloudflare/codemode' import { exports as workerExports } from 'cloudflare:workers' @@ -360,6 +359,8 @@ export async function executeJobOnce(input: { storageId: input.job.storageId, }, } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') const result = input.job.sourceId ? await runRepoBackedJob({ env: input.env, @@ -484,6 +485,8 @@ async function runRepoBackedJob(input: { logs: [], } } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') return await runCodemodeWithRegistry( input.env, { From a88ceaa0c08ec8224f0bc1ca720c8179efb5d03f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 23:52:46 +0000 Subject: [PATCH 20/42] Fix repo-backed review regressions --- .../src/mcp/capabilities/apps/ui-save-app.ts | 10 +- .../mcp/capabilities/meta/meta-save-skill.ts | 2 +- .../capabilities/repo/repo-discard-session.ts | 2 + .../mcp/capabilities/repo/repo-get-session.ts | 2 + .../src/mcp/capabilities/repo/repo-search.ts | 2 + packages/worker/src/repo/app-source.ts | 108 +++++++++++------- packages/worker/src/repo/source-service.ts | 4 +- packages/worker/src/repo/source-sync.ts | 59 +++++----- 8 files changed, 106 insertions(+), 83 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 7b569a778b..f8c9b99d66 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -337,7 +337,7 @@ export const uiSaveAppCapability = defineDomainCapability( if (!updated) { throw new Error('Saved UI artifact not found for this user.') } - const publishedCommit = await syncArtifactSourceSnapshot({ + await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, @@ -351,11 +351,6 @@ export const uiSaveAppCapability = defineDomainCapability( serverCode, }), }) - if (publishedCommit) { - await updateUiArtifact(ctx.env.APP_DB, user.userId, appId, { - sourceId: ensuredSource.id, - }) - } hidden = args.hidden ?? existingApp.hidden return await saveAndIndexApp({ appId, @@ -395,7 +390,7 @@ export const uiSaveAppCapability = defineDomainCapability( created_at: now, updated_at: now, }) - const publishedCommit = await syncArtifactSourceSnapshot({ + await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, baseUrl: ctx.callerContext.baseUrl, @@ -409,7 +404,6 @@ export const uiSaveAppCapability = defineDomainCapability( serverCode, }), }) - void publishedCommit return await saveAndIndexApp({ appId, title, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 26bc736a92..7af79a4034 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -206,7 +206,7 @@ export const metaSaveSkillCapability = defineDomainCapability( } catch (cause) { if (existing) { await updateMcpSkill(ctx.env.APP_DB, user.userId, existing.name, { - source_id: existing.source_id ?? source.id, + source_id: existing.source_id, name: existing.name, title: existing.title, description: existing.description, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts index 7ca5ae826b..6f08cccb3e 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts @@ -1,5 +1,6 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' import { repoSessionIdSchema, @@ -19,6 +20,7 @@ export const repoDiscardSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoDiscardSessionOutputSchema, async handler(args, ctx) { + requireMcpUser(ctx.callerContext) const result = await repoSessionRpc( ctx.env, args.session_id, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts index d813d86e59..c9214c23fc 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts @@ -1,5 +1,6 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' import { repoSessionIdSchema, repoSessionInfoSchema } from './repo-shared.ts' @@ -16,6 +17,7 @@ export const repoGetSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoSessionInfoSchema, async handler(args, ctx) { + requireMcpUser(ctx.callerContext) return await repoSessionRpc(ctx.env, args.session_id).getSessionInfo({ sessionId: args.session_id, }) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-search.ts b/packages/worker/src/mcp/capabilities/repo/repo-search.ts index 1b891a6256..7f3adfbce5 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-search.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-search.ts @@ -1,5 +1,6 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' import { repoSearchInputSchema, repoSearchOutputSchema } from './repo-shared.ts' @@ -16,6 +17,7 @@ export const repoSearchCapability = defineDomainCapability( inputSchema: repoSearchInputSchema, outputSchema: repoSearchOutputSchema, async handler(args, ctx) { + requireMcpUser(ctx.callerContext) const session = repoSessionRpc(ctx.env, args.session_id) const result = await session.search({ sessionId: args.session_id, diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 2ca7cc0673..964e0ebf2b 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -3,6 +3,7 @@ import { parseUiArtifactParameters } from '#mcp/ui-artifact-parameters.ts' import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts' import { getEntitySourceById } from './entity-sources.ts' import { parseRepoManifest } from './manifest.ts' +import { type AppManifest } from './types.ts' import { repoSessionRpc } from './repo-session-do.ts' export type ResolvedSavedAppSource = { @@ -57,6 +58,19 @@ function fallbackFromArtifact(artifact: UiArtifactRow): ResolvedSavedAppSource { } } +function resolveManifestClientPath(manifest: AppManifest) { + if (Array.isArray(manifest.assets) && manifest.assets.length > 0) { + return manifest.assets[0]! + } + if (typeof manifest.client === 'string') { + return manifest.client + } + if (Array.isArray(manifest.client) && manifest.client.length > 0) { + return manifest.client[0]! + } + return 'client.html' +} + function canResolveRepoBackedSource(env: Env, artifact: UiArtifactRow) { return ( artifact.sourceId != null && @@ -86,48 +100,56 @@ export async function resolveSavedAppSource(input: { const cached = savedAppSourceCache.get(cacheKey) if (cached) return cached const sessionId = `app-source-${source.id}` - const session = await repoSessionRpc(input.env, sessionId).openSession({ - sessionId, - sourceId: source.id, - userId: input.artifact.user_id, - baseUrl: input.baseUrl, - sourceRoot: source.source_root, - }) - const manifestFile = await repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: source.manifest_path, - }) - if (!manifestFile.content) return fallback - const manifest = parseRepoManifest({ - content: manifestFile.content, - manifestPath: source.manifest_path, - }) - if (manifest.kind !== 'app') return fallback - const clientPath = - manifest.assets?.[0] ?? - (typeof manifest.client === 'string' ? manifest.client : 'client.html') - const [clientFile, serverFile] = await Promise.all([ - repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: clientPath, - }), - repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: manifest.server, - }), - ]) - const resolved = { - title: manifest.title, - description: manifest.description, - hidden: manifest.hidden ?? fallback.hidden, - parameters: - (manifest.parameters as Array) ?? null, - clientCode: clientFile.content ?? fallback.clientCode, - serverCode: serverFile.content ?? fallback.serverCode, - serverCodeId: source.published_commit ?? fallback.serverCodeId, - sourceId: source.id, - publishedCommit: source.published_commit, + const session = repoSessionRpc(input.env, sessionId) + let openedSessionId: string | null = null + try { + const opened = await session.openSession({ + sessionId, + sourceId: source.id, + userId: input.artifact.user_id, + baseUrl: input.baseUrl, + sourceRoot: source.source_root, + }) + openedSessionId = opened.id + const manifestFile = await session.readFile({ + sessionId: opened.id, + path: source.manifest_path, + }) + if (!manifestFile.content) return fallback + const manifest = parseRepoManifest({ + content: manifestFile.content, + manifestPath: source.manifest_path, + }) + if (manifest.kind !== 'app') return fallback + const [clientFile, serverFile] = await Promise.all([ + session.readFile({ + sessionId: opened.id, + path: resolveManifestClientPath(manifest), + }), + session.readFile({ + sessionId: opened.id, + path: manifest.server, + }), + ]) + const resolved = { + title: manifest.title, + description: manifest.description, + hidden: manifest.hidden ?? fallback.hidden, + parameters: + (manifest.parameters as Array) ?? null, + clientCode: clientFile.content ?? fallback.clientCode, + serverCode: serverFile.content ?? fallback.serverCode, + serverCodeId: source.published_commit ?? fallback.serverCodeId, + sourceId: source.id, + publishedCommit: source.published_commit, + } + rememberSavedAppSource(cacheKey, resolved) + return resolved + } finally { + if (openedSessionId) { + await session.discardSession({ sessionId: openedSessionId }).catch(() => { + // Best effort only; source resolution should preserve the original error. + }) + } } - rememberSavedAppSource(cacheKey, resolved) - return resolved } diff --git a/packages/worker/src/repo/source-service.ts b/packages/worker/src/repo/source-service.ts index 4ea091016e..1c67b3015c 100644 --- a/packages/worker/src/repo/source-service.ts +++ b/packages/worker/src/repo/source-service.ts @@ -56,9 +56,7 @@ export async function ensureEntitySource(input: { if ( typeof (input.db as D1Database | null | undefined)?.prepare !== 'function' || - (envHasArtifactsBinding(input.env) === false && - typeof input.repoId !== 'string' && - input.repoId !== undefined) + envHasArtifactsBinding(input.env) === false ) { return buildEntitySourceRow({ userId: input.userId, diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts index d9b865fc1a..6a66f73fb5 100644 --- a/packages/worker/src/repo/source-sync.ts +++ b/packages/worker/src/repo/source-sync.ts @@ -31,33 +31,36 @@ export async function syncArtifactSourceSnapshot( const source = await getEntitySourceById(input.env.APP_DB, input.sourceId) if (!source) return null const sessionId = buildSyncSessionId(source.id) - await repoSessionRpc(input.env, sessionId).openSession({ - sessionId, - sourceId: source.id, - userId: input.userId, - baseUrl: input.baseUrl, - sourceRoot: source.source_root, - }) - await repoSessionRpc(input.env, sessionId).applyEdits({ - sessionId, - edits: Object.entries(input.files).map(([path, content]) => ({ - kind: 'write' as const, - path, - content, - })), - dryRun: false, - rollbackOnError: true, - }) - const publishResult = await repoSessionRpc( - input.env, - sessionId, - ).publishSession({ - sessionId, - force: true, - }) - if (publishResult.status !== 'ok') { - throw new Error(publishResult.message) + const session = repoSessionRpc(input.env, sessionId) + try { + await session.openSession({ + sessionId, + sourceId: source.id, + userId: input.userId, + baseUrl: input.baseUrl, + sourceRoot: source.source_root, + }) + await session.applyEdits({ + sessionId, + edits: Object.entries(input.files).map(([path, content]) => ({ + kind: 'write' as const, + path, + content, + })), + dryRun: false, + rollbackOnError: true, + }) + const publishResult = await session.publishSession({ + sessionId, + force: true, + }) + if (publishResult.status !== 'ok') { + throw new Error(publishResult.message) + } + return publishResult.publishedCommit + } finally { + await session.discardSession({ sessionId }).catch(() => { + // Best effort only; publish/apply failures should preserve the root cause. + }) } - await repoSessionRpc(input.env, sessionId).discardSession({ sessionId }) - return publishResult.publishedCommit } From 0596d817025b16a6230e84314600a261c784b1e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:01:40 +0000 Subject: [PATCH 21/42] Tighten repo-backed review follow-ups --- packages/worker/src/jobs/service.ts | 2 +- .../worker/src/mcp/capabilities/meta/meta-save-skill.ts | 5 +++++ .../worker/src/mcp/capabilities/repo/repo-run-checks.ts | 2 +- packages/worker/src/mcp/capabilities/repo/repo-tree.ts | 2 +- packages/worker/src/mcp/tools/search-format.ts | 2 +- packages/worker/src/mcp/tools/search.ts | 2 +- packages/worker/src/repo/artifacts.ts | 8 +++++++- packages/worker/src/repo/source-sync.ts | 4 +++- 8 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 113042aacb..9b591aaa90 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -113,7 +113,7 @@ function resolveUpdatedShape(input: { ? input.existing.publishedCommit : input.body.publishedCommit if (!nextCode && !nextSourceId) { - throw new Error('Jobs require code.') + throw new Error('Jobs require either code or sourceId.') } return { code: nextCode, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 7af79a4034..a3194bc19f 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -2,6 +2,7 @@ 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 { deleteEntitySource } from '#worker/repo/entity-sources.ts' import { deleteMcpSkill, getMcpSkillByName, @@ -231,6 +232,10 @@ export const metaSaveSkillCapability = defineDomainCapability( collectionSlug: existing.collection_slug, }) } else { + await deleteEntitySource(ctx.env.APP_DB, { + id: source.id, + userId: user.userId, + }) await deleteMcpSkill( ctx.env.APP_DB, user.userId, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts index d7ea45b601..d62578830b 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts @@ -14,7 +14,7 @@ export const repoRunChecksCapability = defineDomainCapability( description: 'Run the Worker-native validation pipeline for an active repo session so edits can be checked before publish.', keywords: ['repo', 'checks', 'validate', 'typecheck', 'bundle', 'manifest'], - readOnly: true, + readOnly: false, idempotent: true, destructive: false, inputSchema: repoSessionIdSchema, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-tree.ts b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts index ec175f0e7f..6a9be9a3cd 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-tree.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts @@ -83,10 +83,10 @@ function summarizeTree(node: { depth: number, ) { totals.max_depth = Math.max(totals.max_depth, depth) - totals.total_bytes += current.size switch (current.type) { case 'file': totals.files += 1 + totals.total_bytes += current.size break case 'directory': totals.directories += 1 diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index 6b7d74e329..874d470425 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -277,7 +277,7 @@ export function parseEntityRef(entity: string): { const separator = trimmed.lastIndexOf(':') if (separator <= 0 || separator === trimmed.length - 1) { throw new Error( - 'Entity must use the format "{id}:{type}" where type is capability, skill, app, secret, value, or connector.', + 'Entity must use the format "{id}:{type}" where type is capability, skill, app, job, secret, value, or connector.', ) } const id = trimmed.slice(0, separator).trim() diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 660da48bbf..4712807863 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -554,7 +554,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { .min(1) .optional() .describe( - 'Optional exact entity reference in the format "{id}:{type}" where type is capability, skill, app, secret, value, or connector.', + 'Optional exact entity reference in the format "{id}:{type}" where type is capability, skill, app, job, secret, value, or connector.', ), skill_collection: z .string() diff --git a/packages/worker/src/repo/artifacts.ts b/packages/worker/src/repo/artifacts.ts index 43ddc91278..e8e96b5597 100644 --- a/packages/worker/src/repo/artifacts.ts +++ b/packages/worker/src/repo/artifacts.ts @@ -127,7 +127,13 @@ export function buildAuthenticatedArtifactsRemote(input: { token: string }) { const tokenSecret = parseArtifactTokenSecret(input.token) - return `https://x:${tokenSecret}@${input.remote.slice('https://'.length)}` + const remoteUrl = new URL(input.remote) + if (remoteUrl.protocol !== 'https:') { + throw new Error(`Artifact remote must use https://, got: ${input.remote}`) + } + remoteUrl.username = 'x' + remoteUrl.password = tokenSecret + return remoteUrl.toString() } export async function resolveArtifactSourceRepo(env: Env, repoId: string) { diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts index 6a66f73fb5..6d7fa542f5 100644 --- a/packages/worker/src/repo/source-sync.ts +++ b/packages/worker/src/repo/source-sync.ts @@ -15,7 +15,9 @@ function canSyncArtifactSource(env: Env) { (env as Env & { ARTIFACTS?: unknown }).ARTIFACTS != null && typeof (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION === 'object' && - (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION != null + (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION != null && + typeof (env as Env & { APP_DB?: unknown }).APP_DB === 'object' && + (env as Env & { APP_DB?: unknown }).APP_DB != null ) } From 13326c984717556ef5fbf0043b45d42c948f3150 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:04:17 +0000 Subject: [PATCH 22/42] Honor forced repo session publish --- packages/worker/src/repo/repo-session-do.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index 8fb0312f02..e77cd268cd 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -824,7 +824,7 @@ class RepoSessionBase extends DurableObject { if ( (!input.force && !checkStatus.runId) || (!input.force && !checkStatus.ok) || - checkStatus.treeHash !== currentTreeHash + (!input.force && checkStatus.treeHash !== currentTreeHash) ) { return { status: 'checks_outdated', From 4f1a8c03a5eb9f4845b69d644fe5f508d623b70b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:24:52 +0000 Subject: [PATCH 23/42] Clean up repo runtime sessions --- packages/worker/src/jobs/service.node.test.ts | 117 ++++++++++++ packages/worker/src/jobs/service.ts | 168 +++++++++--------- .../worker/src/mcp/skills/run-saved-skill.ts | 122 +++++++------ 3 files changed, 270 insertions(+), 137 deletions(-) diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 2a2c1d9f47..66e08babc2 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -644,6 +644,123 @@ test('executeJobOnce preserves codemode secret and value semantics', async () => } }) +test('executeJobOnce discards repo sessions after repo-backed execution', async () => { + const env = { + APP_DB: createDatabase(), + LOADER: {} as WorkerLoader, + } as Env + const callerContext = createBaseCallerContext() + const job: JobRecord = { + version: 1, + id: 'job-repo-1', + userId: callerContext.user.userId, + name: 'Repo-backed job', + code: null, + sourceId: 'source-1', + publishedCommit: 'commit-1', + storageId: 'job:job-repo-1', + schedule: { + type: 'once', + runAt: '2026-04-17T15:00:00Z', + }, + timezone: 'UTC', + enabled: true, + killSwitchEnabled: false, + createdAt: '2026-04-16T00:00:00.000Z', + updatedAt: '2026-04-16T00:00:00.000Z', + nextRunAt: '2026-04-17T15:00:00.000Z', + runCount: 0, + successCount: 0, + errorCount: 0, + runHistory: [], + } + + const sessionClient = { + openSession: vi.fn(async () => ({ + id: 'job-runtime-job-repo-1', + source_id: 'source-1', + source_root: '/', + base_commit: 'base-1', + session_repo_id: 'session-repo-1', + session_repo_name: 'session-repo-name', + session_repo_namespace: 'default', + conversation_id: null, + last_checkpoint_commit: null, + last_check_run_id: null, + last_check_tree_hash: null, + expires_at: null, + created_at: '2026-04-16T00:00:00.000Z', + updated_at: '2026-04-16T00:00:00.000Z', + published_commit: 'commit-1', + manifest_path: 'kody.json', + entity_type: 'job', + })), + runChecks: vi.fn(async () => ({ + ok: true, + results: [], + manifest: { + version: 1, + kind: 'job', + title: 'Repo-backed job', + description: 'Runs from repo', + entrypoint: 'src/job.ts', + }, + })), + readFile: vi.fn(async ({ path }: { path: string }) => ({ + path, + content: + path === 'kody.json' + ? JSON.stringify({ + version: 1, + kind: 'job', + title: 'Repo-backed job', + description: 'Runs from repo', + entrypoint: 'src/job.ts', + }) + : 'async () => ({ ok: true, repoBacked: true })', + })), + discardSession: vi.fn(async () => ({ + ok: true as const, + sessionId: 'job-runtime-job-repo-1', + deleted: true, + })), + } + + const repoSessionRpcSpy = vi + .spyOn(await import('#worker/repo/repo-session-do.ts'), 'repoSessionRpc') + .mockReturnValue(sessionClient as never) + const executeSpy = vi + .spyOn( + await import('#mcp/run-codemode-registry.ts'), + 'runCodemodeWithRegistry', + ) + .mockResolvedValue({ + result: { ok: true, repoBacked: true }, + logs: ['repo-backed codemode executed'], + }) + + try { + const outcome = await executeJobOnce({ + env, + job, + callerContext, + }) + + expect(outcome.execution).toEqual({ + ok: true, + result: { ok: true, repoBacked: true }, + logs: ['repo-backed codemode executed'], + }) + expect(sessionClient.discardSession).toHaveBeenCalledWith({ + sessionId: 'job-runtime-job-repo-1', + }) + expect(executeSpy).toHaveBeenCalledTimes(1) + } finally { + repoSessionRpcSpy.mockRestore() + executeSpy.mockRestore() + } +}) + test('executeJobOnce returns an error when codemode secret policy would reject execution', async () => { const env = { APP_DB: createDatabase(), diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 9b591aaa90..d039fc6f91 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -422,99 +422,107 @@ async function runRepoBackedJob(input: { } } const sessionId = `job-runtime-${input.job.id}` - const session = await repoSessionRpc(input.env, sessionId).openSession({ + const sessionClient = repoSessionRpc(input.env, sessionId) + const session = await sessionClient.openSession({ sessionId, sourceId: input.job.sourceId, userId: input.callerContext.user.userId, baseUrl: input.callerContext.baseUrl, sourceRoot: '/', }) - const result = await repoSessionRpc(input.env, session.id).runChecks({ - sessionId: session.id, - }) - if (!result.ok) { - return { - error: result.results - .filter((entry) => !entry.ok) - .map((entry) => entry.message) - .join('\n'), - result: null, - logs: [], - } - } - const manifestPath = session.manifest_path?.replace(/^\/+/, '') || 'kody.json' - const entrypoint = await repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: manifestPath, - }) - if (!entrypoint.content) { - return { - error: `Job manifest "${manifestPath}" was not found in repo session.`, - result: null, - logs: [], - } - } - let manifest: ReturnType try { - manifest = parseRepoManifest({ - content: entrypoint.content, - manifestPath, + const result = await sessionClient.runChecks({ + sessionId: session.id, }) - } catch (error) { - return { - error: error instanceof Error ? error.message : String(error), - result: null, - logs: [], + if (!result.ok) { + return { + error: result.results + .filter((entry) => !entry.ok) + .map((entry) => entry.message) + .join('\n'), + result: null, + logs: [], + } } - } - if (manifest.kind !== 'job') { - return { - error: `Repo source "${input.job.sourceId}" is not a job manifest.`, - result: null, - logs: [], + const manifestPath = + session.manifest_path?.replace(/^\/+/, '') || 'kody.json' + const entrypoint = await sessionClient.readFile({ + sessionId: session.id, + path: manifestPath, + }) + if (!entrypoint.content) { + return { + error: `Job manifest "${manifestPath}" was not found in repo session.`, + result: null, + logs: [], + } } - } - const moduleFile = await repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: manifest.entrypoint.replace(/^\/+/, ''), - }) - if (!moduleFile.content) { - return { - error: `Job entrypoint "${manifest.entrypoint}" was not found in repo session.`, - result: null, - logs: [], + let manifest: ReturnType + try { + manifest = parseRepoManifest({ + content: entrypoint.content, + manifestPath, + }) + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + result: null, + logs: [], + } } - } - const { runCodemodeWithRegistry } = - await import('#mcp/run-codemode-registry.ts') - return await runCodemodeWithRegistry( - input.env, - { - ...input.callerContext, - repoContext: { - sourceId: session.source_id, - repoId: null, - sessionId: session.id, - sessionRepoId: session.session_repo_id, - baseCommit: session.base_commit, - manifestPath: session.manifest_path, - sourceRoot: session.source_root, - publishedCommit: session.published_commit, - entityKind: session.entity_type, - entityId: input.job.id, + if (manifest.kind !== 'job') { + return { + error: `Repo source "${input.job.sourceId}" is not a job manifest.`, + result: null, + logs: [], + } + } + const moduleFile = await sessionClient.readFile({ + sessionId: session.id, + path: manifest.entrypoint.replace(/^\/+/, ''), + }) + if (!moduleFile.content) { + return { + error: `Job entrypoint "${manifest.entrypoint}" was not found in repo session.`, + result: null, + logs: [], + } + } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') + return await runCodemodeWithRegistry( + input.env, + { + ...input.callerContext, + repoContext: { + sourceId: session.source_id, + repoId: null, + sessionId: session.id, + sessionRepoId: session.session_repo_id, + baseCommit: session.base_commit, + manifestPath: session.manifest_path, + sourceRoot: session.source_root, + publishedCommit: session.published_commit, + entityKind: session.entity_type, + entityId: input.job.id, + }, }, - }, - moduleFile.content, - input.job.params, - { - executorExports: workerExports, - storageTools: { - userId: input.callerContext.user.userId, - storageId: input.job.storageId, - writable: true, + moduleFile.content, + input.job.params, + { + executorExports: workerExports, + storageTools: { + userId: input.callerContext.user.userId, + storageId: input.job.storageId, + writable: true, + }, }, - }, - ) + ) + } finally { + await sessionClient.discardSession({ sessionId: session.id }).catch(() => { + // Best effort only; preserve the original execution failure. + }) + } } export async function runJobNow(input: { diff --git a/packages/worker/src/mcp/skills/run-saved-skill.ts b/packages/worker/src/mcp/skills/run-saved-skill.ts index 6e59e8224f..9c945cd6a6 100644 --- a/packages/worker/src/mcp/skills/run-saved-skill.ts +++ b/packages/worker/src/mcp/skills/run-saved-skill.ts @@ -104,71 +104,79 @@ async function runRepoBackedSkill(input: { } } const sessionId = `skill-runtime-${input.row.id}` - const session = await repoSessionRpc(input.env, sessionId).openSession({ + const sessionClient = repoSessionRpc(input.env, sessionId) + const session = await sessionClient.openSession({ sessionId, sourceId: input.row.source_id, userId: input.callerContext.user?.userId ?? '', baseUrl: input.callerContext.baseUrl, sourceRoot: '/', }) - const manifestPath = session.manifest_path?.replace(/^\/+/, '') || 'kody.json' - const entrypoint = await repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: manifestPath, - }) - if (!entrypoint.content) { - return { - result: undefined, - error: `Skill manifest "${manifestPath}" was not found in repo session.`, - logs: [], + try { + const manifestPath = + session.manifest_path?.replace(/^\/+/, '') || 'kody.json' + const entrypoint = await sessionClient.readFile({ + sessionId: session.id, + path: manifestPath, + }) + if (!entrypoint.content) { + return { + result: undefined, + error: `Skill manifest "${manifestPath}" was not found in repo session.`, + logs: [], + } } - } - const { parseRepoManifest } = await import('#worker/repo/manifest.ts') - const manifest = parseRepoManifest({ - content: entrypoint.content, - manifestPath, - }) - if (manifest.kind !== 'skill') { - return { - result: undefined, - error: `Repo source "${input.row.source_id}" is not a skill manifest.`, - logs: [], + const { parseRepoManifest } = await import('#worker/repo/manifest.ts') + const manifest = parseRepoManifest({ + content: entrypoint.content, + manifestPath, + }) + if (manifest.kind !== 'skill') { + return { + result: undefined, + error: `Repo source "${input.row.source_id}" is not a skill manifest.`, + logs: [], + } } - } - const moduleFile = await repoSessionRpc(input.env, session.id).readFile({ - sessionId: session.id, - path: manifest.entrypoint.replace(/^\/+/, ''), - }) - if (!moduleFile.content) { - return { - result: undefined, - error: `Skill entrypoint "${manifest.entrypoint}" was not found in repo session.`, - logs: [], + const moduleFile = await sessionClient.readFile({ + sessionId: session.id, + path: manifest.entrypoint.replace(/^\/+/, ''), + }) + if (!moduleFile.content) { + return { + result: undefined, + error: `Skill entrypoint "${manifest.entrypoint}" was not found in repo session.`, + logs: [], + } } - } - const { runCodemodeWithRegistry } = - await import('#mcp/run-codemode-registry.ts') - return runCodemodeWithRegistry( - input.env, - { - ...input.callerContext, - repoContext: { - sourceId: session.source_id, - repoId: null, - sessionId: session.id, - sessionRepoId: session.session_repo_id, - baseCommit: session.base_commit, - manifestPath: session.manifest_path, - sourceRoot: session.source_root, - publishedCommit: session.published_commit, - entityKind: session.entity_type, - entityId: input.row.id, + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') + return runCodemodeWithRegistry( + input.env, + { + ...input.callerContext, + repoContext: { + sourceId: session.source_id, + repoId: null, + sessionId: session.id, + sessionRepoId: session.session_repo_id, + baseCommit: session.base_commit, + manifestPath: session.manifest_path, + sourceRoot: session.source_root, + publishedCommit: session.published_commit, + entityKind: session.entity_type, + entityId: input.row.id, + }, }, - }, - moduleFile.content, - input.params, - { - executorExports: workerExports, - }, - ) + moduleFile.content, + input.params, + { + executorExports: workerExports, + }, + ) + } finally { + await sessionClient.discardSession({ sessionId: session.id }).catch(() => { + // Best effort only; preserve the original execution failure. + }) + } } From cd7f099e1deb72737fee110aa4a62d0bb5424f46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:29:00 +0000 Subject: [PATCH 24/42] Tighten saved app and job search flows --- .../src/mcp/capabilities/apps/ui-save-app.ts | 19 ++++++++------- .../src/mcp/capabilities/unified-search.ts | 3 ++- packages/worker/src/mcp/generated-ui-api.ts | 23 +++++++++++++++---- packages/worker/src/mcp/tools/search.ts | 2 +- packages/worker/src/repo/source-templates.ts | 2 +- 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index f8c9b99d66..60f5e0c701 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -161,14 +161,15 @@ export const uiSaveAppCapability = defineDomainCapability( const user = requireMcpUser(ctx.callerContext) const isUpdate = args.app_id !== undefined const appId = args.app_id ?? crypto.randomUUID() - const ensuredSource = await ensureEntitySource({ - db: ctx.env.APP_DB, - env: ctx.env, - userId: user.userId, - entityKind: 'app', - entityId: appId, - sourceRoot: '/', - }) + const ensureSource = () => + ensureEntitySource({ + db: ctx.env.APP_DB, + env: ctx.env, + userId: user.userId, + entityKind: 'app', + entityId: appId, + sourceRoot: '/', + }) let hidden: boolean let existingApp: Awaited> | null = null @@ -292,6 +293,7 @@ export const uiSaveAppCapability = defineDomainCapability( if (!existingApp) { throw new Error('Saved UI artifact not found for this user.') } + const ensuredSource = await ensureSource() const title = args.title ?? existingApp.title const description = args.description ?? existingApp.description const clientCode = args.clientCode ?? existingApp.clientCode @@ -375,6 +377,7 @@ export const uiSaveAppCapability = defineDomainCapability( : null const serverCodeId = crypto.randomUUID() hidden = args.hidden ?? true + const ensuredSource = await ensureSource() const now = new Date().toISOString() await insertUiArtifact(ctx.env.APP_DB, { id: appId, diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index 5f977a5fa9..c3fdf39a2c 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -388,6 +388,7 @@ function scoreJobLexicalMatch( function buildJobEmbedDoc(row: JobView): string { return buildJobEmbedText({ name: row.name, + description: row.scheduleSummary, scheduleSummary: row.scheduleSummary, sourceId: row.sourceId, publishedCommit: row.publishedCommit, @@ -512,7 +513,7 @@ function rowToJobHit( jobId: row.id, domain: 'jobs', title: row.name, - description: row.name, + description: row.scheduleSummary, scheduleSummary: row.scheduleSummary, sourceId: row.sourceId ?? null, publishedCommit: row.publishedCommit ?? null, diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts index 882a0900f2..55c8f485da 100644 --- a/packages/worker/src/mcp/generated-ui-api.ts +++ b/packages/worker/src/mcp/generated-ui-api.ts @@ -142,11 +142,24 @@ function createGeneratedUiSourceHandler(env: Env) { if (!app) { return jsonResponse({ error: 'Saved UI not found.' }, 404) } - const resolvedApp = await resolveSavedAppSource({ - env, - baseUrl: getAppBaseUrl({ env, requestUrl: request.url }), - artifact: app, - }) + let resolvedApp + try { + resolvedApp = await resolveSavedAppSource({ + env, + baseUrl: getAppBaseUrl({ env, requestUrl: request.url }), + artifact: app, + }) + } catch (error) { + return jsonResponse( + { + error: + error instanceof Error + ? error.message + : 'Unable to load saved UI source.', + }, + 502, + ) + } let resolvedParams: Record try { resolvedParams = diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 4712807863..5df849af69 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -480,7 +480,7 @@ async function resolveEntityDetail(input: { type: 'job' as const, id: row.id, title: row.name, - description: row.name, + description: row.scheduleSummary, row, } } diff --git a/packages/worker/src/repo/source-templates.ts b/packages/worker/src/repo/source-templates.ts index bd6c770500..7beffc1960 100644 --- a/packages/worker/src/repo/source-templates.ts +++ b/packages/worker/src/repo/source-templates.ts @@ -48,7 +48,7 @@ export function buildJobSourceFiles(input: { version: 1 as const, kind: 'job' as const, title: input.job.name, - description: input.job.name, + description: input.job.scheduleSummary, keywords: ['job', 'scheduled'], searchText: input.job.scheduleSummary, sourceRoot: '/', From e1e1246b073741851c32d470aad482e71acdbddb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:32:32 +0000 Subject: [PATCH 25/42] Refine repo publish and search reporting --- .../capabilities/repo/repo-publish-session.ts | 2 +- packages/worker/src/repo/repo-session-do.ts | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts index 48fb637511..9aa045563c 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts @@ -50,7 +50,7 @@ export const repoPublishSessionCapability = defineDomainCapability( return { status: 'ok' as const, session_id: result.sessionId, - published_commit: result.publishedCommit ?? '', + published_commit: result.publishedCommit, message: result.message, } } diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index e77cd268cd..56900eb02e 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -543,11 +543,12 @@ class RepoSessionBase extends DurableObject { const outputMode = input.outputMode ?? 'content' const maxMatches = normalizeSearchLimit(input.limit) let totalMatches = 0 + let truncated = false for (const file of files) { if (file.type !== 'file') continue const content = await this.workspace.readFile(file.path) if (content == null) continue - const matches = searchInText({ + const result = searchInText({ content, query: search.query, regex: search.regex, @@ -556,7 +557,9 @@ class RepoSessionBase extends DurableObject { contextAfter: input.after ?? 0, maxMatches, }) + const matches = result.matches if (matches.length === 0) continue + truncated ||= result.truncated matchMap.set(file.path, { path: this.toExternalPath(file.path), matches: @@ -581,7 +584,7 @@ class RepoSessionBase extends DurableObject { totalFiles: filesWithMatches.length, totalMatches, outputMode, - truncated: totalMatches > maxMatches, + truncated, } } @@ -1159,8 +1162,12 @@ function searchInText(input: { beforeLines?: string[] afterLines?: string[] }> = [] + let truncated = false for (const match of source.matchAll(matcher)) { - if (matches.length >= input.maxMatches) break + if (matches.length >= input.maxMatches) { + truncated = true + break + } const index = match.index ?? 0 let lineIndex = 0 for (let candidate = 0; candidate < lineOffsets.length; candidate += 1) { @@ -1183,5 +1190,8 @@ function searchInText(input: { afterLines: lines.slice(lineIndex + 1, afterEnd), }) } - return matches + return { + matches, + truncated, + } } From a71e5d8485268ed44393eaf0b6dcdbe468dc94bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:40:48 +0000 Subject: [PATCH 26/42] Rollback saved app source state on failure --- .../src/mcp/capabilities/apps/ui-save-app.ts | 74 ++++++++++++++++--- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index 60f5e0c701..ebcbc5ca0b 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -29,6 +29,10 @@ import { import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' import { buildAppSourceFiles } from '#worker/repo/source-templates.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' +import { + getEntitySourceById, + updateEntitySource, +} from '#worker/repo/entity-sources.ts' const appServerCodeExportPattern = /export\s+class\s+App\s+extends\s+DurableObject\b/ @@ -40,6 +44,10 @@ function assertValidSavedAppServerCode(serverCode: string | null | undefined) { } } +function hasAppDbBinding(db: D1Database | null | undefined) { + return typeof db?.prepare === 'function' +} + const inputSchema = z .object({ app_id: z @@ -174,6 +182,32 @@ export const uiSaveAppCapability = defineDomainCapability( let existingApp: Awaited> | null = null + const readPublishedCommit = async ( + sourceId: string, + ): Promise => { + if (!hasAppDbBinding(ctx.env.APP_DB)) { + return null + } + return ( + (await getEntitySourceById(ctx.env.APP_DB, sourceId)) + ?.published_commit ?? null + ) + } + + const restorePublishedCommit = async ( + sourceId: string, + publishedCommit: string | null, + ) => { + if (!hasAppDbBinding(ctx.env.APP_DB)) { + return + } + await updateEntitySource(ctx.env.APP_DB, { + id: sourceId, + userId: user.userId, + publishedCommit, + }) + } + async function saveAndIndexApp(input: { appId: string title: string @@ -183,6 +217,8 @@ export const uiSaveAppCapability = defineDomainCapability( serverCodeId: string parameters: ReturnType hidden: boolean + sourceId: string + previousPublishedCommit: string | null existingApp: Awaited> | null }) { const hasBackend = hasUiArtifactServerCode(input.serverCode) @@ -203,19 +239,29 @@ export const uiSaveAppCapability = defineDomainCapability( appId: input.appId, }), deleteUiArtifact(ctx.env.APP_DB, user.userId, input.appId), + restorePublishedCommit( + input.sourceId, + input.previousPublishedCommit, + ), ]) } else if (input.existingApp) { try { - await updateUiArtifact(ctx.env.APP_DB, user.userId, input.appId, { - title: input.existingApp.title, - description: input.existingApp.description, - sourceId: input.existingApp.sourceId, - clientCode: input.existingApp.clientCode, - serverCode: input.existingApp.serverCode, - serverCodeId: input.existingApp.serverCodeId, - parameters: input.existingApp.parameters, - hidden: input.existingApp.hidden, - }) + await Promise.allSettled([ + updateUiArtifact(ctx.env.APP_DB, user.userId, input.appId, { + title: input.existingApp.title, + description: input.existingApp.description, + sourceId: input.existingApp.sourceId, + clientCode: input.existingApp.clientCode, + serverCode: input.existingApp.serverCode, + serverCodeId: input.existingApp.serverCodeId, + parameters: input.existingApp.parameters, + hidden: input.existingApp.hidden, + }), + restorePublishedCommit( + input.sourceId, + input.previousPublishedCommit, + ), + ]) } catch { // Preserve the original runner configuration failure. } @@ -246,6 +292,10 @@ export const uiSaveAppCapability = defineDomainCapability( appId: input.appId, }), deleteUiArtifact(ctx.env.APP_DB, user.userId, input.appId), + restorePublishedCommit( + input.sourceId, + input.previousPublishedCommit, + ), ]) throw cause } @@ -363,6 +413,8 @@ export const uiSaveAppCapability = defineDomainCapability( serverCodeId, parameters, hidden, + sourceId: ensuredSource.id, + previousPublishedCommit: await readPublishedCommit(ensuredSource.id), existingApp, }) } else { @@ -416,6 +468,8 @@ export const uiSaveAppCapability = defineDomainCapability( serverCodeId, parameters, hidden, + sourceId: ensuredSource.id, + previousPublishedCommit: await readPublishedCommit(ensuredSource.id), existingApp, }) } From 26250cb70100f3364f905b37138471e08ae38227 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:44:00 +0000 Subject: [PATCH 27/42] Use unique repo runtime session ids --- packages/worker/src/jobs/service.ts | 2 +- packages/worker/src/mcp/skills/run-saved-skill.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index d039fc6f91..41921d8040 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -421,7 +421,7 @@ async function runRepoBackedJob(input: { logs: [], } } - const sessionId = `job-runtime-${input.job.id}` + const sessionId = `job-runtime-${input.job.id}-${crypto.randomUUID()}` const sessionClient = repoSessionRpc(input.env, sessionId) const session = await sessionClient.openSession({ sessionId, diff --git a/packages/worker/src/mcp/skills/run-saved-skill.ts b/packages/worker/src/mcp/skills/run-saved-skill.ts index 9c945cd6a6..df3ea98abe 100644 --- a/packages/worker/src/mcp/skills/run-saved-skill.ts +++ b/packages/worker/src/mcp/skills/run-saved-skill.ts @@ -103,7 +103,7 @@ async function runRepoBackedSkill(input: { logs: [], } } - const sessionId = `skill-runtime-${input.row.id}` + const sessionId = `skill-runtime-${input.row.id}-${crypto.randomUUID()}` const sessionClient = repoSessionRpc(input.env, sessionId) const session = await sessionClient.openSession({ sessionId, From d6ce0008e1a1c15d04b4a5bccc07d625ebf568d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 00:51:41 +0000 Subject: [PATCH 28/42] Normalize repo app params and merge imports --- .../src/mcp/capabilities/meta/meta-save-skill.ts | 6 ++++-- packages/worker/src/repo/app-source.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index a3194bc19f..92715e6ebd 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -2,7 +2,10 @@ 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 { deleteEntitySource } from '#worker/repo/entity-sources.ts' +import { + deleteEntitySource, + updateEntitySource, +} from '#worker/repo/entity-sources.ts' import { deleteMcpSkill, getMcpSkillByName, @@ -20,7 +23,6 @@ import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts' import { buildSkillSourceFiles } from '#worker/repo/source-templates.ts' import { requireMcpUser } from './require-user.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' -import { updateEntitySource } from '#worker/repo/entity-sources.ts' const inputSchema = z.object({ name: z diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 964e0ebf2b..c9687a4120 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -1,5 +1,8 @@ import { type UiArtifactParameterDefinition } from '@kody-internal/shared/ui-artifact-parameters.ts' -import { parseUiArtifactParameters } from '#mcp/ui-artifact-parameters.ts' +import { + normalizeUiArtifactParameters, + parseUiArtifactParameters, +} from '#mcp/ui-artifact-parameters.ts' import { type UiArtifactRow } from '#mcp/ui-artifacts-types.ts' import { getEntitySourceById } from './entity-sources.ts' import { parseRepoManifest } from './manifest.ts' @@ -135,8 +138,9 @@ export async function resolveSavedAppSource(input: { title: manifest.title, description: manifest.description, hidden: manifest.hidden ?? fallback.hidden, - parameters: - (manifest.parameters as Array) ?? null, + parameters: manifest.parameters + ? normalizeUiArtifactParameters(manifest.parameters) + : null, clientCode: clientFile.content ?? fallback.clientCode, serverCode: serverFile.content ?? fallback.serverCode, serverCodeId: source.published_commit ?? fallback.serverCodeId, From 69331f2a932f6316cd8b1ef7b02d4caabcaaddff Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 16 Apr 2026 19:12:56 -0600 Subject: [PATCH 29/42] Harden repo search and align patch schemas Protect repo session search from unsafe regex patterns, unify the repo patch capability with the shared schema, and keep once-job cleanup and AI model typing in sync with runtime behavior. Made-with: Cursor --- packages/worker/src/jobs/service.node.test.ts | 57 +++++++++++++++++- packages/worker/src/jobs/service.ts | 1 + .../mcp/capabilities/repo/repo-apply-patch.ts | 59 ++----------------- .../src/mcp/capabilities/repo/repo-shared.ts | 8 +-- packages/worker/src/repo/repo-session-do.ts | 39 +++++++++++- packages/worker/worker-configuration.d.ts | 1 + 6 files changed, 102 insertions(+), 63 deletions(-) diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 66e08babc2..7beebf4125 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -3,7 +3,7 @@ import { createMcpCallerContext } from '#mcp/context.ts' import { createCapabilitySecretAccessDeniedMessage } from '#mcp/secrets/errors.ts' import { saveSecret } from '#mcp/secrets/service.ts' import { saveValue } from '#mcp/values/service.ts' -import { createJob, executeJobOnce, updateJob } from './service.ts' +import { createJob, executeJobOnce, runJobNow } from './service.ts' import { type JobCreateInput, type JobRecord, @@ -820,3 +820,58 @@ test('executeJobOnce returns an error when codemode secret policy would reject e executeSpy.mockRestore() } }) + +test('runJobNow deletes vectors for once jobs', async () => { + const db = createDatabase() + const env = { + APP_DB: db, + LOADER: {} as WorkerLoader, + } as Env & { CAPABILITY_VECTOR_INDEX?: Pick } + const callerContext = createBaseCallerContext() + const jobView = await createJob({ + env, + callerContext, + body: { + name: 'Run once and delete vector', + code: 'async () => ({ ok: true })', + schedule: { + type: 'once', + runAt: '2026-04-17T15:00:00Z', + }, + }, + }) + const deleteByIds = vi.fn(async () => {}) + env.CAPABILITY_VECTOR_INDEX = { + deleteByIds, + } + const executeSpy = vi + .spyOn( + await import('#mcp/run-codemode-registry.ts'), + 'runCodemodeWithRegistry', + ) + .mockResolvedValue({ + result: { ok: true }, + logs: [], + }) + + try { + const result = await runJobNow({ + env: env as Env, + userId: callerContext.user.userId, + jobId: jobView.id, + callerContext, + }) + expect(result.execution).toEqual({ + ok: true, + result: { ok: true }, + logs: [], + }) + expect(deleteByIds).toHaveBeenCalledWith([`job_${jobView.id}`]) + const row = await ( + await import('./repo.ts') + ).getJobRowById(db, callerContext.user.userId, jobView.id) + expect(row).toBeNull() + } finally { + executeSpy.mockRestore() + } +}) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 41921d8040..386e2e1b74 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -555,6 +555,7 @@ export async function runJobNow(input: { }) if (row.record.schedule.type === 'once') { await deleteJobRow(input.env.APP_DB, input.userId, input.jobId) + await deleteJobVector(input.env, input.jobId) } else { await updateJobRow({ db: input.env.APP_DB, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts index c172a90177..248a5d06e2 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts @@ -1,58 +1,11 @@ -import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' - -const repoPatchSearchOptionsSchema = z.object({ - case_sensitive: z.boolean().optional(), - regex: z.boolean().optional(), - whole_word: z.boolean().optional(), - context_before: z.number().int().min(0).optional(), - context_after: z.number().int().min(0).optional(), - max_matches: z.number().int().min(1).optional(), -}) - -const repoPatchInstructionSchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('write'), - path: z.string().min(1), - content: z.string(), - }), - z.object({ - kind: z.literal('replace'), - path: z.string().min(1), - search: z.string().min(1), - replacement: z.string(), - options: repoPatchSearchOptionsSchema.optional(), - }), - z.object({ - kind: z.literal('write_json'), - path: z.string().min(1), - value: z.unknown(), - spaces: z.number().int().min(0).optional(), - }), -]) - -const inputSchema = z.object({ - session_id: z.string().min(1), - instructions: z.array(repoPatchInstructionSchema).min(1), - dry_run: z.boolean().optional(), - rollback_on_error: z.boolean().optional(), -}) - -const outputSchema = z.object({ - dry_run: z.boolean(), - edits: z.array( - z.object({ - path: z.string(), - changed: z.boolean(), - content: z.string(), - diff: z.string(), - }), - ), - total_changed: z.number().int().min(0), -}) +import { + repoApplyPatchInputSchema, + repoApplyPatchResultSchema, +} from './repo-shared.ts' export const repoApplyPatchCapability = defineDomainCapability( capabilityDomainNames.repo, @@ -64,8 +17,8 @@ export const repoApplyPatchCapability = defineDomainCapability( readOnly: false, idempotent: false, destructive: false, - inputSchema, - outputSchema, + inputSchema: repoApplyPatchInputSchema, + outputSchema: repoApplyPatchResultSchema, async handler(args, ctx) { requireMcpUser(ctx.callerContext) const result = await repoSessionRpc(ctx.env, args.session_id).applyEdits({ diff --git a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts index 497aa2da32..ab97d4ed3e 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts @@ -208,14 +208,10 @@ export const repoPatchInstructionSchema = z.discriminatedUnion('kind', [ options: repoPatchSearchOptionsSchema.optional(), }), z.object({ - kind: z.literal('writeJson'), + kind: z.literal('write_json'), path: z.string().min(1), value: z.unknown(), - options: z - .object({ - spaces: z.number().int().min(0).optional(), - }) - .optional(), + spaces: z.number().int().min(0).optional(), }), ]) diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index 56900eb02e..a818b6d3fc 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -24,6 +24,10 @@ import { runRepoChecks, type RepoCheckRunResult } from './checks.ts' const repoSessionWorkspacePrefix = '/session' const defaultRepoSearchLimit = 50 +const maxRepoSearchBytes = 200_000 +const maxRepoSearchRegexLength = 512 +const obviousNestedQuantifierPattern = + /\((?:[^()\\]|\\.)*[+*{][^)]*\)(?:[+*]|\{\d+(?:,\d*)?\})/ export type RepoSearchMode = 'literal' | 'regex' export type RepoSearchOutputMode = 'content' | 'files' @@ -150,6 +154,19 @@ function escapeRegex(source: string) { return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function assertSafeRepoSearchRegex(pattern: string) { + if (pattern.length > maxRepoSearchRegexLength) { + throw new Error( + `repo_search regex patterns must be ${maxRepoSearchRegexLength} characters or fewer.`, + ) + } + if (obviousNestedQuantifierPattern.test(pattern)) { + throw new Error( + 'repo_search rejected an unsafe regex pattern with nested quantifiers.', + ) + } +} + function normalizeSearchQuery(input: { pattern: string mode?: RepoSearchMode @@ -1143,10 +1160,26 @@ function searchInText(input: { contextAfter: number maxMatches: number }) { - const source = input.content + const inputTruncated = input.content.length > maxRepoSearchBytes + // Bound regex work so a single pathological search cannot monopolize the DO. + const source = inputTruncated + ? input.content.slice(0, maxRepoSearchBytes) + : input.content const flags = input.caseSensitive ? 'g' : 'gi' const pattern = input.regex ? input.query : escapeRegex(input.query) - const matcher = new RegExp(pattern, flags) + if (input.regex) { + assertSafeRepoSearchRegex(pattern) + } + let matcher: RegExp + try { + matcher = new RegExp(pattern, flags) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unknown regex compilation error.' + throw new Error(`repo_search received an invalid regex: ${message}`) + } const lines = source.split('\n') const lineOffsets: number[] = [] let offset = 0 @@ -1192,6 +1225,6 @@ function searchInText(input: { } return { matches, - truncated, + truncated: truncated || inputTruncated, } } diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index 919ddcf77d..fa931f9077 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -9507,6 +9507,7 @@ interface AiModels { "@cf/deepgram/aura-1": Base_Ai_Cf_Deepgram_Aura_1; "@cf/ai4bharat/indictrans2-en-indic-1B": Base_Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B; "@cf/aisingapore/gemma-sea-lion-v4-27b-it": Base_Ai_Cf_Aisingapore_Gemma_Sea_Lion_V4_27B_It; + "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; "@cf/pfnet/plamo-embedding-1b": Base_Ai_Cf_Pfnet_Plamo_Embedding_1B; "@cf/deepgram/flux": Base_Ai_Cf_Deepgram_Flux; "@cf/deepgram/aura-2-en": Base_Ai_Cf_Deepgram_Aura_2_En; From 1915391381e81807dd15218bd3365049803cf352 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 01:18:13 +0000 Subject: [PATCH 30/42] Fix ui save rollback commit capture Co-authored-by: Kent C. Dodds --- .../worker/src/mcp/capabilities/apps/ui-save-app.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index ebcbc5ca0b..b2688919fd 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -389,6 +389,9 @@ export const uiSaveAppCapability = defineDomainCapability( if (!updated) { throw new Error('Saved UI artifact not found for this user.') } + const previousPublishedCommit = await readPublishedCommit( + ensuredSource.id, + ) await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, @@ -414,7 +417,7 @@ export const uiSaveAppCapability = defineDomainCapability( parameters, hidden, sourceId: ensuredSource.id, - previousPublishedCommit: await readPublishedCommit(ensuredSource.id), + previousPublishedCommit, existingApp, }) } else { @@ -445,6 +448,9 @@ export const uiSaveAppCapability = defineDomainCapability( created_at: now, updated_at: now, }) + const previousPublishedCommit = await readPublishedCommit( + ensuredSource.id, + ) await syncArtifactSourceSnapshot({ env: ctx.env, userId: user.userId, @@ -469,7 +475,7 @@ export const uiSaveAppCapability = defineDomainCapability( parameters, hidden, sourceId: ensuredSource.id, - previousPublishedCommit: await readPublishedCommit(ensuredSource.id), + previousPublishedCommit, existingApp, }) } From 571464bbb9a2cffc38fc4f212127e98809cbc0d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 01:24:40 +0000 Subject: [PATCH 31/42] Fix cache and caller context handling Co-authored-by: Kent C. Dodds Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/service.ts | 3 +++ packages/worker/src/repo/app-source.ts | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 386e2e1b74..515d74ab1c 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -358,6 +358,9 @@ export async function executeJobOnce(input: { appId: input.callerContext.storageContext?.appId ?? null, storageId: input.job.storageId, }, + repoContext: input.job.sourceId + ? (input.callerContext.repoContext ?? null) + : null, } const { runCodemodeWithRegistry } = await import('#mcp/run-codemode-registry.ts') diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index c9687a4120..549ad5efe6 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -26,9 +26,9 @@ const savedAppSourceCacheLimit = 100 function buildSavedAppSourceCacheKey(input: { sourceId: string - publishedCommit: string | null + publishedCommit: string }) { - return `${input.sourceId}:${input.publishedCommit ?? 'unpublished'}` + return `${input.sourceId}:${input.publishedCommit}` } function rememberSavedAppSource( @@ -96,11 +96,13 @@ export async function resolveSavedAppSource(input: { input.artifact.sourceId!, ) if (!source) return fallback - const cacheKey = buildSavedAppSourceCacheKey({ - sourceId: source.id, - publishedCommit: source.published_commit, - }) - const cached = savedAppSourceCache.get(cacheKey) + const cacheKey = source.published_commit + ? buildSavedAppSourceCacheKey({ + sourceId: source.id, + publishedCommit: source.published_commit, + }) + : null + const cached = cacheKey ? savedAppSourceCache.get(cacheKey) : null if (cached) return cached const sessionId = `app-source-${source.id}` const session = repoSessionRpc(input.env, sessionId) @@ -147,7 +149,9 @@ export async function resolveSavedAppSource(input: { sourceId: source.id, publishedCommit: source.published_commit, } - rememberSavedAppSource(cacheKey, resolved) + if (cacheKey) { + rememberSavedAppSource(cacheKey, resolved) + } return resolved } finally { if (openedSessionId) { From 6fd87ea14b26f95751fdfb8b4a7657d33e33e805 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 01:47:30 +0000 Subject: [PATCH 32/42] Harden repo-backed session ownership --- packages/worker/src/jobs/service.node.test.ts | 1 + packages/worker/src/jobs/service.ts | 16 +++- .../mcp/capabilities/meta/meta-save-skill.ts | 58 ++++++++++++ .../mcp/capabilities/repo/repo-apply-patch.ts | 3 +- .../capabilities/repo/repo-discard-session.ts | 3 +- .../repo/repo-get-check-status.ts | 3 +- .../mcp/capabilities/repo/repo-get-session.ts | 3 +- .../capabilities/repo/repo-open-session.ts | 1 + .../capabilities/repo/repo-publish-session.ts | 3 +- .../mcp/capabilities/repo/repo-read-file.ts | 3 +- .../capabilities/repo/repo-rebase-session.ts | 3 +- .../mcp/capabilities/repo/repo-run-checks.ts | 3 +- .../src/mcp/capabilities/repo/repo-search.ts | 3 +- .../src/mcp/capabilities/repo/repo-tree.ts | 3 +- .../mcp/capabilities/repo/repo-write-file.ts | 2 +- .../worker/src/mcp/skills/run-saved-skill.ts | 13 ++- packages/worker/src/repo/app-source.ts | 14 ++- packages/worker/src/repo/repo-session-do.ts | 93 +++++++++++++++---- packages/worker/src/repo/source-service.ts | 3 + packages/worker/src/repo/source-sync.ts | 10 +- 20 files changed, 202 insertions(+), 39 deletions(-) diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 7beebf4125..d0142229d5 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -753,6 +753,7 @@ test('executeJobOnce discards repo sessions after repo-backed execution', async }) expect(sessionClient.discardSession).toHaveBeenCalledWith({ sessionId: 'job-runtime-job-repo-1', + userId: 'user-123', }) expect(executeSpy).toHaveBeenCalledTimes(1) } finally { diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 515d74ab1c..d3e50c0d08 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -137,6 +137,7 @@ export async function createJob(input: { ? await ensureEntitySource({ db: input.env.APP_DB, env: input.env, + id: shape.sourceId, userId: callerContext.user.userId, entityKind: 'job', entityId: jobId, @@ -254,6 +255,7 @@ export async function updateJob(input: { ? await ensureEntitySource({ db: input.env.APP_DB, env: input.env, + id: shape.sourceId, userId: callerContext.user.userId, entityKind: 'job', entityId: existing.id, @@ -436,6 +438,7 @@ async function runRepoBackedJob(input: { try { const result = await sessionClient.runChecks({ sessionId: session.id, + userId: input.callerContext.user.userId, }) if (!result.ok) { return { @@ -451,6 +454,7 @@ async function runRepoBackedJob(input: { session.manifest_path?.replace(/^\/+/, '') || 'kody.json' const entrypoint = await sessionClient.readFile({ sessionId: session.id, + userId: input.callerContext.user.userId, path: manifestPath, }) if (!entrypoint.content) { @@ -482,6 +486,7 @@ async function runRepoBackedJob(input: { } const moduleFile = await sessionClient.readFile({ sessionId: session.id, + userId: input.callerContext.user.userId, path: manifest.entrypoint.replace(/^\/+/, ''), }) if (!moduleFile.content) { @@ -522,9 +527,14 @@ async function runRepoBackedJob(input: { }, ) } finally { - await sessionClient.discardSession({ sessionId: session.id }).catch(() => { - // Best effort only; preserve the original execution failure. - }) + await sessionClient + .discardSession({ + sessionId: session.id, + userId: input.callerContext.user.userId, + }) + .catch(() => { + // Best effort only; preserve the original execution failure. + }) } } diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 92715e6ebd..5b553df822 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -24,6 +24,18 @@ import { buildSkillSourceFiles } from '#worker/repo/source-templates.ts' import { requireMcpUser } from './require-user.ts' import { ensureEntitySource } from '#worker/repo/source-service.ts' +function parseJsonStringArray(raw: string | null): Array { + if (raw == null) return [] + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === 'string') + : [] + } catch { + return [] + } +} + const inputSchema = z.object({ name: z .string() @@ -137,6 +149,7 @@ export const metaSaveSkillCapability = defineDomainCapability( entityId: skillId, sourceRoot: '/', }) + const previousPublishedCommit = source.published_commit if (existing) { const updated = await updateMcpSkill( @@ -233,6 +246,51 @@ export const metaSaveSkillCapability = defineDomainCapability( embedText: oldEmbed, collectionSlug: existing.collection_slug, }) + const restoredPublishedCommit = await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: source.id, + files: buildSkillSourceFiles({ + title: existing.title, + description: existing.description, + keywords: parseJsonStringArray(existing.keywords), + searchText: existing.search_text ?? null, + collection: existing.collection_name, + readOnly: existing.read_only === 1, + idempotent: existing.idempotent === 1, + destructive: existing.destructive === 1, + usesCapabilities: parseJsonStringArray( + existing.uses_capabilities, + ), + parameters: skillParameterSchema + .array() + .safeParse( + existing.parameters == null + ? null + : JSON.parse(existing.parameters), + ).success + ? existing.parameters == null + ? null + : (JSON.parse(existing.parameters) as Array<{ + name: string + description: string + type: 'string' | 'number' | 'boolean' | 'json' + required?: boolean + default?: unknown + }>) + : null, + code: existing.code, + }), + }) + await updateEntitySource(ctx.env.APP_DB, { + id: source.id, + userId: user.userId, + publishedCommit: + restoredPublishedCommit ?? previousPublishedCommit ?? null, + indexedCommit: + restoredPublishedCommit ?? previousPublishedCommit ?? null, + }) } else { await deleteEntitySource(ctx.env.APP_DB, { id: source.id, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts index 248a5d06e2..014b901aed 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-apply-patch.ts @@ -20,9 +20,10 @@ export const repoApplyPatchCapability = defineDomainCapability( inputSchema: repoApplyPatchInputSchema, outputSchema: repoApplyPatchResultSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc(ctx.env, args.session_id).applyEdits({ sessionId: args.session_id, + userId: user.userId, edits: args.instructions.map((instruction) => { switch (instruction.kind) { case 'write': diff --git a/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts index 6f08cccb3e..603a19af43 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-discard-session.ts @@ -20,12 +20,13 @@ export const repoDiscardSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoDiscardSessionOutputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc( ctx.env, args.session_id, ).discardSession({ sessionId: args.session_id, + userId: user.userId, }) return { ok: true as const, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts b/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts index b70469b677..89420b1bfc 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-get-check-status.ts @@ -21,12 +21,13 @@ export const repoGetCheckStatusCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoCheckStatusOutputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const status = await repoSessionRpc( ctx.env, args.session_id, ).getCheckStatus({ sessionId: args.session_id, + userId: user.userId, }) return { run_id: status.runId, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts index c9214c23fc..dbfbfd3d51 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-get-session.ts @@ -17,9 +17,10 @@ export const repoGetSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoSessionInfoSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) return await repoSessionRpc(ctx.env, args.session_id).getSessionInfo({ sessionId: args.session_id, + userId: user.userId, }) }, }, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts index 189ad4dce1..892f3d46b6 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-open-session.ts @@ -42,6 +42,7 @@ export const repoOpenSessionCapability = defineDomainCapability( } return repoSessionRpc(ctx.env, existingSession.id).getSessionInfo({ sessionId: existingSession.id, + userId: user.userId, }) } diff --git a/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts index 9aa045563c..1dcff777a6 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-publish-session.ts @@ -39,12 +39,13 @@ export const repoPublishSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc( ctx.env, args.session_id, ).publishSession({ sessionId: args.session_id, + userId: user.userId, }) if (result.status === 'ok') { return { diff --git a/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts b/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts index 1660057d5a..2c77b8caee 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-read-file.ts @@ -21,9 +21,10 @@ export const repoReadFileCapability = defineDomainCapability( inputSchema: repoReadFileInputSchema, outputSchema: repoReadFileOutputSchema, async handler(args, ctx: CapabilityContext) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) return repoSessionRpc(ctx.env, args.session_id).readFile({ sessionId: args.session_id, + userId: user.userId, path: args.path, }) }, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts b/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts index 5e371dcc4c..f92b98dd44 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-rebase-session.ts @@ -26,12 +26,13 @@ export const repoRebaseSessionCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc( ctx.env, args.session_id, ).rebaseSession({ sessionId: args.session_id, + userId: user.userId, }) return { ok: result.ok, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts index d62578830b..c7037c82d1 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-run-checks.ts @@ -20,9 +20,10 @@ export const repoRunChecksCapability = defineDomainCapability( inputSchema: repoSessionIdSchema, outputSchema: repoRunChecksOutputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc(ctx.env, args.session_id).runChecks({ sessionId: args.session_id, + userId: user.userId, }) return { ok: result.ok, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-search.ts b/packages/worker/src/mcp/capabilities/repo/repo-search.ts index 7f3adfbce5..0594a42b9d 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-search.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-search.ts @@ -17,10 +17,11 @@ export const repoSearchCapability = defineDomainCapability( inputSchema: repoSearchInputSchema, outputSchema: repoSearchOutputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const session = repoSessionRpc(ctx.env, args.session_id) const result = await session.search({ sessionId: args.session_id, + userId: user.userId, pattern: args.pattern, mode: args.mode, glob: args.glob, diff --git a/packages/worker/src/mcp/capabilities/repo/repo-tree.ts b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts index 6a9be9a3cd..7fc50ea6c6 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-tree.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-tree.ts @@ -45,9 +45,10 @@ export const repoTreeCapability = defineDomainCapability( inputSchema, outputSchema, async handler(args, ctx) { - requireMcpUser(ctx.callerContext) + const user = requireMcpUser(ctx.callerContext) const result = await repoSessionRpc(ctx.env, args.session_id).tree({ sessionId: args.session_id, + userId: user.userId, path: args.path ?? null, maxDepth: args.max_depth, }) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts b/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts index f007eda0d8..1f585f9a25 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-write-file.ts @@ -22,9 +22,9 @@ export const repoWriteFileCapability = defineDomainCapability( outputSchema: repoWriteFileOutputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - void user return await repoSessionRpc(ctx.env, args.session_id).writeFile({ sessionId: args.session_id, + userId: user.userId, path: args.path, content: args.content, }) diff --git a/packages/worker/src/mcp/skills/run-saved-skill.ts b/packages/worker/src/mcp/skills/run-saved-skill.ts index df3ea98abe..310b17bf4c 100644 --- a/packages/worker/src/mcp/skills/run-saved-skill.ts +++ b/packages/worker/src/mcp/skills/run-saved-skill.ts @@ -117,6 +117,7 @@ async function runRepoBackedSkill(input: { session.manifest_path?.replace(/^\/+/, '') || 'kody.json' const entrypoint = await sessionClient.readFile({ sessionId: session.id, + userId: input.callerContext.user?.userId ?? '', path: manifestPath, }) if (!entrypoint.content) { @@ -140,6 +141,7 @@ async function runRepoBackedSkill(input: { } const moduleFile = await sessionClient.readFile({ sessionId: session.id, + userId: input.callerContext.user?.userId ?? '', path: manifest.entrypoint.replace(/^\/+/, ''), }) if (!moduleFile.content) { @@ -175,8 +177,13 @@ async function runRepoBackedSkill(input: { }, ) } finally { - await sessionClient.discardSession({ sessionId: session.id }).catch(() => { - // Best effort only; preserve the original execution failure. - }) + await sessionClient + .discardSession({ + sessionId: session.id, + userId: input.callerContext.user?.userId ?? '', + }) + .catch(() => { + // Best effort only; preserve the original execution failure. + }) } } diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 549ad5efe6..4eab2df52e 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -118,6 +118,7 @@ export async function resolveSavedAppSource(input: { openedSessionId = opened.id const manifestFile = await session.readFile({ sessionId: opened.id, + userId: input.artifact.user_id, path: source.manifest_path, }) if (!manifestFile.content) return fallback @@ -129,10 +130,12 @@ export async function resolveSavedAppSource(input: { const [clientFile, serverFile] = await Promise.all([ session.readFile({ sessionId: opened.id, + userId: input.artifact.user_id, path: resolveManifestClientPath(manifest), }), session.readFile({ sessionId: opened.id, + userId: input.artifact.user_id, path: manifest.server, }), ]) @@ -155,9 +158,14 @@ export async function resolveSavedAppSource(input: { return resolved } finally { if (openedSessionId) { - await session.discardSession({ sessionId: openedSessionId }).catch(() => { - // Best effort only; source resolution should preserve the original error. - }) + await session + .discardSession({ + sessionId: openedSessionId, + userId: input.artifact.user_id, + }) + .catch(() => { + // Best effort only; source resolution should preserve the original error. + }) } } } diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index a818b6d3fc..d3a5c7dc9b 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -227,11 +227,16 @@ class RepoSessionBase extends DurableObject { private initializedSessionId: string | null = null - private async getSessionState(sessionId: string) { + private async getSessionState(sessionId: string, userId: string) { const sessionRow = await getRepoSessionById(this.env.APP_DB, sessionId) if (!sessionRow) { throw new Error(`Repo session "${sessionId}" was not found.`) } + if (sessionRow.user_id !== userId) { + throw new Error( + `Repo session "${sessionId}" was not found for this user.`, + ) + } const source = await getEntitySourceById( this.env.APP_DB, sessionRow.source_id, @@ -467,13 +472,17 @@ class RepoSessionBase extends DurableObject { return this.toSessionInfo(sessionRow, source) } - async getSessionInfo(input: { sessionId: string }) { - const { sessionRow, source } = await this.getSessionState(input.sessionId) + async getSessionInfo(input: { sessionId: string; userId: string }) { + const { sessionRow, source } = await this.getSessionState( + input.sessionId, + input.userId, + ) return this.toSessionInfo(sessionRow, source) } async discardSession(input: { sessionId: string + userId: string }): Promise { const sessionRow = await getRepoSessionById( this.env.APP_DB, @@ -486,6 +495,11 @@ class RepoSessionBase extends DurableObject { deleted: false, } } + if (sessionRow.user_id !== input.userId) { + throw new Error( + `Repo session "${input.sessionId}" was not found for this user.`, + ) + } await deleteRepoSession(this.env.APP_DB, input.sessionId) try { await this.workspace.rm(repoSessionWorkspacePrefix, { @@ -504,9 +518,10 @@ class RepoSessionBase extends DurableObject { async readFile(input: { sessionId: string + userId: string path: string }): Promise<{ path: string; content: string | null }> { - await this.getSessionState(input.sessionId) + await this.getSessionState(input.sessionId, input.userId) return { path: input.path, content: await this.workspace.readFile( @@ -517,10 +532,14 @@ class RepoSessionBase extends DurableObject { async writeFile(input: { sessionId: string + userId: string path: string content: string }): Promise<{ ok: true; path: string }> { - const { sessionRow } = await this.getSessionState(input.sessionId) + const { sessionRow } = await this.getSessionState( + input.sessionId, + input.userId, + ) await this.workspace.writeFile( this.resolveWorkspacePath(input.path), input.content, @@ -535,6 +554,7 @@ class RepoSessionBase extends DurableObject { async search(input: { sessionId: string + userId: string pattern: string mode?: RepoSearchMode glob?: string | null @@ -545,7 +565,10 @@ class RepoSessionBase extends DurableObject { limit?: number outputMode?: RepoSearchOutputMode }): Promise { - const { sessionRow } = await this.getSessionState(input.sessionId) + const { sessionRow } = await this.getSessionState( + input.sessionId, + input.userId, + ) const search = normalizeSearchQuery({ pattern: input.pattern, mode: input.mode, @@ -607,6 +630,7 @@ class RepoSessionBase extends DurableObject { async applyPatch(input: { sessionId: string + userId: string edits: Array< | { kind: 'write' @@ -644,10 +668,14 @@ class RepoSessionBase extends DurableObject { async tree(input: { sessionId: string + userId: string path?: string | null maxDepth?: number }): Promise { - const { sessionRow } = await this.getSessionState(input.sessionId) + const { sessionRow } = await this.getSessionState( + input.sessionId, + input.userId, + ) const root = this.resolveWorkspacePath( input.path?.trim() || sessionRow.source_root || @@ -661,6 +689,7 @@ class RepoSessionBase extends DurableObject { async applyEdits(input: { sessionId: string + userId: string edits: Array<{ kind: 'write' | 'replace' | 'writeJson' path: string @@ -681,7 +710,10 @@ class RepoSessionBase extends DurableObject { dryRun?: boolean rollbackOnError?: boolean }): Promise { - const { sessionRow } = await this.getSessionState(input.sessionId) + const { sessionRow } = await this.getSessionState( + input.sessionId, + input.userId, + ) const plan = await this.state.planEdits( input.edits.map((edit) => { const path = this.resolveWorkspacePath(edit.path) @@ -740,8 +772,14 @@ class RepoSessionBase extends DurableObject { } } - async runChecks(input: { sessionId: string }): Promise { - const { sessionRow, source } = await this.getSessionState(input.sessionId) + async runChecks(input: { + sessionId: string + userId: string + }): Promise { + const { sessionRow, source } = await this.getSessionState( + input.sessionId, + input.userId, + ) const manifestPath = this.resolveWorkspacePath(source.manifest_path) const result = await runRepoChecks({ workspace: this.workspace, @@ -777,15 +815,19 @@ class RepoSessionBase extends DurableObject { } } - async getCheckStatus(input: { sessionId: string }) { - await this.getSessionState(input.sessionId) + async getCheckStatus(input: { sessionId: string; userId: string }) { + await this.getSessionState(input.sessionId, input.userId) return this.readCheckStatus() } async rebaseSession(input: { sessionId: string + userId: string }): Promise { - const { sessionRow, source } = await this.getSessionState(input.sessionId) + const { sessionRow, source } = await this.getSessionState( + input.sessionId, + input.userId, + ) const sourceRepo = await resolveArtifactSourceRepo(this.env, source.repo_id) const sourceInfo = await sourceRepo.info() const sourceAccess = await ensureArtifactRepoRemote({ @@ -836,9 +878,13 @@ class RepoSessionBase extends DurableObject { async publishSession(input: { sessionId: string + userId: string force?: boolean }): Promise { - const { sessionRow, source } = await this.getSessionState(input.sessionId) + const { sessionRow, source } = await this.getSessionState( + input.sessionId, + input.userId, + ) const checkStatus = await this.readCheckStatus() const currentTreeHash = await this.computeTreeHash() if ( @@ -1067,7 +1113,10 @@ export function repoSessionRpc(env: Env, sessionId: string) { manifest_path: string entity_type: 'skill' | 'app' | 'job' }> - getSessionInfo: (payload: { sessionId: string }) => Promise<{ + getSessionInfo: (payload: { + sessionId: string + userId: string + }) => Promise<{ id: string source_id: string source_root: string @@ -1088,18 +1137,22 @@ export function repoSessionRpc(env: Env, sessionId: string) { }> discardSession: (payload: { sessionId: string + userId: string }) => Promise readFile: (payload: { sessionId: string + userId: string path: string }) => Promise<{ path: string; content: string | null }> writeFile: (payload: { sessionId: string + userId: string path: string content: string }) => Promise<{ ok: true; path: string }> search: (payload: { sessionId: string + userId: string pattern: string mode?: RepoSearchMode glob?: string | null @@ -1112,11 +1165,13 @@ export function repoSessionRpc(env: Env, sessionId: string) { }) => Promise tree: (payload: { sessionId: string + userId: string path?: string | null maxDepth?: number }) => Promise applyEdits: (payload: { sessionId: string + userId: string edits: Array<{ kind: 'write' | 'replace' | 'writeJson' path: string @@ -1137,15 +1192,21 @@ export function repoSessionRpc(env: Env, sessionId: string) { dryRun?: boolean rollbackOnError?: boolean }) => Promise - runChecks: (payload: { sessionId: string }) => Promise + runChecks: (payload: { + sessionId: string + userId: string + }) => Promise getCheckStatus: (payload: { sessionId: string + userId: string }) => Promise> rebaseSession: (payload: { sessionId: string + userId: string }) => Promise publishSession: (payload: { sessionId: string + userId: string force?: boolean }) => Promise } diff --git a/packages/worker/src/repo/source-service.ts b/packages/worker/src/repo/source-service.ts index 1c67b3015c..91f6736673 100644 --- a/packages/worker/src/repo/source-service.ts +++ b/packages/worker/src/repo/source-service.ts @@ -46,6 +46,7 @@ function buildEntitySourceRow(input: { export async function ensureEntitySource(input: { db: D1Database env: Env + id?: string userId: string entityKind: EntityKind entityId: string @@ -59,6 +60,7 @@ export async function ensureEntitySource(input: { envHasArtifactsBinding(input.env) === false ) { return buildEntitySourceRow({ + id: input.id, userId: input.userId, entityKind: input.entityKind, entityId: input.entityId, @@ -74,6 +76,7 @@ export async function ensureEntitySource(input: { }) if (existing) return existing const row = buildEntitySourceRow({ + id: input.id, userId: input.userId, entityKind: input.entityKind, entityId: input.entityId, diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts index 6d7fa542f5..cd5f520932 100644 --- a/packages/worker/src/repo/source-sync.ts +++ b/packages/worker/src/repo/source-sync.ts @@ -44,6 +44,7 @@ export async function syncArtifactSourceSnapshot( }) await session.applyEdits({ sessionId, + userId: input.userId, edits: Object.entries(input.files).map(([path, content]) => ({ kind: 'write' as const, path, @@ -54,6 +55,7 @@ export async function syncArtifactSourceSnapshot( }) const publishResult = await session.publishSession({ sessionId, + userId: input.userId, force: true, }) if (publishResult.status !== 'ok') { @@ -61,8 +63,10 @@ export async function syncArtifactSourceSnapshot( } return publishResult.publishedCommit } finally { - await session.discardSession({ sessionId }).catch(() => { - // Best effort only; publish/apply failures should preserve the root cause. - }) + await session + .discardSession({ sessionId, userId: input.userId }) + .catch(() => { + // Best effort only; publish/apply failures should preserve the root cause. + }) } } From fa0289c747cfaa14b925ad973c1f2b2a5dff0ac9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:05:37 +0000 Subject: [PATCH 33/42] Fix repo session search and origin credentials --- packages/worker/src/repo/repo-session-do.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index d3a5c7dc9b..a8de0c4b38 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -573,8 +573,11 @@ class RepoSessionBase extends DurableObject { pattern: input.pattern, mode: input.mode, }) - const root = - input.path?.trim() || sessionRow.source_root || repoSessionWorkspacePrefix + const root = this.resolveWorkspacePath( + input.path?.trim() || + sessionRow.source_root || + repoSessionWorkspacePrefix, + ) const globPattern = input.glob?.trim() || `${root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` @@ -824,7 +827,7 @@ class RepoSessionBase extends DurableObject { sessionId: string userId: string }): Promise { - const { sessionRow, source } = await this.getSessionState( + const { sessionRow, source, sessionAccess } = await this.getSessionState( input.sessionId, input.userId, ) @@ -856,9 +859,10 @@ class RepoSessionBase extends DurableObject { dir: repoSessionWorkspacePrefix, remote: 'origin', ref: defaultBranch, - token: sourceAccess.token, + token: sessionAccess.token, username: 'x', - password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + password: + sessionAccess.token.split('?expires=')[0] ?? sessionAccess.token, }) await updateRepoSession(this.env.APP_DB, { id: sessionRow.id, @@ -881,7 +885,7 @@ class RepoSessionBase extends DurableObject { userId: string force?: boolean }): Promise { - const { sessionRow, source } = await this.getSessionState( + const { sessionRow, source, sessionAccess } = await this.getSessionState( input.sessionId, input.userId, ) @@ -924,9 +928,10 @@ class RepoSessionBase extends DurableObject { ref: await this.getCurrentBranch( sourceInfo?.defaultBranch ?? defaultSessionBranch, ), - token: sourceAccess.token, + token: sessionAccess.token, username: 'x', - password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, + password: + sessionAccess.token.split('?expires=')[0] ?? sessionAccess.token, }) await this.ensureRemote({ name: 'source', From f054138f1b8fd04a9c7de5b9d5790a579397c1b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:10:19 +0000 Subject: [PATCH 34/42] Delete vectors for due once-jobs Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index d3e50c0d08..23d3dc206a 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -622,6 +622,7 @@ export async function runDueJobsForUser(input: { } for (const jobId of result.deleteJobIds) { await deleteJobRow(input.env.APP_DB, input.userId, jobId) + await deleteJobVector(input.env, jobId) } return dueRows.length } From 74d9760286d75a7e7f244baa57ec384700739dc5 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 16 Apr 2026 20:19:30 -0600 Subject: [PATCH 35/42] Refactor repo session helper boundaries Move repo session search, RPC typing, and shared session result types into focused modules so future changes stay local and easier to review. Made-with: Cursor --- packages/worker/src/repo/repo-session-do.ts | 452 ++---------------- packages/worker/src/repo/repo-session-rpc.ts | 100 ++++ .../worker/src/repo/repo-session-search.ts | 200 ++++++++ packages/worker/src/repo/types.ts | 139 ++++++ 4 files changed, 476 insertions(+), 415 deletions(-) create mode 100644 packages/worker/src/repo/repo-session-rpc.ts create mode 100644 packages/worker/src/repo/repo-session-search.ts diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index a8de0c4b38..6e05b4c949 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -19,117 +19,27 @@ import { } from './artifacts.ts' import { buildSentryOptions } from '#worker/sentry-options.ts' import { getEntitySourceById, updateEntitySource } from './entity-sources.ts' -import { type EntitySourceRow, type RepoSessionRow } from './types.ts' -import { runRepoChecks, type RepoCheckRunResult } from './checks.ts' +import { parseRepoManifest } from './manifest.ts' +import { searchRepoWorkspace } from './repo-session-search.ts' +import { repoSessionRpc as createRepoSessionRpc } from './repo-session-rpc.ts' +import { runRepoChecks } from './checks.ts' +import { + type EntitySourceRow, + type RepoApplyPatchResult, + type RepoSearchMode, + type RepoSearchOutputMode, + type RepoSessionApplyEditsResult, + type RepoSessionCheckRun, + type RepoSessionCheckStatus, + type RepoSessionDiscardResult, + type RepoSessionPublishResult, + type RepoSessionRebaseResult, + type RepoSessionRow, + type RepoSessionSearchResult, + type RepoSessionTreeResult, +} from './types.ts' const repoSessionWorkspacePrefix = '/session' -const defaultRepoSearchLimit = 50 -const maxRepoSearchBytes = 200_000 -const maxRepoSearchRegexLength = 512 -const obviousNestedQuantifierPattern = - /\((?:[^()\\]|\\.)*[+*{][^)]*\)(?:[+*]|\{\d+(?:,\d*)?\})/ - -export type RepoSearchMode = 'literal' | 'regex' -export type RepoSearchOutputMode = 'content' | 'files' - -export type RepoSearchMatch = { - line: number - column: number - match: string - lineText: string - beforeLines: Array - afterLines: Array -} - -export type RepoSearchFileMatch = { - path: string - matches: Array -} - -export type RepoSessionSearchResult = { - files: Array - totalFiles: number - totalMatches: number - outputMode: RepoSearchOutputMode - truncated: boolean -} - -export type RepoApplyPatchResult = { - dryRun: boolean - totalChanged: number - edits: Array<{ - path: string - changed: boolean - content: string - diff: string - }> -} - -export type RepoSessionTreeResult = { - path: string - name: string - type: 'file' | 'directory' | 'symlink' - size: number - children?: Array -} - -export type RepoSessionApplyEditsResult = { - dryRun: boolean - totalChanged: number - edits: Array<{ - path: string - changed: boolean - content: string - diff: string - }> -} - -export type RepoSessionCheckRun = RepoCheckRunResult & { - runId: string - treeHash: string - checkedAt: string -} - -export type RepoSessionDiscardResult = { - ok: true - sessionId: string - deleted: boolean -} - -export type RepoSessionCheckStatus = { - runId: string | null - treeHash: string | null - checkedAt: string | null - ok: boolean | null - results: Array<{ - kind: string - ok: boolean - message: string - }> | null -} - -export type RepoSessionPublishResult = - | { - status: 'ok' - sessionId: string - publishedCommit: string - message: string - } - | { - status: 'checks_outdated' | 'base_moved' - sessionId: string - message: string - publishedCommit: null - } - -export type RepoSessionRebaseResult = { - ok: true - sessionId: string - baseCommit: string - headCommit: string | null - merged: boolean -} - const lastCheckStatusStorageKey = 'repo-session:last-check-status' const defaultSessionBranch = 'main' const sessionCommitAuthor = { @@ -145,42 +55,6 @@ function nowIso() { return new Date().toISOString() } -function normalizeSearchLimit(limit: number | undefined) { - if (!Number.isFinite(limit)) return defaultRepoSearchLimit - return Math.min(Math.max(Math.trunc(limit as number), 1), 200) -} - -function escapeRegex(source: string) { - return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function assertSafeRepoSearchRegex(pattern: string) { - if (pattern.length > maxRepoSearchRegexLength) { - throw new Error( - `repo_search regex patterns must be ${maxRepoSearchRegexLength} characters or fewer.`, - ) - } - if (obviousNestedQuantifierPattern.test(pattern)) { - throw new Error( - 'repo_search rejected an unsafe regex pattern with nested quantifiers.', - ) - } -} - -function normalizeSearchQuery(input: { - pattern: string - mode?: RepoSearchMode -}) { - const pattern = input.pattern.trim() - if (!pattern) { - throw new Error('repo_search requires a non-empty pattern.') - } - return { - query: pattern, - regex: input.mode === 'regex', - } -} - async function ensureArtifactRepoRemote(input: { repo: { info: () => Promise<{ remote: string } | null> @@ -569,66 +443,24 @@ class RepoSessionBase extends DurableObject { input.sessionId, input.userId, ) - const search = normalizeSearchQuery({ - pattern: input.pattern, - mode: input.mode, - }) const root = this.resolveWorkspacePath( input.path?.trim() || sessionRow.source_root || repoSessionWorkspacePrefix, ) - const globPattern = - input.glob?.trim() || - `${root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` - const files = await this.workspace.glob(globPattern) - const matchMap = new Map() - const outputMode = input.outputMode ?? 'content' - const maxMatches = normalizeSearchLimit(input.limit) - let totalMatches = 0 - let truncated = false - for (const file of files) { - if (file.type !== 'file') continue - const content = await this.workspace.readFile(file.path) - if (content == null) continue - const result = searchInText({ - content, - query: search.query, - regex: search.regex, - caseSensitive: input.caseSensitive ?? false, - contextBefore: input.before ?? 0, - contextAfter: input.after ?? 0, - maxMatches, - }) - const matches = result.matches - if (matches.length === 0) continue - truncated ||= result.truncated - matchMap.set(file.path, { - path: this.toExternalPath(file.path), - matches: - outputMode === 'files' - ? [] - : matches.map((match) => ({ - line: match.line, - column: match.column, - match: match.match, - lineText: match.lineText, - beforeLines: match.beforeLines ?? [], - afterLines: match.afterLines ?? [], - })), - }) - totalMatches += matches.length - } - const filesWithMatches = [...matchMap.values()].sort((left, right) => - left.path.localeCompare(right.path), - ) - return { - files: filesWithMatches, - totalFiles: filesWithMatches.length, - totalMatches, - outputMode, - truncated, - } + return searchRepoWorkspace({ + workspace: this.workspace, + root, + pattern: input.pattern, + mode: input.mode, + glob: input.glob, + caseSensitive: input.caseSensitive, + before: input.before, + after: input.after, + limit: input.limit, + outputMode: input.outputMode, + toExternalPath: (path) => this.toExternalPath(path), + }) } async applyPatch(input: { @@ -955,12 +787,10 @@ class RepoSessionBase extends DurableObject { if (manifestContent == null) { throw new Error(`Manifest "${source.manifest_path}" was not found.`) } - const manifest = await import('./manifest.ts').then((module) => - module.parseRepoManifest({ - content: manifestContent, - manifestPath: source.manifest_path, - }), - ) + const manifest = parseRepoManifest({ + content: manifestContent, + manifestPath: source.manifest_path, + }) await updateEntitySource(this.env.APP_DB, { id: source.id, userId: source.user_id, @@ -1084,213 +914,5 @@ export const RepoSession = Sentry.instrumentDurableObjectWithSentry( ) export function repoSessionRpc(env: Env, sessionId: string) { - const namespace = ( - env as Env & { REPO_SESSION?: DurableObjectNamespace | undefined } - ).REPO_SESSION - if (!namespace) { - throw new Error('REPO_SESSION binding is not configured.') - } - return namespace.get(namespace.idFromName(sessionId)) as unknown as { - openSession: (payload: { - sessionId: string - sourceId: string - userId: string - baseUrl: string - conversationId?: string | null - sourceRoot?: string | null - defaultBranch?: string | null - }) => Promise<{ - id: string - source_id: string - source_root: string - base_commit: string - session_repo_id: string - session_repo_name: string - session_repo_namespace: string - conversation_id: string | null - last_checkpoint_commit: string | null - last_check_run_id: string | null - last_check_tree_hash: string | null - expires_at: string | null - created_at: string - updated_at: string - published_commit: string | null - manifest_path: string - entity_type: 'skill' | 'app' | 'job' - }> - getSessionInfo: (payload: { - sessionId: string - userId: string - }) => Promise<{ - id: string - source_id: string - source_root: string - base_commit: string - session_repo_id: string - session_repo_name: string - session_repo_namespace: string - conversation_id: string | null - last_checkpoint_commit: string | null - last_check_run_id: string | null - last_check_tree_hash: string | null - expires_at: string | null - created_at: string - updated_at: string - published_commit: string | null - manifest_path: string - entity_type: 'skill' | 'app' | 'job' - }> - discardSession: (payload: { - sessionId: string - userId: string - }) => Promise - readFile: (payload: { - sessionId: string - userId: string - path: string - }) => Promise<{ path: string; content: string | null }> - writeFile: (payload: { - sessionId: string - userId: string - path: string - content: string - }) => Promise<{ ok: true; path: string }> - search: (payload: { - sessionId: string - userId: string - pattern: string - mode?: RepoSearchMode - glob?: string | null - path?: string | null - caseSensitive?: boolean - before?: number - after?: number - limit?: number - outputMode?: RepoSearchOutputMode - }) => Promise - tree: (payload: { - sessionId: string - userId: string - path?: string | null - maxDepth?: number - }) => Promise - applyEdits: (payload: { - sessionId: string - userId: string - edits: Array<{ - kind: 'write' | 'replace' | 'writeJson' - path: string - content?: string - search?: string - replacement?: string - value?: unknown - options?: { - caseSensitive?: boolean - regex?: boolean - wholeWord?: boolean - contextBefore?: number - contextAfter?: number - maxMatches?: number - spaces?: number - } - }> - dryRun?: boolean - rollbackOnError?: boolean - }) => Promise - runChecks: (payload: { - sessionId: string - userId: string - }) => Promise - getCheckStatus: (payload: { - sessionId: string - userId: string - }) => Promise> - rebaseSession: (payload: { - sessionId: string - userId: string - }) => Promise - publishSession: (payload: { - sessionId: string - userId: string - force?: boolean - }) => Promise - } -} - -function searchInText(input: { - content: string - query: string - regex: boolean - caseSensitive: boolean - contextBefore: number - contextAfter: number - maxMatches: number -}) { - const inputTruncated = input.content.length > maxRepoSearchBytes - // Bound regex work so a single pathological search cannot monopolize the DO. - const source = inputTruncated - ? input.content.slice(0, maxRepoSearchBytes) - : input.content - const flags = input.caseSensitive ? 'g' : 'gi' - const pattern = input.regex ? input.query : escapeRegex(input.query) - if (input.regex) { - assertSafeRepoSearchRegex(pattern) - } - let matcher: RegExp - try { - matcher = new RegExp(pattern, flags) - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Unknown regex compilation error.' - throw new Error(`repo_search received an invalid regex: ${message}`) - } - const lines = source.split('\n') - const lineOffsets: number[] = [] - let offset = 0 - for (const line of lines) { - lineOffsets.push(offset) - offset += line.length + 1 - } - const matches: Array<{ - line: number - column: number - match: string - lineText: string - beforeLines?: string[] - afterLines?: string[] - }> = [] - let truncated = false - for (const match of source.matchAll(matcher)) { - if (matches.length >= input.maxMatches) { - truncated = true - break - } - const index = match.index ?? 0 - let lineIndex = 0 - for (let candidate = 0; candidate < lineOffsets.length; candidate += 1) { - const candidateOffset = lineOffsets[candidate] - if (candidateOffset === undefined) break - if (candidateOffset > index) break - lineIndex = candidate - } - const lineStart = lineOffsets[lineIndex] ?? 0 - const column = index - lineStart + 1 - const lineText = lines[lineIndex] ?? '' - const beforeStart = Math.max(0, lineIndex - input.contextBefore) - const afterEnd = Math.min(lines.length, lineIndex + input.contextAfter + 1) - matches.push({ - line: lineIndex + 1, - column, - match: match[0] ?? '', - lineText, - beforeLines: lines.slice(beforeStart, lineIndex), - afterLines: lines.slice(lineIndex + 1, afterEnd), - }) - } - return { - matches, - truncated: truncated || inputTruncated, - } + return createRepoSessionRpc(env, sessionId) } diff --git a/packages/worker/src/repo/repo-session-rpc.ts b/packages/worker/src/repo/repo-session-rpc.ts new file mode 100644 index 0000000000..be198ba350 --- /dev/null +++ b/packages/worker/src/repo/repo-session-rpc.ts @@ -0,0 +1,100 @@ +import { + type RepoSearchMode, + type RepoSearchOutputMode, + type RepoSessionApplyEditsResult, + type RepoSessionCheckRun, + type RepoSessionCheckStatus, + type RepoSessionDiscardResult, + type RepoSessionEdit, + type RepoSessionInfoResult, + type RepoSessionPublishResult, + type RepoSessionRebaseResult, + type RepoSessionSearchResult, + type RepoSessionTreeResult, +} from './types.ts' + +export type RepoSessionRpc = { + openSession: (payload: { + sessionId: string + sourceId: string + userId: string + baseUrl: string + conversationId?: string | null + sourceRoot?: string | null + defaultBranch?: string | null + }) => Promise + getSessionInfo: (payload: { + sessionId: string + userId: string + }) => Promise + discardSession: (payload: { + sessionId: string + userId: string + }) => Promise + readFile: (payload: { + sessionId: string + userId: string + path: string + }) => Promise<{ path: string; content: string | null }> + writeFile: (payload: { + sessionId: string + userId: string + path: string + content: string + }) => Promise<{ ok: true; path: string }> + search: (payload: { + sessionId: string + userId: string + pattern: string + mode?: RepoSearchMode + glob?: string | null + path?: string | null + caseSensitive?: boolean + before?: number + after?: number + limit?: number + outputMode?: RepoSearchOutputMode + }) => Promise + tree: (payload: { + sessionId: string + userId: string + path?: string | null + maxDepth?: number + }) => Promise + applyEdits: (payload: { + sessionId: string + userId: string + edits: Array + dryRun?: boolean + rollbackOnError?: boolean + }) => Promise + runChecks: (payload: { + sessionId: string + userId: string + }) => Promise + getCheckStatus: (payload: { + sessionId: string + userId: string + }) => Promise + rebaseSession: (payload: { + sessionId: string + userId: string + }) => Promise + publishSession: (payload: { + sessionId: string + userId: string + force?: boolean + }) => Promise +} + +export function repoSessionRpc(env: Env, sessionId: string): RepoSessionRpc { + const namespace = ( + env as Env & { REPO_SESSION?: DurableObjectNamespace | undefined } + ).REPO_SESSION + if (!namespace) { + throw new Error('REPO_SESSION binding is not configured.') + } + return namespace.get( + namespace.idFromName(sessionId), + ) as unknown as RepoSessionRpc +} diff --git a/packages/worker/src/repo/repo-session-search.ts b/packages/worker/src/repo/repo-session-search.ts new file mode 100644 index 0000000000..9c555dbda1 --- /dev/null +++ b/packages/worker/src/repo/repo-session-search.ts @@ -0,0 +1,200 @@ +import { + type RepoSearchFileMatch, + type RepoSearchMatch, + type RepoSearchMode, + type RepoSearchOutputMode, + type RepoSessionSearchResult, +} from './types.ts' + +const defaultRepoSearchLimit = 50 +const maxRepoSearchBytes = 200_000 +const maxRepoSearchRegexLength = 512 +const obviousNestedQuantifierPattern = + /\((?:[^()\\]|\\.)*[+*{][^)]*\)(?:[+*]|\{\d+(?:,\d*)?\})/ + +function normalizeSearchLimit(limit: number | undefined) { + if (!Number.isFinite(limit)) return defaultRepoSearchLimit + return Math.min(Math.max(Math.trunc(limit as number), 1), 200) +} + +function escapeRegex(source: string) { + return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function assertSafeRepoSearchRegex(pattern: string) { + if (pattern.length > maxRepoSearchRegexLength) { + throw new Error( + `repo_search regex patterns must be ${maxRepoSearchRegexLength} characters or fewer.`, + ) + } + if (obviousNestedQuantifierPattern.test(pattern)) { + throw new Error( + 'repo_search rejected an unsafe regex pattern with nested quantifiers.', + ) + } +} + +function normalizeSearchQuery(input: { + pattern: string + mode?: RepoSearchMode +}) { + const pattern = input.pattern.trim() + if (!pattern) { + throw new Error('repo_search requires a non-empty pattern.') + } + return { + query: pattern, + regex: input.mode === 'regex', + } +} + +function searchInText(input: { + content: string + query: string + regex: boolean + caseSensitive: boolean + contextBefore: number + contextAfter: number + maxMatches: number +}) { + const inputTruncated = input.content.length > maxRepoSearchBytes + // Bound regex work so a single pathological search cannot monopolize the DO. + const source = inputTruncated + ? input.content.slice(0, maxRepoSearchBytes) + : input.content + const flags = input.caseSensitive ? 'g' : 'gi' + const pattern = input.regex ? input.query : escapeRegex(input.query) + if (input.regex) { + assertSafeRepoSearchRegex(pattern) + } + let matcher: RegExp + try { + matcher = new RegExp(pattern, flags) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unknown regex compilation error.' + throw new Error(`repo_search received an invalid regex: ${message}`) + } + const lines = source.split('\n') + const lineOffsets: number[] = [] + let offset = 0 + for (const line of lines) { + lineOffsets.push(offset) + offset += line.length + 1 + } + const matches: Array<{ + line: number + column: number + match: string + lineText: string + beforeLines?: string[] + afterLines?: string[] + }> = [] + let truncated = false + for (const match of source.matchAll(matcher)) { + if (matches.length >= input.maxMatches) { + truncated = true + break + } + const index = match.index ?? 0 + let lineIndex = 0 + for (let candidate = 0; candidate < lineOffsets.length; candidate += 1) { + const candidateOffset = lineOffsets[candidate] + if (candidateOffset === undefined) break + if (candidateOffset > index) break + lineIndex = candidate + } + const lineStart = lineOffsets[lineIndex] ?? 0 + const column = index - lineStart + 1 + const lineText = lines[lineIndex] ?? '' + const beforeStart = Math.max(0, lineIndex - input.contextBefore) + const afterEnd = Math.min(lines.length, lineIndex + input.contextAfter + 1) + matches.push({ + line: lineIndex + 1, + column, + match: match[0] ?? '', + lineText, + beforeLines: lines.slice(beforeStart, lineIndex), + afterLines: lines.slice(lineIndex + 1, afterEnd), + }) + } + return { + matches, + truncated: truncated || inputTruncated, + } +} + +export async function searchRepoWorkspace(input: { + workspace: { + glob(pattern: string): Promise> + readFile(path: string): Promise + } + root: string + pattern: string + mode?: RepoSearchMode + glob?: string | null + caseSensitive?: boolean + before?: number + after?: number + limit?: number + outputMode?: RepoSearchOutputMode + toExternalPath(path: string): string +}): Promise { + const search = normalizeSearchQuery({ + pattern: input.pattern, + mode: input.mode, + }) + const globPattern = + input.glob?.trim() || + `${input.root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` + const files = await input.workspace.glob(globPattern) + const matchMap = new Map() + const outputMode = input.outputMode ?? 'content' + const maxMatches = normalizeSearchLimit(input.limit) + let totalMatches = 0 + let truncated = false + for (const file of files) { + if (file.type !== 'file') continue + const content = await input.workspace.readFile(file.path) + if (content == null) continue + const result = searchInText({ + content, + query: search.query, + regex: search.regex, + caseSensitive: input.caseSensitive ?? false, + contextBefore: input.before ?? 0, + contextAfter: input.after ?? 0, + maxMatches, + }) + const matches = result.matches + if (matches.length === 0) continue + truncated ||= result.truncated + matchMap.set(file.path, { + path: input.toExternalPath(file.path), + matches: + outputMode === 'files' + ? [] + : matches.map((match) => ({ + line: match.line, + column: match.column, + match: match.match, + lineText: match.lineText, + beforeLines: match.beforeLines ?? [], + afterLines: match.afterLines ?? [], + })), + }) + totalMatches += matches.length + } + const filesWithMatches = [...matchMap.values()].sort((left, right) => + left.path.localeCompare(right.path), + ) + return { + files: filesWithMatches, + totalFiles: filesWithMatches.length, + totalMatches, + outputMode, + truncated, + } +} diff --git a/packages/worker/src/repo/types.ts b/packages/worker/src/repo/types.ts index d1d6699e5b..ab48859ecd 100644 --- a/packages/worker/src/repo/types.ts +++ b/packages/worker/src/repo/types.ts @@ -194,3 +194,142 @@ export type RepoSessionDiscardResult = { sessionId: string deleted: boolean } + +export type RepoSessionInfoResult = { + id: string + source_id: string + source_root: string + base_commit: string + session_repo_id: string + session_repo_name: string + session_repo_namespace: string + conversation_id: string | null + last_checkpoint_commit: string | null + last_check_run_id: string | null + last_check_tree_hash: string | null + expires_at: string | null + created_at: string + updated_at: string + published_commit: string | null + manifest_path: string + entity_type: EntityKind +} + +export type RepoSessionTreeResult = { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array +} + +export type RepoSessionReplaceOptions = { + caseSensitive?: boolean + regex?: boolean + wholeWord?: boolean + contextBefore?: number + contextAfter?: number + maxMatches?: number +} + +export type RepoSessionWriteJsonOptions = { + spaces?: number +} + +export type RepoSessionWriteEdit = { + kind: 'write' + path: string + content: string +} + +export type RepoSessionReplaceEdit = { + kind: 'replace' + path: string + search: string + replacement?: string + options?: RepoSessionReplaceOptions +} + +export type RepoSessionWriteJsonEdit = { + kind: 'writeJson' + path: string + value: unknown + options?: RepoSessionWriteJsonOptions +} + +export type RepoSessionEdit = + | RepoSessionWriteEdit + | RepoSessionReplaceEdit + | RepoSessionWriteJsonEdit + +export type RepoSessionApplyEditsResult = { + dryRun: boolean + totalChanged: number + edits: Array<{ + path: string + changed: boolean + content: string + diff: string + }> +} + +export type RepoApplyPatchResult = RepoSessionApplyEditsResult + +export type RepoSessionCheckStatus = { + runId: string | null + treeHash: string | null + checkedAt: string | null + ok: boolean | null + results: Array<{ + kind: + | 'manifest' + | 'dependencies' + | 'bundle' + | 'typecheck' + | 'lint' + | 'smoke' + ok: boolean + message: string + }> | null +} + +export type RepoSessionCheckRun = { + ok: boolean + results: Array<{ + kind: + | 'manifest' + | 'dependencies' + | 'bundle' + | 'typecheck' + | 'lint' + | 'smoke' + ok: boolean + message: string + }> + manifest: RepoManifest + runId: string + treeHash: string + checkedAt: string +} + +export type RepoSessionPublishResult = + | { + status: 'ok' + sessionId: string + publishedCommit: string + message: string + } + | { + status: 'checks_outdated' | 'base_moved' + sessionId: string + message: string + publishedCommit: null + } + +export type RepoSessionRebaseResult = { + ok: true + sessionId: string + baseCommit: string + headCommit: string | null + merged: boolean +} From 688097d0dbb4105024a4ca09cc4d968218054787 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 16 Apr 2026 20:22:02 -0600 Subject: [PATCH 36/42] Extract repo session tree helpers Move repo session path, tree, and session-info shaping into a focused helper module so the session DO stays centered on orchestration. Made-with: Cursor --- packages/worker/src/repo/repo-session-do.ts | 135 +++++------------- packages/worker/src/repo/repo-session-tree.ts | 106 ++++++++++++++ 2 files changed, 139 insertions(+), 102 deletions(-) create mode 100644 packages/worker/src/repo/repo-session-tree.ts diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index 6e05b4c949..11f46256f1 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -22,9 +22,14 @@ import { getEntitySourceById, updateEntitySource } from './entity-sources.ts' import { parseRepoManifest } from './manifest.ts' import { searchRepoWorkspace } from './repo-session-search.ts' import { repoSessionRpc as createRepoSessionRpc } from './repo-session-rpc.ts' +import { + resolveRepoWorkspacePath, + toExternalRepoPath, + toRepoSessionInfoResult, + toRepoSessionTreeResult, +} from './repo-session-tree.ts' import { runRepoChecks } from './checks.ts' import { - type EntitySourceRow, type RepoApplyPatchResult, type RepoSearchMode, type RepoSearchOutputMode, @@ -343,7 +348,7 @@ class RepoSessionBase extends DurableObject { if (!source) { throw new Error(`Source "${sessionRow.source_id}" was not found.`) } - return this.toSessionInfo(sessionRow, source) + return toRepoSessionInfoResult(sessionRow, source) } async getSessionInfo(input: { sessionId: string; userId: string }) { @@ -351,7 +356,7 @@ class RepoSessionBase extends DurableObject { input.sessionId, input.userId, ) - return this.toSessionInfo(sessionRow, source) + return toRepoSessionInfoResult(sessionRow, source) } async discardSession(input: { @@ -399,7 +404,7 @@ class RepoSessionBase extends DurableObject { return { path: input.path, content: await this.workspace.readFile( - this.resolveWorkspacePath(input.path), + resolveRepoWorkspacePath(input.path, repoSessionWorkspacePrefix), ), } } @@ -415,7 +420,7 @@ class RepoSessionBase extends DurableObject { input.userId, ) await this.workspace.writeFile( - this.resolveWorkspacePath(input.path), + resolveRepoWorkspacePath(input.path, repoSessionWorkspacePrefix), input.content, ) await updateRepoSession(this.env.APP_DB, { @@ -443,10 +448,11 @@ class RepoSessionBase extends DurableObject { input.sessionId, input.userId, ) - const root = this.resolveWorkspacePath( + const root = resolveRepoWorkspacePath( input.path?.trim() || sessionRow.source_root || repoSessionWorkspacePrefix, + repoSessionWorkspacePrefix, ) return searchRepoWorkspace({ workspace: this.workspace, @@ -459,7 +465,8 @@ class RepoSessionBase extends DurableObject { after: input.after, limit: input.limit, outputMode: input.outputMode, - toExternalPath: (path) => this.toExternalPath(path), + toExternalPath: (path) => + toExternalRepoPath(path, repoSessionWorkspacePrefix), }) } @@ -511,15 +518,19 @@ class RepoSessionBase extends DurableObject { input.sessionId, input.userId, ) - const root = this.resolveWorkspacePath( + const root = resolveRepoWorkspacePath( input.path?.trim() || sessionRow.source_root || repoSessionWorkspacePrefix, + repoSessionWorkspacePrefix, ) const tree = await this.state.walkTree(root, { maxDepth: input.maxDepth, }) - return this.toTreeResult(tree) + return toRepoSessionTreeResult({ + node: tree, + workspacePrefix: repoSessionWorkspacePrefix, + }) } async applyEdits(input: { @@ -551,7 +562,10 @@ class RepoSessionBase extends DurableObject { ) const plan = await this.state.planEdits( input.edits.map((edit) => { - const path = this.resolveWorkspacePath(edit.path) + const path = resolveRepoWorkspacePath( + edit.path, + repoSessionWorkspacePrefix, + ) switch (edit.kind) { case 'write': if (typeof edit.content !== 'string') { @@ -599,7 +613,7 @@ class RepoSessionBase extends DurableObject { dryRun: result.dryRun, totalChanged: result.totalChanged, edits: result.edits.map((edit) => ({ - path: this.toExternalPath(edit.path), + path: toExternalRepoPath(edit.path, repoSessionWorkspacePrefix), changed: edit.changed, content: edit.content, diff: edit.diff, @@ -615,7 +629,10 @@ class RepoSessionBase extends DurableObject { input.sessionId, input.userId, ) - const manifestPath = this.resolveWorkspacePath(source.manifest_path) + const manifestPath = resolveRepoWorkspacePath( + source.manifest_path, + repoSessionWorkspacePrefix, + ) const result = await runRepoChecks({ workspace: this.workspace, manifestPath, @@ -782,7 +799,10 @@ class RepoSessionBase extends DurableObject { password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token, }) const manifestContent = await this.workspace.readFile( - this.resolveWorkspacePath(source.manifest_path), + resolveRepoWorkspacePath( + source.manifest_path, + repoSessionWorkspacePrefix, + ), ) if (manifestContent == null) { throw new Error(`Manifest "${source.manifest_path}" was not found.`) @@ -817,95 +837,6 @@ class RepoSessionBase extends DurableObject { message: `Published session ${sessionRow.id} to ${source.repo_id}.`, } } - - private resolveWorkspacePath(path: string) { - const trimmed = path.trim() - if (!trimmed) { - throw new Error('A non-empty repo path is required.') - } - if (trimmed.startsWith(repoSessionWorkspacePrefix)) { - return trimmed - } - return `${repoSessionWorkspacePrefix}/${trimmed.replace(/^\/+/, '')}` - } - - private toExternalPath(path: string) { - return path.startsWith(`${repoSessionWorkspacePrefix}/`) - ? path.slice(repoSessionWorkspacePrefix.length + 1) - : path - } - - private normalizeUnknownTreeChild( - child: unknown, - parentPath: string, - ): { - path: string - name: string - type: 'file' | 'directory' | 'symlink' - size: number - children?: Array - } { - const input = - child && typeof child === 'object' - ? (child as Record) - : ({} as Record) - return { - path: - typeof input.path === 'string' ? input.path : `${parentPath}/unknown`, - name: typeof input.name === 'string' ? input.name : 'unknown', - type: - input.type === 'file' || - input.type === 'directory' || - input.type === 'symlink' - ? input.type - : 'file', - size: typeof input.size === 'number' ? input.size : 0, - children: Array.isArray(input.children) - ? (input.children as Array) - : undefined, - } - } - - private toTreeResult(node: { - path: string - name: string - type: 'file' | 'directory' | 'symlink' - size: number - children?: Array - }): RepoSessionTreeResult { - return { - path: this.toExternalPath(node.path), - name: node.name, - type: node.type, - size: node.size, - children: node.children?.map( - (child): RepoSessionTreeResult => - this.toTreeResult(this.normalizeUnknownTreeChild(child, node.path)), - ), - } - } - - private toSessionInfo(session: RepoSessionRow, source: EntitySourceRow) { - return { - id: session.id, - source_id: session.source_id, - source_root: session.source_root, - base_commit: session.base_commit, - session_repo_id: session.session_repo_id, - session_repo_name: session.session_repo_name, - session_repo_namespace: session.session_repo_namespace, - conversation_id: session.conversation_id, - last_checkpoint_commit: session.last_checkpoint_commit, - last_check_run_id: session.last_check_run_id, - last_check_tree_hash: session.last_check_tree_hash, - expires_at: session.expires_at, - created_at: session.created_at, - updated_at: session.updated_at, - published_commit: source.published_commit, - manifest_path: source.manifest_path, - entity_type: source.entity_kind, - } - } } export const RepoSession = Sentry.instrumentDurableObjectWithSentry( diff --git a/packages/worker/src/repo/repo-session-tree.ts b/packages/worker/src/repo/repo-session-tree.ts new file mode 100644 index 0000000000..e482b5f6f2 --- /dev/null +++ b/packages/worker/src/repo/repo-session-tree.ts @@ -0,0 +1,106 @@ +import { + type EntitySourceRow, + type RepoSessionInfoResult, + type RepoSessionRow, + type RepoSessionTreeResult, +} from './types.ts' + +function normalizeUnknownTreeChild( + child: unknown, + parentPath: string, +): { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array +} { + const input = + child && typeof child === 'object' + ? (child as Record) + : ({} as Record) + return { + path: typeof input.path === 'string' ? input.path : `${parentPath}/unknown`, + name: typeof input.name === 'string' ? input.name : 'unknown', + type: + input.type === 'file' || + input.type === 'directory' || + input.type === 'symlink' + ? input.type + : 'file', + size: typeof input.size === 'number' ? input.size : 0, + children: Array.isArray(input.children) + ? (input.children as Array) + : undefined, + } +} + +export function resolveRepoWorkspacePath( + path: string, + workspacePrefix: string, +) { + const trimmed = path.trim() + if (!trimmed) { + throw new Error('A non-empty repo path is required.') + } + if (trimmed.startsWith(workspacePrefix)) { + return trimmed + } + return `${workspacePrefix}/${trimmed.replace(/^\/+/, '')}` +} + +export function toExternalRepoPath(path: string, workspacePrefix: string) { + return path.startsWith(`${workspacePrefix}/`) + ? path.slice(workspacePrefix.length + 1) + : path +} + +export function toRepoSessionTreeResult(input: { + node: { + path: string + name: string + type: 'file' | 'directory' | 'symlink' + size: number + children?: Array + } + workspacePrefix: string +}): RepoSessionTreeResult { + return { + path: toExternalRepoPath(input.node.path, input.workspacePrefix), + name: input.node.name, + type: input.node.type, + size: input.node.size, + children: input.node.children?.map( + (child): RepoSessionTreeResult => + toRepoSessionTreeResult({ + node: normalizeUnknownTreeChild(child, input.node.path), + workspacePrefix: input.workspacePrefix, + }), + ), + } +} + +export function toRepoSessionInfoResult( + session: RepoSessionRow, + source: EntitySourceRow, +): RepoSessionInfoResult { + return { + id: session.id, + source_id: session.source_id, + source_root: session.source_root, + base_commit: session.base_commit, + session_repo_id: session.session_repo_id, + session_repo_name: session.session_repo_name, + session_repo_namespace: session.session_repo_namespace, + conversation_id: session.conversation_id, + last_checkpoint_commit: session.last_checkpoint_commit, + last_check_run_id: session.last_check_run_id, + last_check_tree_hash: session.last_check_tree_hash, + expires_at: session.expires_at, + created_at: session.created_at, + updated_at: session.updated_at, + published_commit: source.published_commit, + manifest_path: source.manifest_path, + entity_type: source.entity_kind, + } +} From ad414e84ac5251c246328288c11e0f980d01585c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:31:45 +0000 Subject: [PATCH 37/42] Fix repo session isolation and job storage fallback Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/repo.ts | 11 ++++++----- packages/worker/src/repo/app-source.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/jobs/repo.ts b/packages/worker/src/jobs/repo.ts index 0bacedc617..e23a8cb55e 100644 --- a/packages/worker/src/jobs/repo.ts +++ b/packages/worker/src/jobs/repo.ts @@ -1,3 +1,4 @@ +import { createJobStorageId } from '#worker/storage-runner.ts' import { type JobRecord, type PersistedJobCallerContext } from './types.ts' type JobRowRecord = { @@ -70,20 +71,20 @@ function parseJson(value: string | null, fallback: T): T { } function mapRow(row: Record): JobRow { + const jobId = String(row['id']) const rawStorageId = row['storage_id'] - if (rawStorageId == null) { - throw new Error('Job row is missing storage_id.') - } + const storageId = + rawStorageId == null ? createJobStorageId(jobId) : String(rawStorageId) const record: JobRecord = { version: 1, - id: String(row['id']), + id: jobId, userId: String(row['user_id']), name: String(row['name']), code: row['code'] == null ? null : String(row['code']), sourceId: row['source_id'] == null ? null : String(row['source_id']), publishedCommit: row['published_commit'] == null ? null : String(row['published_commit']), - storageId: String(rawStorageId), + storageId, params: parseJson | undefined>( row['params_json'] == null ? null : String(row['params_json']), undefined, diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 4eab2df52e..64cac9ab1f 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -104,7 +104,7 @@ export async function resolveSavedAppSource(input: { : null const cached = cacheKey ? savedAppSourceCache.get(cacheKey) : null if (cached) return cached - const sessionId = `app-source-${source.id}` + const sessionId = `app-source-${source.id}-${crypto.randomUUID()}` const session = repoSessionRpc(input.env, sessionId) let openedSessionId: string | null = null try { From 7d961252308d953d790d2692acf2b354e7bda91f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:35:15 +0000 Subject: [PATCH 38/42] Address remaining repo-backed review findings --- packages/worker/src/jobs/service.ts | 11 ++++++++++- packages/worker/src/repo/repo-session-do.ts | 16 +++++++++++++++- packages/worker/src/repo/repo-session-search.ts | 10 ++++++---- packages/worker/src/repo/repo-session-tree.ts | 5 ++++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 23d3dc206a..f159784eb2 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -250,8 +250,17 @@ export async function updateJob(input: { existing, body: input.body, }) + if ( + input.body.sourceId !== undefined && + existing.sourceId != null && + input.body.sourceId !== existing.sourceId + ) { + throw new Error( + `Job "${existing.id}" cannot change sourceId after it is assigned.`, + ) + } const ensuredSource = - shape.sourceId && !existing.sourceId + shape.sourceId && shape.sourceId !== existing.sourceId ? await ensureEntitySource({ db: input.env.APP_DB, env: input.env, diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index 11f46256f1..ed75195b6c 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -286,6 +286,11 @@ class RepoSessionBase extends DurableObject { if (!source) { throw new Error(`Source "${input.sourceId}" was not found.`) } + if (source.user_id !== input.userId) { + throw new Error( + `Source "${input.sourceId}" was not found for this user.`, + ) + } const sourceRepo = await resolveArtifactSourceRepo( this.env, source.repo_id, @@ -327,6 +332,11 @@ class RepoSessionBase extends DurableObject { sessionRepoToken: forked.token, }) } else { + if (sessionRow.user_id !== input.userId) { + throw new Error( + `Repo session "${input.sessionId}" was not found for this user.`, + ) + } const sessionRepo = await resolveSessionRepo(this.env, { namespace: sessionRow.session_repo_namespace, name: sessionRow.session_repo_name, @@ -633,10 +643,14 @@ class RepoSessionBase extends DurableObject { source.manifest_path, repoSessionWorkspacePrefix, ) + const sourceRoot = resolveRepoWorkspacePath( + source.source_root || repoSessionWorkspacePrefix, + repoSessionWorkspacePrefix, + ) const result = await runRepoChecks({ workspace: this.workspace, manifestPath, - sourceRoot: repoSessionWorkspacePrefix, + sourceRoot, }) const runId = crypto.randomUUID() const treeHash = await this.computeTreeHash() diff --git a/packages/worker/src/repo/repo-session-search.ts b/packages/worker/src/repo/repo-session-search.ts index 9c555dbda1..33b7ae0fc4 100644 --- a/packages/worker/src/repo/repo-session-search.ts +++ b/packages/worker/src/repo/repo-session-search.ts @@ -152,10 +152,11 @@ export async function searchRepoWorkspace(input: { const files = await input.workspace.glob(globPattern) const matchMap = new Map() const outputMode = input.outputMode ?? 'content' - const maxMatches = normalizeSearchLimit(input.limit) + let remaining = normalizeSearchLimit(input.limit) let totalMatches = 0 let truncated = false for (const file of files) { + if (remaining <= 0) break if (file.type !== 'file') continue const content = await input.workspace.readFile(file.path) if (content == null) continue @@ -166,11 +167,13 @@ export async function searchRepoWorkspace(input: { caseSensitive: input.caseSensitive ?? false, contextBefore: input.before ?? 0, contextAfter: input.after ?? 0, - maxMatches, + maxMatches: remaining, }) const matches = result.matches if (matches.length === 0) continue - truncated ||= result.truncated + totalMatches += matches.length + remaining = Math.max(0, remaining - matches.length) + truncated ||= result.truncated || remaining === 0 matchMap.set(file.path, { path: input.toExternalPath(file.path), matches: @@ -185,7 +188,6 @@ export async function searchRepoWorkspace(input: { afterLines: match.afterLines ?? [], })), }) - totalMatches += matches.length } const filesWithMatches = [...matchMap.values()].sort((left, right) => left.path.localeCompare(right.path), diff --git a/packages/worker/src/repo/repo-session-tree.ts b/packages/worker/src/repo/repo-session-tree.ts index e482b5f6f2..61a4dbc09e 100644 --- a/packages/worker/src/repo/repo-session-tree.ts +++ b/packages/worker/src/repo/repo-session-tree.ts @@ -43,7 +43,10 @@ export function resolveRepoWorkspacePath( if (!trimmed) { throw new Error('A non-empty repo path is required.') } - if (trimmed.startsWith(workspacePrefix)) { + if ( + trimmed === workspacePrefix || + trimmed.startsWith(`${workspacePrefix}/`) + ) { return trimmed } return `${workspacePrefix}/${trimmed.replace(/^\/+/, '')}` From ee15fe357d7ecd1038781711d3e0bcdfb2f39cd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:41:38 +0000 Subject: [PATCH 39/42] Tighten remaining review edge cases --- packages/worker/src/jobs/service.node.test.ts | 114 ++++++++++++++++++ packages/worker/src/jobs/service.ts | 16 +++ .../src/mcp/capabilities/unified-search.ts | 1 - packages/worker/src/repo/app-source.ts | 7 +- 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index d0142229d5..b8fc264d2e 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -762,6 +762,120 @@ test('executeJobOnce discards repo sessions after repo-backed execution', async } }) +test('executeJobOnce returns a clear error for module-style repo-backed job entrypoints', async () => { + const env = { + APP_DB: createDatabase(), + LOADER: {} as WorkerLoader, + } as Env + const callerContext = createBaseCallerContext() + const job: JobRecord = { + version: 1, + id: 'job-repo-module', + userId: callerContext.user.userId, + name: 'Repo-backed module job', + code: null, + sourceId: 'source-job-repo-module', + publishedCommit: 'commit-abc', + storageId: 'job:job-repo-module', + schedule: { + type: 'once', + runAt: '2026-04-17T15:00:00Z', + }, + timezone: 'UTC', + enabled: true, + killSwitchEnabled: false, + createdAt: '2026-04-16T00:00:00.000Z', + updatedAt: '2026-04-16T00:00:00.000Z', + nextRunAt: '2026-04-17T15:00:00.000Z', + runCount: 0, + successCount: 0, + errorCount: 0, + runHistory: [], + } + + const sessionClient = { + openSession: vi.fn(async () => ({ + id: 'job-runtime-job-repo-module', + source_id: 'source-job-repo-module', + source_root: '/', + base_commit: 'commit-abc', + session_repo_id: 'session-repo-id', + session_repo_name: 'session-repo-name', + session_repo_namespace: 'default', + conversation_id: null, + last_checkpoint_commit: null, + last_check_run_id: null, + last_check_tree_hash: null, + expires_at: null, + created_at: '2026-04-16T00:00:00.000Z', + updated_at: '2026-04-16T00:00:00.000Z', + published_commit: 'commit-abc', + manifest_path: 'kody.json', + entity_type: 'job' as const, + })), + runChecks: vi.fn(async () => ({ + ok: true, + results: [], + manifest: { + version: 1, + kind: 'job', + title: 'Repo-backed module job', + description: 'Runs from repo', + entrypoint: 'src/job.ts', + }, + })), + readFile: vi.fn(async ({ path }: { path: string }) => ({ + path, + content: + path === 'kody.json' + ? JSON.stringify({ + version: 1, + kind: 'job', + title: 'Repo-backed module job', + description: 'Runs from repo', + entrypoint: 'src/job.ts', + }) + : 'export default async () => ({ ok: true })', + })), + discardSession: vi.fn(async () => ({ + ok: true as const, + sessionId: 'job-runtime-job-repo-module', + deleted: true, + })), + } + + const repoSessionRpcSpy = vi + .spyOn(await import('#worker/repo/repo-session-do.ts'), 'repoSessionRpc') + .mockReturnValue(sessionClient as never) + const executeSpy = vi.spyOn( + await import('#mcp/run-codemode-registry.ts'), + 'runCodemodeWithRegistry', + ) + + try { + const outcome = await executeJobOnce({ + env, + job, + callerContext, + }) + + expect(outcome.execution).toEqual({ + ok: false, + error: + 'Repo-backed job entrypoints must be execute-compatible async function snippets, not ESM/CommonJS modules.', + logs: [], + }) + expect(executeSpy).not.toHaveBeenCalled() + expect(sessionClient.discardSession).toHaveBeenCalledWith({ + sessionId: 'job-runtime-job-repo-module', + userId: 'user-123', + }) + } finally { + repoSessionRpcSpy.mockRestore() + executeSpy.mockRestore() + } +}) + test('executeJobOnce returns an error when codemode secret policy would reject execution', async () => { const env = { APP_DB: createDatabase(), diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index f159784eb2..653f109919 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -73,6 +73,14 @@ function normalizeJobCode(code: string) { return trimmed } +function hasModuleStyleCodemodeEntrypoint(code: string) { + return ( + /^\s*export\s+/m.test(code) || + /\bmodule\.exports\b/.test(code) || + /\bexports\.[A-Za-z_$][\w$]*/.test(code) + ) +} + function normalizeOptionalParams( params: Record | null | undefined, ): Record | undefined { @@ -505,6 +513,14 @@ async function runRepoBackedJob(input: { logs: [], } } + if (hasModuleStyleCodemodeEntrypoint(moduleFile.content)) { + return { + error: + 'Repo-backed job entrypoints must be execute-compatible async function snippets, not ESM/CommonJS modules.', + result: null, + logs: [], + } + } const { runCodemodeWithRegistry } = await import('#mcp/run-codemode-registry.ts') return await runCodemodeWithRegistry( diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index c3fdf39a2c..ab3b45cf80 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -388,7 +388,6 @@ function scoreJobLexicalMatch( function buildJobEmbedDoc(row: JobView): string { return buildJobEmbedText({ name: row.name, - description: row.scheduleSummary, scheduleSummary: row.scheduleSummary, sourceId: row.sourceId, publishedCommit: row.publishedCommit, diff --git a/packages/worker/src/repo/app-source.ts b/packages/worker/src/repo/app-source.ts index 64cac9ab1f..ea29fb5b8c 100644 --- a/packages/worker/src/repo/app-source.ts +++ b/packages/worker/src/repo/app-source.ts @@ -75,10 +75,13 @@ function resolveManifestClientPath(manifest: AppManifest) { } function canResolveRepoBackedSource(env: Env, artifact: UiArtifactRow) { + const anyEnv = env as Env & { APP_DB?: unknown; REPO_SESSION?: unknown } return ( artifact.sourceId != null && - typeof (env as Env & { APP_DB?: unknown }).APP_DB === 'object' && - typeof (env as Env & { REPO_SESSION?: unknown }).REPO_SESSION === 'object' + anyEnv.APP_DB != null && + typeof anyEnv.APP_DB === 'object' && + anyEnv.REPO_SESSION != null && + typeof anyEnv.REPO_SESSION === 'object' ) } From 1f4da7c67756323c8cd1e7aa98259cc9d7377008 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 02:44:39 +0000 Subject: [PATCH 40/42] Fix ui save app sync rollback Co-authored-by: Kent C. Dodds --- packages/worker/src/jobs/service.ts | 1 - .../src/mcp/capabilities/apps/ui-save-app.ts | 87 +++++++++++++------ 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 653f109919..69487199fb 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -38,7 +38,6 @@ import { buildJobSourceFiles } from '#worker/repo/source-templates.ts' function hasRepoBackedJobSource(input: { sourceId?: string | null - publishedCommit?: string | null }) { return typeof input.sourceId === 'string' && input.sourceId.length > 0 } diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index b2688919fd..6de8d9ef33 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -392,20 +392,40 @@ export const uiSaveAppCapability = defineDomainCapability( const previousPublishedCommit = await readPublishedCommit( ensuredSource.id, ) - await syncArtifactSourceSnapshot({ - env: ctx.env, - userId: user.userId, - baseUrl: ctx.callerContext.baseUrl, - sourceId: ensuredSource.id, - files: buildAppSourceFiles({ - title, - description, - parameters, - hidden: args.hidden ?? existingApp.hidden, - clientCode, - serverCode, - }), - }) + try { + await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: ensuredSource.id, + files: buildAppSourceFiles({ + title, + description, + parameters, + hidden: args.hidden ?? existingApp.hidden, + clientCode, + serverCode, + }), + }) + } catch (cause) { + await Promise.allSettled([ + updateUiArtifact(ctx.env.APP_DB, user.userId, appId, { + title: existingApp.title, + description: existingApp.description, + sourceId: existingApp.sourceId, + clientCode: existingApp.clientCode, + serverCode: existingApp.serverCode, + serverCodeId: existingApp.serverCodeId, + parameters: existingApp.parameters, + hidden: existingApp.hidden, + }), + restorePublishedCommit( + ensuredSource.id, + previousPublishedCommit, + ), + ]) + throw cause + } hidden = args.hidden ?? existingApp.hidden return await saveAndIndexApp({ appId, @@ -451,20 +471,31 @@ export const uiSaveAppCapability = defineDomainCapability( const previousPublishedCommit = await readPublishedCommit( ensuredSource.id, ) - await syncArtifactSourceSnapshot({ - env: ctx.env, - userId: user.userId, - baseUrl: ctx.callerContext.baseUrl, - sourceId: ensuredSource.id, - files: buildAppSourceFiles({ - title, - description, - parameters, - hidden, - clientCode, - serverCode, - }), - }) + try { + await syncArtifactSourceSnapshot({ + env: ctx.env, + userId: user.userId, + baseUrl: ctx.callerContext.baseUrl, + sourceId: ensuredSource.id, + files: buildAppSourceFiles({ + title, + description, + parameters, + hidden, + clientCode, + serverCode, + }), + }) + } catch (cause) { + await Promise.allSettled([ + deleteUiArtifact(ctx.env.APP_DB, user.userId, appId), + restorePublishedCommit( + ensuredSource.id, + previousPublishedCommit, + ), + ]) + throw cause + } return await saveAndIndexApp({ appId, title, From e54473835261131ddd7a1c24fe7835fe74e700f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 03:00:38 +0000 Subject: [PATCH 41/42] Fix job reindex secrets and repo session reuse Co-authored-by: Kent C. Dodds --- packages/worker/src/env-schema.ts | 1 + packages/worker/src/job-maintenance.ts | 2 +- packages/worker/src/jobs/service.node.test.ts | 8 +- packages/worker/src/jobs/service.ts | 210 +++++++++--------- packages/worker/src/repo/source-sync.ts | 2 +- 5 files changed, 113 insertions(+), 110 deletions(-) diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts index ea17d7f393..56d79a3d22 100644 --- a/packages/worker/src/env-schema.ts +++ b/packages/worker/src/env-schema.ts @@ -202,6 +202,7 @@ export const EnvSchema = object({ CLOUDFLARE_API_TOKEN: optionalNonEmptyStringSchema, CLOUDFLARE_API_BASE_URL: optionalUrlStringSchema, CAPABILITY_REINDEX_SECRET: optionalNonEmptyStringSchema, + JOB_REINDEX_SECRET: optionalNonEmptyStringSchema, HOME_CONNECTOR_SHARED_SECRET: optionalNonEmptyStringSchema, REMOTE_CONNECTOR_SECRETS: optionalRemoteConnectorSecretsSchema, }) diff --git a/packages/worker/src/job-maintenance.ts b/packages/worker/src/job-maintenance.ts index d7110250d9..98a184587d 100644 --- a/packages/worker/src/job-maintenance.ts +++ b/packages/worker/src/job-maintenance.ts @@ -8,7 +8,7 @@ export async function handleJobReindexRequest( return new Response('Method Not Allowed', { status: 405 }) } - const secret = env.CAPABILITY_REINDEX_SECRET?.trim() + const secret = env.JOB_REINDEX_SECRET?.trim() if (!secret) { return new Response('Job reindex is not configured', { status: 503 }) } diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index b8fc264d2e..e7960cc552 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -644,7 +644,7 @@ test('executeJobOnce preserves codemode secret and value semantics', async () => } }) -test('executeJobOnce discards repo sessions after repo-backed execution', async () => { +test('executeJobOnce refreshes repo sessions when base commit moves', async () => { const env = { APP_DB: createDatabase(), LOADER: {} as WorkerLoader, @@ -751,6 +751,7 @@ test('executeJobOnce discards repo sessions after repo-backed execution', async result: { ok: true, repoBacked: true }, logs: ['repo-backed codemode executed'], }) + expect(sessionClient.openSession).toHaveBeenCalledTimes(2) expect(sessionClient.discardSession).toHaveBeenCalledWith({ sessionId: 'job-runtime-job-repo-1', userId: 'user-123', @@ -866,10 +867,7 @@ test('executeJobOnce returns a clear error for module-style repo-backed job entr logs: [], }) expect(executeSpy).not.toHaveBeenCalled() - expect(sessionClient.discardSession).toHaveBeenCalledWith({ - sessionId: 'job-runtime-job-repo-module', - userId: 'user-123', - }) + expect(sessionClient.discardSession).not.toHaveBeenCalled() } finally { repoSessionRpcSpy.mockRestore() executeSpy.mockRestore() diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 69487199fb..cd9d8222dd 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -442,124 +442,128 @@ async function runRepoBackedJob(input: { logs: [], } } - const sessionId = `job-runtime-${input.job.id}-${crypto.randomUUID()}` + const sessionId = `job-runtime-${input.job.id}` const sessionClient = repoSessionRpc(input.env, sessionId) - const session = await sessionClient.openSession({ + const openSessionInput = { sessionId, sourceId: input.job.sourceId, userId: input.callerContext.user.userId, baseUrl: input.callerContext.baseUrl, sourceRoot: '/', + } + let session = await sessionClient.openSession(openSessionInput) + if ( + session.published_commit && + session.base_commit !== session.published_commit + ) { + await sessionClient + .discardSession({ + sessionId: session.id, + userId: input.callerContext.user.userId, + }) + .catch(() => { + // Best effort only; ensure new sessions pick up the latest commit. + }) + session = await sessionClient.openSession(openSessionInput) + } + const result = await sessionClient.runChecks({ + sessionId: session.id, + userId: input.callerContext.user.userId, }) - try { - const result = await sessionClient.runChecks({ - sessionId: session.id, - userId: input.callerContext.user.userId, - }) - if (!result.ok) { - return { - error: result.results - .filter((entry) => !entry.ok) - .map((entry) => entry.message) - .join('\n'), - result: null, - logs: [], - } + if (!result.ok) { + return { + error: result.results + .filter((entry) => !entry.ok) + .map((entry) => entry.message) + .join('\n'), + result: null, + logs: [], } - const manifestPath = - session.manifest_path?.replace(/^\/+/, '') || 'kody.json' - const entrypoint = await sessionClient.readFile({ - sessionId: session.id, - userId: input.callerContext.user.userId, - path: manifestPath, - }) - if (!entrypoint.content) { - return { - error: `Job manifest "${manifestPath}" was not found in repo session.`, - result: null, - logs: [], - } + } + const manifestPath = + session.manifest_path?.replace(/^\/+/, '') || 'kody.json' + const entrypoint = await sessionClient.readFile({ + sessionId: session.id, + userId: input.callerContext.user.userId, + path: manifestPath, + }) + if (!entrypoint.content) { + return { + error: `Job manifest "${manifestPath}" was not found in repo session.`, + result: null, + logs: [], } - let manifest: ReturnType - try { - manifest = parseRepoManifest({ - content: entrypoint.content, - manifestPath, - }) - } catch (error) { - return { - error: error instanceof Error ? error.message : String(error), - result: null, - logs: [], - } + } + let manifest: ReturnType + try { + manifest = parseRepoManifest({ + content: entrypoint.content, + manifestPath, + }) + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + result: null, + logs: [], } - if (manifest.kind !== 'job') { - return { - error: `Repo source "${input.job.sourceId}" is not a job manifest.`, - result: null, - logs: [], - } + } + if (manifest.kind !== 'job') { + return { + error: `Repo source "${input.job.sourceId}" is not a job manifest.`, + result: null, + logs: [], } - const moduleFile = await sessionClient.readFile({ - sessionId: session.id, - userId: input.callerContext.user.userId, - path: manifest.entrypoint.replace(/^\/+/, ''), - }) - if (!moduleFile.content) { - return { - error: `Job entrypoint "${manifest.entrypoint}" was not found in repo session.`, - result: null, - logs: [], - } + } + const moduleFile = await sessionClient.readFile({ + sessionId: session.id, + userId: input.callerContext.user.userId, + path: manifest.entrypoint.replace(/^\/+/, ''), + }) + if (!moduleFile.content) { + return { + error: `Job entrypoint "${manifest.entrypoint}" was not found in repo session.`, + result: null, + logs: [], } - if (hasModuleStyleCodemodeEntrypoint(moduleFile.content)) { - return { - error: - 'Repo-backed job entrypoints must be execute-compatible async function snippets, not ESM/CommonJS modules.', - result: null, - logs: [], - } + } + if (hasModuleStyleCodemodeEntrypoint(moduleFile.content)) { + return { + error: + 'Repo-backed job entrypoints must be execute-compatible async function snippets, not ESM/CommonJS modules.', + result: null, + logs: [], } - const { runCodemodeWithRegistry } = - await import('#mcp/run-codemode-registry.ts') - return await runCodemodeWithRegistry( - input.env, - { - ...input.callerContext, - repoContext: { - sourceId: session.source_id, - repoId: null, - sessionId: session.id, - sessionRepoId: session.session_repo_id, - baseCommit: session.base_commit, - manifestPath: session.manifest_path, - sourceRoot: session.source_root, - publishedCommit: session.published_commit, - entityKind: session.entity_type, - entityId: input.job.id, - }, - }, - moduleFile.content, - input.job.params, - { - executorExports: workerExports, - storageTools: { - userId: input.callerContext.user.userId, - storageId: input.job.storageId, - writable: true, - }, - }, - ) - } finally { - await sessionClient - .discardSession({ + } + const { runCodemodeWithRegistry } = + await import('#mcp/run-codemode-registry.ts') + return await runCodemodeWithRegistry( + input.env, + { + ...input.callerContext, + repoContext: { + sourceId: session.source_id, + repoId: null, sessionId: session.id, + sessionRepoId: session.session_repo_id, + baseCommit: session.base_commit, + manifestPath: session.manifest_path, + sourceRoot: session.source_root, + publishedCommit: session.published_commit, + entityKind: session.entity_type, + entityId: input.job.id, + }, + }, + moduleFile.content, + input.job.params, + { + executorExports: workerExports, + storageTools: { userId: input.callerContext.user.userId, - }) - .catch(() => { - // Best effort only; preserve the original execution failure. - }) - } + storageId: input.job.storageId, + writable: true, + }, + }, + ) } export async function runJobNow(input: { diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts index cd5f520932..822a25c634 100644 --- a/packages/worker/src/repo/source-sync.ts +++ b/packages/worker/src/repo/source-sync.ts @@ -22,7 +22,7 @@ function canSyncArtifactSource(env: Env) { } function buildSyncSessionId(sourceId: string) { - return `source-sync-${sourceId}` + return `source-sync-${sourceId}-${crypto.randomUUID()}` } export async function syncArtifactSourceSnapshot( From ddb50b0c265fe0e2df84cb51974715862ee969bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 03:14:40 +0000 Subject: [PATCH 42/42] Polish remaining review fixes --- packages/worker/src/repo/repo-session-do.ts | 7 +++---- packages/worker/worker-configuration.d.ts | 20 ++++++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts index ed75195b6c..abbe656bb5 100644 --- a/packages/worker/src/repo/repo-session-do.ts +++ b/packages/worker/src/repo/repo-session-do.ts @@ -296,10 +296,9 @@ class RepoSessionBase extends DurableObject { source.repo_id, ) const baseCommit = source.published_commit - const sessionRepoName = `${source.repo_id}-${input.sessionId}`.slice( - 0, - 63, - ) + const compactSessionId = input.sessionId.replace(/-/g, '') + const repoPrefixLength = Math.max(1, 63 - compactSessionId.length - 1) + const sessionRepoName = `${source.repo_id.slice(0, repoPrefixLength)}-${compactSessionId}` const forked = await sourceRepo.fork({ name: sessionRepoName, readOnly: false, diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index fa931f9077..ce10edd9fd 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -9579,6 +9579,16 @@ interface InferenceUpstreamError extends Error { interface AiInternalError extends Error { } type AiModelListType = Record; +type ModelsWithRequests = { + [K in keyof M]: Extract extends never ? never : K; +}[keyof M]; +type ModelsWithStream = { + [K in keyof M]: Extract extends never ? never : K; +}[keyof M]; type AiAsyncBatchResponse = { request_id: string; }; @@ -9599,9 +9609,9 @@ declare abstract class Ai { */ autorag(autoragId: string): AutoRAG; // Batch request - run(model: Name, inputs: { - requests: AiModelList[Name]['inputs'][]; - }, options: AiOptions & { + run>(model: Name, inputs: Extract, options: AiOptions & { queueRequest: true; }): Promise; // Raw response @@ -9613,7 +9623,9 @@ declare abstract class Ai { websocket: true; }): Promise; // Streaming - run(model: Name, inputs: AiModelList[Name]['inputs'] & { + run>(model: Name, inputs: Extract & { stream: true; }, options?: AiOptions): Promise; // Normal (default) - known model