diff --git a/bun.lock b/bun.lock index 7ae4a784f36..d099abaa5c4 100644 --- a/bun.lock +++ b/bun.lock @@ -1449,17 +1449,17 @@ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260421.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260421.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260421.1", "", { "os": "linux", "cpu": "x64" }, "sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260421.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260421.1", "", { "os": "win32", "cpu": "x64" }, "sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260424.1", "", {}, "sha512-0DLJ9yEk1KKzPbqop80Gw/P1wkKKzawmipULiJWdBXIBCoMvE0OVWms3IrL/Q/G7tfmPop9yF4XlZ69k9JLYng=="], @@ -2177,7 +2177,7 @@ "@posthog/core": ["@posthog/core@1.9.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw=="], - "@posthog/types": ["@posthog/types@1.371.2", "", {}, "sha512-Ak3kuGMPBTjuoK6Ki0kQbq9eROk7LRJ3GBkbQINCZBT9FPlHvlXRwQr0/OVsi4xYPSMSGxWjRDMPFsR9Px66Cg=="], + "@posthog/types": ["@posthog/types@1.371.3", "", {}, "sha512-oRmCJUMTM43tgbiH8fgGTu5ksjN5d6Lc6ckEYGUpbEMUVB+Of/yIOjb7Okaaqw0erSvtQumFM0teEP+nUI3JtQ=="], "@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="], @@ -3099,7 +3099,7 @@ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], @@ -3313,7 +3313,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], @@ -4443,7 +4443,7 @@ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], "hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], @@ -4621,7 +4621,7 @@ "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], - "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], @@ -4753,7 +4753,7 @@ "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], - "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], "locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="], @@ -5035,7 +5035,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260421.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260421.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw=="], + "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -5339,9 +5339,9 @@ "posthog-js": ["posthog-js@1.310.1", "", { "dependencies": { "@posthog/core": "1.9.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-UkR6zzlWNtqHDXHJl2Yk062DOmZyVKTPL5mX4j4V+u3RiYbMHJe47+PpMMUsvK1R2e1r/m9uSlHaJMJRzyUjGg=="], - "posthog-node": ["posthog-node@5.30.0", "", { "dependencies": { "@posthog/core": "1.27.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-/5X7IbC5q4a6Igql2D44ztotGaRqVaIEMIh/laNaTzmL4WY1jEpo7wQRLbw45gzcPH/hfkYImwvi9A9vt/DyxA=="], + "posthog-node": ["posthog-node@5.30.1", "", { "dependencies": { "@posthog/core": "1.27.2" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-eAmKDGgA4Au7JBgwgftExDgSLjF6KO8RWBntAM50hNuNHGNF9MSko0M+0HTQUg27dLzmg5DIjwyJVOFDAWBFsw=="], - "posthog-react-native": ["posthog-react-native@4.43.1", "", { "dependencies": { "@posthog/core": "1.27.1", "@posthog/types": "1.371.2" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", "@react-navigation/native": ">= 5.0.0", "expo-application": ">= 4.0.0", "expo-device": ">= 4.0.0", "expo-file-system": ">= 13.0.0", "expo-localization": ">= 11.0.0", "posthog-react-native-session-replay": ">= 1.5.4", "react-native-device-info": ">= 10.0.0", "react-native-localize": ">= 3.0.0", "react-native-navigation": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0", "react-native-svg": ">= 15.0.0" }, "optionalPeers": ["@react-native-async-storage/async-storage", "@react-navigation/native", "expo-application", "expo-device", "expo-file-system", "expo-localization", "posthog-react-native-session-replay", "react-native-device-info", "react-native-localize", "react-native-navigation", "react-native-safe-area-context"] }, "sha512-XGBoPLErLzqQ5ohc87HMeQO/0RJl76ZN+lLBiyeFEnpqbVte46b+lWuf6E/gsmYuW99OgMYZGpq8hBfmCeWMuw=="], + "posthog-react-native": ["posthog-react-native@4.43.2", "", { "dependencies": { "@posthog/core": "1.27.2", "@posthog/types": "1.371.3" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", "@react-navigation/native": ">= 5.0.0", "expo-application": ">= 4.0.0", "expo-device": ">= 4.0.0", "expo-file-system": ">= 13.0.0", "expo-localization": ">= 11.0.0", "posthog-react-native-session-replay": ">= 1.5.4", "react-native-device-info": ">= 10.0.0", "react-native-localize": ">= 3.0.0", "react-native-navigation": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0", "react-native-svg": ">= 15.0.0" }, "optionalPeers": ["@react-native-async-storage/async-storage", "@react-navigation/native", "expo-application", "expo-device", "expo-file-system", "expo-localization", "posthog-react-native-session-replay", "react-native-device-info", "react-native-localize", "react-native-navigation", "react-native-safe-area-context"] }, "sha512-TmnFBT4FyWLcfaOfX+m6svvaZ6bNnl2FI+h9UEABIPjomRl147xh1SZtn+4yTTd2S7jfeI7APiEWCzKrxd/p9A=="], "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], @@ -6183,7 +6183,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -6275,7 +6275,7 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], @@ -6313,9 +6313,9 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "workerd": ["workerd@1.20260421.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260421.1", "@cloudflare/workerd-darwin-arm64": "1.20260421.1", "@cloudflare/workerd-linux-64": "1.20260421.1", "@cloudflare/workerd-linux-arm64": "1.20260421.1", "@cloudflare/workerd-windows-64": "1.20260421.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA=="], + "workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], - "wrangler": ["wrangler@4.84.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260421.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260421.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260421.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg=="], + "wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -6499,8 +6499,6 @@ "@develar/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "@develar/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -6831,6 +6829,8 @@ "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -6843,8 +6843,6 @@ "app-builder-lib/dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "app-builder-lib/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -7003,14 +7001,14 @@ "fastembed/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "fetch-cookie/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "formdata-node/web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - "friendly-words/express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -7073,8 +7071,6 @@ "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "jest-haste-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], - "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -7137,8 +7133,6 @@ "metro/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], - "metro/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], - "metro/serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], "metro/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -7147,8 +7141,6 @@ "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], - "metro-file-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], - "metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "metro-symbolicate/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], @@ -7225,9 +7217,9 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "posthog-node/@posthog/core": ["@posthog/core@1.27.1", "", { "dependencies": { "@posthog/types": "1.371.2" } }, "sha512-130F5zAmGoY0KAqdT2FYfU79mk54z0wTWfCMkyzXmkKz2eg23694O2ofaAf4NuIdI8e6LFNHyaZ7aWbatUeW5A=="], + "posthog-node/@posthog/core": ["@posthog/core@1.27.2", "", { "dependencies": { "@posthog/types": "1.371.3" } }, "sha512-y4YzMnUPbuVmL9s31JnJ2lTXxqy1QBTttxzjtfAuogQCN7nGpeDJoVAFz48CMFfLVFexLC2zt7LnkVWfq2hrxw=="], - "posthog-react-native/@posthog/core": ["@posthog/core@1.27.1", "", { "dependencies": { "@posthog/types": "1.371.2" } }, "sha512-130F5zAmGoY0KAqdT2FYfU79mk54z0wTWfCMkyzXmkKz2eg23694O2ofaAf4NuIdI8e6LFNHyaZ7aWbatUeW5A=="], + "posthog-react-native/@posthog/core": ["@posthog/core@1.27.2", "", { "dependencies": { "@posthog/types": "1.371.3" } }, "sha512-y4YzMnUPbuVmL9s31JnJ2lTXxqy1QBTttxzjtfAuogQCN7nGpeDJoVAFz48CMFfLVFexLC2zt7LnkVWfq2hrxw=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], @@ -7303,6 +7295,8 @@ "schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + "schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -7361,6 +7355,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "terser-webpack-plugin/jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "test-exclude/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -7441,6 +7437,8 @@ "yaml-language-server/vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + "yaml-language-server/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], @@ -7749,6 +7747,8 @@ "@wdio/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -7927,8 +7927,6 @@ "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], - "jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -7963,12 +7961,8 @@ "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro-file-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], - "metro/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -8027,6 +8021,8 @@ "temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "terser-webpack-plugin/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -8245,10 +8241,10 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yaml-language-server/vscode-json-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@a2a-js/sdk/@types/express/@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "@a2a-js/sdk/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@a2a-js/sdk/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -8273,6 +8269,8 @@ "@browserbasehq/stagehand/puppeteer-core/@puppeteer/browsers/tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + "@browserbasehq/stagehand/puppeteer-core/chromium-bidi/urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], + "@browserbasehq/stagehand/puppeteer-core/chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts new file mode 100644 index 00000000000..41d2d19294e --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/adopt.ts @@ -0,0 +1,159 @@ +import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { workspaces } from "../../../../db/schema"; +import { protectedProcedure } from "../../../index"; +import { adoptInputSchema } from "../schemas"; +import { + findWorktreeAtPath, + listWorktreeBranches, +} from "../shared/branch-search"; +import { requireLocalProject } from "../shared/local-project"; +import type { TerminalDescriptor } from "../shared/types"; + +export const adopt = protectedProcedure + .input(adoptInputSchema) + .mutation(async ({ ctx, input }) => { + const deviceClientId = getHashedDeviceId(); + const deviceName = getDeviceName(); + + const localProject = requireLocalProject(ctx, input.projectId); + + const branch = input.branch.trim(); + if (!branch) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Branch name is empty", + }); + } + + const git = await ctx.git(localProject.repoPath); + + let worktreePath: string; + if (input.worktreePath) { + const found = await findWorktreeAtPath(git, input.worktreePath, branch); + if (!found) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No git worktree registered at "${input.worktreePath}" on branch "${branch}"`, + }); + } + worktreePath = input.worktreePath; + } else { + // FORK NOTE: listWorktreeBranches uses (git, repoPath) — fork variant. + const { worktreeMap } = await listWorktreeBranches( + git, + localProject.repoPath, + ); + const found = worktreeMap.get(branch); + if (!found) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No existing worktree for branch "${branch}"`, + }); + } + worktreePath = found; + } + + // We used to short-circuit on an existing local `workspaces` row + // (returning its id without calling cloud). That returned a + // phantom id when the cloud row had been hard-deleted — the + // picker would navigate to a workspace that no longer exists. + // Always create a fresh cloud row; if a stale local row leftover + // from a prior delete exists, replace it below. Proper host-side + // cleanup on delete is owned by the follow-up delete PR. + let host: { id: string }; + try { + host = await ctx.api.device.ensureV2Host.mutate({ + organizationId: ctx.organizationId, + machineId: deviceClientId, + name: deviceName, + }); + } catch (err) { + if (err instanceof TRPCError) throw err; + console.error("[workspaceCreation.adopt] ensureV2Host failed", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + let cloudRow: Awaited>; + try { + cloudRow = await ctx.api.v2Workspace.create.mutate({ + organizationId: ctx.organizationId, + projectId: input.projectId, + name: input.workspaceName, + branch, + hostId: host.id, + }); + } catch (err) { + if (err instanceof TRPCError) throw err; + console.error("[workspaceCreation.adopt] v2Workspace.create failed", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to create workspace: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + if (!cloudRow) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloud workspace create returned no row", + }); + } + + // Replace any stale local row for this (project, branch) — its + // id likely points at a deleted cloud row. The new cloudRow.id + // is the authoritative mapping. + const stale = ctx.db.query.workspaces + .findFirst({ + where: and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.branch, branch), + ), + }) + .sync(); + if (stale && stale.id !== cloudRow.id) { + ctx.db.delete(workspaces).where(eq(workspaces.id, stale.id)).run(); + } + + try { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: input.projectId, + worktreePath, + branch, + }) + .onConflictDoUpdate({ + target: workspaces.id, + set: { projectId: input.projectId, worktreePath, branch }, + }) + .run(); + } catch (err) { + console.error( + "[workspaceCreation.adopt] local workspaces insert failed", + err, + ); + await ctx.api.v2Workspace.delete + .mutate({ id: cloudRow.id }) + .catch((cleanupErr) => { + console.warn( + "[workspaceCreation.adopt] failed to rollback cloud workspace", + { workspaceId: cloudRow.id, err: cleanupErr }, + ); + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + return { + workspace: cloudRow, + terminals: [] as TerminalDescriptor[], + warnings: [] as string[], + }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts new file mode 100644 index 00000000000..e816468fd75 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts @@ -0,0 +1,262 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { workspaces } from "../../../../db/schema"; +import { resolveRef } from "../../../../runtime/git/refs"; +import { protectedProcedure } from "../../../index"; +import { checkoutInputSchema } from "../schemas"; +import { finishCheckout } from "../shared/finish-checkout"; +import { enablePushAutoSetupRemote } from "../shared/git-config"; +import { requireLocalProject } from "../shared/local-project"; +import { clearProgress, setProgress } from "../shared/progress-store"; +import { safeResolveWorktreePath } from "../shared/worktree-paths"; +import { execGh } from "../utils/exec-gh"; +import { derivePrLocalBranchName } from "../utils/pr-branch-name"; + +export const checkout = protectedProcedure + .input(checkoutInputSchema) + .mutation(async ({ ctx, input }) => { + setProgress(input.pendingId, "ensuring_repo"); + + const localProject = requireLocalProject(ctx, input.projectId); + + setProgress(input.pendingId, "creating_worktree"); + + // ── PR path ──────────────────────────────────────────────────────── + if (input.pr) { + const branch = derivePrLocalBranchName(input.pr); + + // Idempotency: existing workspace for this PR's branch → + // return it. Renderer navigates to it via `alreadyExists: true` + // instead of treating as a new create. + const existing = ctx.db.query.workspaces + .findFirst({ + where: and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.branch, branch), + ), + }) + .sync(); + if (existing) { + clearProgress(input.pendingId); + return { + workspace: { id: existing.id }, + terminals: [], + warnings: [], + alreadyExists: true as const, + }; + } + + // FORK NOTE: safeResolveWorktreePath uses (repoPath, branch) — fork variant. + let worktreePath: string; + try { + worktreePath = safeResolveWorktreePath(localProject.repoPath, branch); + } catch (err) { + clearProgress(input.pendingId); + throw err; + } + const git = await ctx.git(localProject.repoPath); + + // Detect a pre-existing local branch with the same derived name + // BEFORE running `gh pr checkout --force`. The idempotency check + // above rules out Superset-managed worktrees, but a branch can + // exist outside any workspace — e.g., from a prior manual + // `gh pr checkout` in the primary working tree. `--force` would + // reset it to the PR HEAD, silently losing any unpushed commits. + // We surface a warning pointing at reflog for recovery rather + // than blocking, so the point-and-click flow stays smooth. + let preExistingLocalBranch = false; + try { + await git.raw([ + "show-ref", + "--verify", + "--quiet", + `refs/heads/${branch}`, + ]); + preExistingLocalBranch = true; + } catch { + // Non-zero exit = branch doesn't exist. Expected path. + } + + // Detached worktree first — `gh pr checkout` inside it creates the + // branch with correct fork-remote + upstream config. Mirrors v1's + // `createWorktreeFromPr`. + try { + await git.raw(["worktree", "add", "--detach", worktreePath]); + } catch (err) { + clearProgress(input.pendingId); + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error + ? err.message + : "Failed to add detached worktree", + }); + } + + try { + await execGh( + [ + "pr", + "checkout", + String(input.pr.number), + "--branch", + branch, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (err) { + await git + .raw(["worktree", "remove", "--force", worktreePath]) + .catch((rollbackErr) => { + console.warn( + "[workspaceCreation.checkout] failed to rollback PR worktree", + { worktreePath, err: rollbackErr }, + ); + }); + clearProgress(input.pendingId); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `gh pr checkout failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + + // Push ergonomics. `gh pr checkout` sets per-branch push config + // to the fork URL for cross-repo PRs; this covers the same-repo + // case where upstream isn't auto-set. + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaceCreation.checkout]", + ); + + const extraWarnings: string[] = []; + if (input.pr.state !== "open") { + extraWarnings.push( + `PR is ${input.pr.state} — commits are included, but the PR may not merge.`, + ); + } + if (preExistingLocalBranch) { + extraWarnings.push( + `Reset existing local branch "${branch}" to PR HEAD. If you had unpushed commits there, recover them via \`git reflog show ${branch}\`.`, + ); + } + + return await finishCheckout(ctx, { + pendingId: input.pendingId, + projectId: input.projectId, + workspaceName: input.workspaceName, + branch, + worktreePath, + baseBranch: input.composer.baseBranch, + runSetupScript: input.composer.runSetupScript ?? false, + git, + extraWarnings, + }); + } + + // ── Branch path ──────────────────────────────────────────────────── + const branch = (input.branch ?? "").trim(); + if (!branch) { + clearProgress(input.pendingId); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Branch name is empty", + }); + } + + // FORK NOTE: safeResolveWorktreePath uses (repoPath, branch) — fork variant. + let worktreePath: string; + try { + worktreePath = safeResolveWorktreePath(localProject.repoPath, branch); + } catch (err) { + clearProgress(input.pendingId); + throw err; + } + const git = await ctx.git(localProject.repoPath); + + // Resolve via the discriminated-ref helper so we don't infer kind + // from a refname string (a local branch named `origin/foo` would + // otherwise be misclassified). See GIT_REFS.md. + const resolved = await resolveRef(git, branch); + if (!resolved || resolved.kind === "head" || resolved.kind === "tag") { + clearProgress(input.pendingId); + throw new TRPCError({ + code: "BAD_REQUEST", + message: + resolved?.kind === "tag" + ? `"${branch}" is a tag, not a branch — cannot check out into a workspace` + : `Branch "${branch}" does not exist locally or on origin`, + }); + } + + if (resolved.kind === "remote-tracking") { + try { + await git.fetch([ + resolved.remote, + resolved.shortName, + "--quiet", + "--no-tags", + ]); + } catch (err) { + console.warn( + `[workspaceCreation.checkout] fetch ${resolved.remoteShortName} failed:`, + err, + ); + } + } + + try { + // For a remote-only branch, create a local tracking branch + // explicitly. `git worktree add origin/` without + // --track/-b produces a detached HEAD because the fully-qualified + // ref is treated as a commit-ish, not a branch shorthand. + await git.raw( + resolved.kind === "remote-tracking" + ? [ + "worktree", + "add", + "--track", + "-b", + branch, + worktreePath, + resolved.remoteShortName, + ] + : ["worktree", "add", worktreePath, resolved.shortName], + ); + } catch (err) { + clearProgress(input.pendingId); + const message = + err instanceof Error ? err.message : "Failed to add worktree"; + // Most common cause here is "branch already checked out elsewhere". + // Client disables the button for known cases via isCheckedOut, but + // we still get here for races. + throw new TRPCError({ code: "CONFLICT", message }); + } + + // Enable autoSetupRemote so the first terminal `git push` on a + // local-only branch creates origin/ without requiring -u. + // Branches checked out from a remote already have upstream set + // via --track above, so this config is a no-op for them. + // `--local` in a linked worktree writes to the shared repo config, + // so this applies repo-wide — intentional. + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaceCreation.checkout]", + ); + + return await finishCheckout(ctx, { + pendingId: input.pendingId, + projectId: input.projectId, + workspaceName: input.workspaceName, + branch, + worktreePath, + baseBranch: input.composer.baseBranch, + runSetupScript: input.composer.runSetupScript ?? false, + git, + extraWarnings: [], + }); + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts new file mode 100644 index 00000000000..c1d82a80e50 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts @@ -0,0 +1,329 @@ +import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; +import { TRPCError } from "@trpc/server"; +import { workspaces } from "../../../../db/schema"; +import { + asRemoteRef, + type ResolvedRef, + resolveDefaultBranchName, + resolveUpstream, +} from "../../../../runtime/git/refs"; +import { protectedProcedure } from "../../../index"; +import { createInputSchema } from "../schemas"; +import { enablePushAutoSetupRemote } from "../shared/git-config"; +import { requireLocalProject } from "../shared/local-project"; +import { clearProgress, setProgress } from "../shared/progress-store"; +import { startSetupTerminalIfPresent } from "../shared/setup-terminal"; +import { buildStartPointFromHint } from "../shared/start-point"; +import type { TerminalDescriptor } from "../shared/types"; +import { safeResolveWorktreePath } from "../shared/worktree-paths"; +import { applyAiWorkspaceRename } from "../utils/ai-workspace-names"; +import { listBranchNames } from "../utils/list-branch-names"; +import { resolveStartPoint } from "../utils/resolve-start-point"; +import { deduplicateBranchName } from "../utils/sanitize-branch"; + +export const create = protectedProcedure + .input(createInputSchema) + .mutation(async ({ ctx, input }) => { + const deviceClientId = getHashedDeviceId(); + const deviceName = getDeviceName(); + setProgress(input.pendingId, "ensuring_repo"); + + const localProject = requireLocalProject(ctx, input.projectId); + + setProgress(input.pendingId, "creating_worktree"); + + // Renderer already sanitized/slugified. Host-service only validates + // and deduplicates — doesn't re-sanitize (which would strip case, + // slashes, etc. the user intended). + if (!input.names.branchName.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Branch name is empty", + }); + } + + const existingBranches = await listBranchNames(ctx, localProject.repoPath); + const branchName = deduplicateBranchName( + input.names.branchName, + existingBranches, + ); + + // FORK NOTE: safeResolveWorktreePath uses (repoPath, branch) — fork variant. + // Worktrees live under /.worktrees/, not the global + // ~/.superset/worktrees// used by upstream split. + const worktreePath = safeResolveWorktreePath( + localProject.repoPath, + branchName, + ); + + const git = await ctx.git(localProject.repoPath); + + // Trust the picker's hint when provided: it knows whether the row + // the user clicked was local or remote-only. Re-resolving here + // races against stale cached refs (a workspace branch with an + // incidental `refs/remotes/origin/` cache would silently win). + // Falls back to probing for callers that don't pass the hint. + let startPoint: ResolvedRef = + input.composer.baseBranch && input.composer.baseBranchSource + ? buildStartPointFromHint( + input.composer.baseBranch, + input.composer.baseBranchSource, + ) + : await resolveStartPoint(git, input.composer.baseBranch); + + // Local default branches are rarely fast-forwarded; swap to the + // branch's configured upstream so we fork from the real tip, not a + // stale local ref. Non-default branches stay local-first by design. + if (startPoint.kind === "local") { + const defaultBranchName = await resolveDefaultBranchName(git); + if (startPoint.shortName === defaultBranchName) { + const upstream = await resolveUpstream(git, defaultBranchName); + if (upstream) { + const remoteRef = asRemoteRef(upstream.remote, upstream.remoteBranch); + const remoteExists = await git + .raw(["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]) + .then(() => true) + .catch(() => false); + if (remoteExists) { + startPoint = { + kind: "remote-tracking", + fullRef: remoteRef, + shortName: upstream.remoteBranch, + remote: upstream.remote, + remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, + }; + } + } + } + } + + // If we resolved to a remote-tracking ref, fetch just that branch + // to ensure we're branching from the latest remote state. + if (startPoint.kind === "remote-tracking") { + try { + await git.fetch([ + startPoint.remote, + startPoint.shortName, + "--quiet", + "--no-tags", + ]); + } catch (err) { + console.warn( + `[workspaceCreation.create] fetch ${startPoint.remoteShortName} failed, proceeding with local ref:`, + err, + ); + } + } + + // Always create a new branch — never check out an existing one. + // Checking out existing branches is a separate intent (createFromPr, + // or the picker's Check out action via the `checkout` procedure). + // --no-track keeps `git pull` / ahead-behind counts from treating + // the start point as the branch's home. Push targeting is handled + // separately by push.autoSetupRemote (set below). + const startPointArg = + startPoint.kind === "head" ? "HEAD" : startPoint.shortName; + try { + await git.raw([ + "worktree", + "add", + "--no-track", + "-b", + branchName, + worktreePath, + startPoint.kind === "remote-tracking" + ? startPoint.remoteShortName + : startPointArg, + ]); + } catch (err) { + clearProgress(input.pendingId); + throw new TRPCError({ + code: "CONFLICT", + message: err instanceof Error ? err.message : "Failed to add worktree", + }); + } + + // Enable autoSetupRemote so the first terminal `git push` creates + // origin/ and sets it as upstream without requiring + // `-u`. Note: `--local` in a linked worktree writes to the shared + // repo config, so this applies repo-wide — intentional, every + // workspace worktree wants the same ergonomics. Safe against + // wrong-upstream targeting because --no-track above guarantees no + // upstream exists at first push, so auto-create always wins and + // always uses the branch's own name (never the base branch). + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaceCreation.create]", + ); + + // Record the base branch in git config so the Changes tab knows what + // to compare against on first open. startPoint.shortName is the ref + // we actually forked from (user selection, resolved against local / + // remote). + // + // FORK NOTE: only write for remote-tracking start points. Downstream + // (resolveBaseComparison) always rebuilds the compare ref as + // `origin/${baseBranch}`, so a local-only branch name would resolve + // to a non-existent `origin/` and the Changes tab would + // silently break. Skipping the write leaves baseBranch null for + // local-only bases — downstream falls back to the default branch. + // Upstream split uses `startPoint.kind !== "head"` which would write + // for local branches too — we intentionally diverge here. + if (startPoint.kind === "remote-tracking") { + await git + .raw(["config", `branch.${branchName}.base`, startPoint.shortName]) + .catch((err) => { + console.warn( + `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, + err, + ); + }); + } + + setProgress(input.pendingId, "registering"); + + const rollbackWorktree = async () => { + try { + await git.raw(["worktree", "remove", worktreePath]); + } catch (err) { + console.warn("[workspaceCreation.create] failed to rollback worktree", { + worktreePath, + err, + }); + } + }; + + let host: { id: string }; + try { + host = await ctx.api.device.ensureV2Host.mutate({ + organizationId: ctx.organizationId, + machineId: deviceClientId, + name: deviceName, + }); + } catch (err) { + console.error("[workspaceCreation.create] ensureV2Host failed", err); + clearProgress(input.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const cloudRow = await ctx.api.v2Workspace.create + .mutate({ + organizationId: ctx.organizationId, + projectId: input.projectId, + name: input.names.workspaceName, + branch: branchName, + hostId: host.id, + }) + .catch(async (err) => { + console.error( + "[workspaceCreation.create] v2Workspace.create failed", + err, + ); + clearProgress(input.pendingId); + await rollbackWorktree(); + throw err; + }); + + if (!cloudRow) { + clearProgress(input.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloud workspace create returned no row", + }); + } + + try { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: input.projectId, + worktreePath, + branch: branchName, + }) + .run(); + } catch (err) { + console.error( + "[workspaceCreation.create] local workspaces insert failed", + err, + ); + clearProgress(input.pendingId); + await rollbackWorktree(); + await ctx.api.v2Workspace.delete + .mutate({ id: cloudRow.id }) + .catch((cleanupErr) => { + console.warn( + "[workspaceCreation.create] failed to rollback cloud workspace", + { workspaceId: cloudRow.id, err: cleanupErr }, + ); + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // Fire-and-forget AI rename from the composer prompt. A single + // structured-output call generates both a display title and a + // kebab-case branch name, and we apply each independently. + // Electric syncs updates to the renderer via v2_workspaces, so + // the pending/workspace page updates in place once the model + // responds. + // + // Name precedence (matches renderer `resolveNames`): + // 1. user-typed title → skip AI rename (flag = false) + // 2. friendly fallback + prompt → AI rename (this branch) + // 3. friendly fallback, no prompt → keep fallback + // + // `expectedCurrentName` covers the race where a user edits the + // title after create but before the AI response lands. + const composerPrompt = input.composer.prompt?.trim(); + const allowAiRename = input.names.workspaceNameWasAutoGenerated !== false; + if (composerPrompt && allowAiRename) { + void applyAiWorkspaceRename({ + ctx, + workspaceId: cloudRow.id, + repoPath: localProject.repoPath, + worktreePath, + oldBranchName: branchName, + oldWorkspaceName: input.names.workspaceName, + prompt: composerPrompt, + }).catch((err) => { + console.warn( + "[workspaceCreation.create] AI workspace rename failed", + err, + ); + }); + } + + const terminals: TerminalDescriptor[] = []; + const warnings: string[] = []; + + if (input.composer.runSetupScript) { + const { terminal, warning } = startSetupTerminalIfPresent({ + ctx, + workspaceId: cloudRow.id, + worktreePath, + }); + if (warning) { + warnings.push(warning); + } + if (terminal) { + terminals.push(terminal); + } + } + + clearProgress(input.pendingId); + + return { + workspace: cloudRow, + terminals, + warnings, + }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts new file mode 100644 index 00000000000..7fe6b3a5fe6 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts @@ -0,0 +1,22 @@ +import { protectedProcedure } from "../../../index"; +import { generateBranchNameInputSchema } from "../schemas"; +import { findLocalProject } from "../shared/local-project"; +import { generateBranchNameFromPrompt } from "../utils/ai-branch-name"; +import { listBranchNames } from "../utils/list-branch-names"; + +export const generateBranchName = protectedProcedure + .input(generateBranchNameInputSchema) + .mutation(async ({ ctx, input }) => { + const trimmed = input.prompt.trim(); + if (!trimmed) return { branchName: null }; + + const localProject = findLocalProject(ctx, input.projectId); + if (!localProject) return { branchName: null }; + + const existingBranches = await listBranchNames(ctx, localProject.repoPath); + const branchName = await generateBranchNameFromPrompt( + trimmed, + existingBranches, + ); + return { branchName }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts new file mode 100644 index 00000000000..a12b2643542 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts @@ -0,0 +1,27 @@ +import { resolveDefaultBranchName } from "../../../../runtime/git/refs"; +import { protectedProcedure } from "../../../index"; +import { getContextInputSchema } from "../schemas"; +import { findLocalProject } from "../shared/local-project"; + +export const getContext = protectedProcedure + .input(getContextInputSchema) + .query(async ({ ctx, input }) => { + const localProject = findLocalProject(ctx, input.projectId); + + if (!localProject) { + return { + projectId: input.projectId, + hasLocalRepo: false, + defaultBranch: null as string | null, + }; + } + + const git = await ctx.git(localProject.repoPath); + const defaultBranch = await resolveDefaultBranchName(git); + + return { + projectId: input.projectId, + hasLocalRepo: true, + defaultBranch, + }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts new file mode 100644 index 00000000000..99b2f163e57 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts @@ -0,0 +1,42 @@ +import { TRPCError } from "@trpc/server"; +import { protectedProcedure } from "../../../index"; +import { githubIssueContentInputSchema, issueContentSchema } from "../schemas"; +import { resolveGithubRepo } from "../shared/project-helpers"; +import { execGh } from "../utils/exec-gh"; + +// Shell out to the user's `gh` CLI rather than host-service's +// octokit — `gh auth login` works out of the box while the +// credential-manager path requires setup most users don't have. +// Matches V1's projects.getIssueContent behavior. +export const getGitHubIssueContent = protectedProcedure + .input(githubIssueContentInputSchema) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + try { + const raw = await execGh([ + "issue", + "view", + String(input.issueNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,createdAt,updatedAt", + ]); + const data = issueContentSchema.parse(raw); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.url, + state: data.state.toLowerCase(), + author: data.author?.login ?? null, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch issue #${input.issueNumber}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts new file mode 100644 index 00000000000..63ea9a19ea4 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts @@ -0,0 +1,46 @@ +import { TRPCError } from "@trpc/server"; +import { protectedProcedure } from "../../../index"; +import { + githubPullRequestContentInputSchema, + pullRequestContentSchema, +} from "../schemas"; +import { resolveGithubRepo } from "../shared/project-helpers"; +import { execGh } from "../utils/exec-gh"; + +export const getGitHubPullRequestContent = protectedProcedure + .input(githubPullRequestContentInputSchema) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + try { + const raw = await execGh([ + "pr", + "view", + String(input.prNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", + ]); + const data = pullRequestContentSchema.parse(raw); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.url, + state: data.state.toLowerCase(), + branch: data.headRefName, + baseBranch: data.baseRefName, + headRepositoryOwner: data.headRepositoryOwner?.login ?? null, + isCrossRepository: data.isCrossRepository, + author: data.author?.login ?? null, + isDraft: data.isDraft, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }; + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts new file mode 100644 index 00000000000..626c6b129a2 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts @@ -0,0 +1,14 @@ +import { protectedProcedure } from "../../../index"; +import { getProgressInputSchema } from "../schemas"; +import { + getProgress as getCreateProgress, + sweepStaleProgress, +} from "../shared/progress-store"; + +export const getProgress = protectedProcedure + .input(getProgressInputSchema) + .query(({ input }) => { + sweepStaleProgress(); + const steps = getCreateProgress(input.pendingId); + return steps ? { steps } : null; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts new file mode 100644 index 00000000000..e3d66d6d734 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts @@ -0,0 +1,11 @@ +export { adopt } from "./adopt"; +export { checkout } from "./checkout"; +export { create } from "./create"; +export { generateBranchName } from "./generate-branch-name"; +export { getContext } from "./get-context"; +export { getGitHubIssueContent } from "./get-github-issue-content"; +export { getGitHubPullRequestContent } from "./get-github-pull-request-content"; +export { getProgress } from "./get-progress"; +export { searchBranches } from "./search-branches"; +export { searchGitHubIssues } from "./search-github-issues"; +export { searchPullRequests } from "./search-pull-requests"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-branches.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-branches.ts new file mode 100644 index 00000000000..abdd6429861 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-branches.ts @@ -0,0 +1,176 @@ +import { eq } from "drizzle-orm"; +import { workspaces } from "../../../../db/schema"; +import { resolveDefaultBranchName } from "../../../../runtime/git/refs"; +import { protectedProcedure } from "../../../index"; +import { searchBranchesInputSchema } from "../schemas"; +import { + decodeCursor, + encodeNextCursor, + getRecentBranchOrder, + listWorktreeBranches, + markRefetchRemote, + shouldRefetchRemote, +} from "../shared/branch-search"; +import { findLocalProject } from "../shared/local-project"; +import type { BranchRow } from "../shared/types"; + +type BranchAccum = { + name: string; + lastCommitDate: number; + isLocal: boolean; + isRemote: boolean; +}; + +export const searchBranches = protectedProcedure + .input(searchBranchesInputSchema) + .query(async ({ ctx, input }) => { + const limit = input.limit ?? 50; + const offset = decodeCursor(input.cursor); + + const localProject = findLocalProject(ctx, input.projectId); + if (!localProject) { + return { + defaultBranch: null as string | null, + items: [] as BranchRow[], + nextCursor: null as string | null, + }; + } + + const git = await ctx.git(localProject.repoPath); + + // Honor `refresh` only if TTL elapsed — prevents thrashing `git fetch` + // on every keystroke when the client tags first-page requests. + if (input.refresh && shouldRefetchRemote(input.projectId)) { + markRefetchRemote(input.projectId); + try { + await git.fetch(["--prune", "--quiet", "--no-tags"]); + } catch { + // offline — proceed with cached refs + } + } + + const defaultBranch = await resolveDefaultBranchName(git); + // FORK NOTE: listWorktreeBranches uses (git, repoPath) — fork variant. + const { worktreeMap, checkedOutBranches } = await listWorktreeBranches( + git, + localProject.repoPath, + ); + const recencyMap = await getRecentBranchOrder(git, 30); + + // Branches that already have a workspace row on this host. The + // Worktree tab uses this to distinguish Open (has row) from + // Create (orphan worktree — worktree on disk, no workspace row). + const workspaceBranches = new Set( + ctx.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all() + .map((workspace) => workspace.branch) + .filter((branch): branch is string => Boolean(branch)), + ); + + const branchMap = new Map(); + try { + const raw = await git.raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname)\t%(refname:short)\t%(committerdate:unix)", + "refs/heads/", + "refs/remotes/origin/", + ]); + for (const line of raw.trim().split("\n").filter(Boolean)) { + const [refname, _short, timestamp] = line.split("\t"); + if (!refname) continue; + + // Derive isLocal/isRemote and the user-facing name from + // the FULL refname's structural prefix — never from the + // short form. See GIT_REFS.md. + let name: string; + let isLocal = false; + let isRemote = false; + if (refname.startsWith("refs/heads/")) { + name = refname.slice("refs/heads/".length); + isLocal = true; + } else if (refname.startsWith("refs/remotes/origin/")) { + name = refname.slice("refs/remotes/origin/".length); + isRemote = true; + } else { + continue; + } + if (!name || name === "HEAD") continue; + + const existing = branchMap.get(name); + if (existing) { + existing.isLocal = existing.isLocal || isLocal; + existing.isRemote = existing.isRemote || isRemote; + continue; + } + + branchMap.set(name, { + name, + lastCommitDate: Number.parseInt(timestamp ?? "0", 10), + isLocal, + isRemote, + }); + } + } catch (err) { + console.warn( + "[workspaceCreation.searchBranches] git for-each-ref failed:", + err, + ); + } + + let branches = Array.from(branchMap.values()); + + if (input.filter === "worktree") { + branches = branches.filter((branch) => worktreeMap.has(branch.name)); + } else { + // default "branch": any branch (local or remote) without a worktree + branches = branches.filter((branch) => !worktreeMap.has(branch.name)); + } + + if (input.query) { + const query = input.query.toLowerCase(); + branches = branches.filter((branch) => + branch.name.toLowerCase().includes(query), + ); + } + + // Sort: default → reflog-recent → everything else by committerdate desc. + // for-each-ref already emits in committerdate-desc order, so the tail + // of this sort is a stable no-op for branches outside default/recency. + branches.sort((a, b) => { + const aDefault = a.name === defaultBranch ? 0 : 1; + const bDefault = b.name === defaultBranch ? 0 : 1; + if (aDefault !== bDefault) return aDefault - bDefault; + + const aRecency = recencyMap.get(a.name); + const bRecency = recencyMap.get(b.name); + if (aRecency !== undefined && bRecency !== undefined) { + return aRecency - bRecency; + } + if (aRecency !== undefined) return -1; + if (bRecency !== undefined) return 1; + + return b.lastCommitDate - a.lastCommitDate; + }); + + const page = branches.slice(offset, offset + limit); + const items: BranchRow[] = page.map((branch) => ({ + name: branch.name, + lastCommitDate: branch.lastCommitDate, + isLocal: branch.isLocal, + isRemote: branch.isRemote, + recency: recencyMap.get(branch.name) ?? null, + worktreePath: worktreeMap.get(branch.name) ?? null, + hasWorkspace: workspaceBranches.has(branch.name), + isCheckedOut: checkedOutBranches.has(branch.name), + })); + + return { + defaultBranch, + items, + nextCursor: encodeNextCursor(offset, limit, branches.length), + }; + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts new file mode 100644 index 00000000000..e54a92f7b44 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts @@ -0,0 +1,75 @@ +import { protectedProcedure } from "../../../index"; +import { normalizeGitHubQuery } from "../normalize-github-query"; +import { githubSearchInputSchema } from "../schemas"; +import { resolveGithubRepo } from "../shared/project-helpers"; + +export const searchGitHubIssues = protectedProcedure + .input(githubSearchInputSchema) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const limit = input.limit ?? 30; + + // Normalize the query: detect GitHub issue URLs, strip `#` shorthand + const raw = input.query?.trim() ?? ""; + const normalized = normalizeGitHubQuery(raw, repo, "issue"); + + if (normalized.repoMismatch) { + return { + issues: [], + repoMismatch: `${repo.owner}/${repo.name}`, + }; + } + + const effectiveQuery = normalized.query; + const octokit = await ctx.github(); + + try { + // Direct lookup by issue number (from URL paste or `#123` shorthand) + if (normalized.isDirectLookup) { + const issueNumber = Number.parseInt(effectiveQuery, 10); + const { data: issue } = await octokit.issues.get({ + owner: repo.owner, + repo: repo.name, + issue_number: issueNumber, + }); + // issues.get returns PRs too — filter them out + if (issue.pull_request) { + return { issues: [] }; + } + return { + issues: [ + { + issueNumber: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state, + authorLogin: issue.user?.login ?? null, + }, + ], + }; + } + + const query = + `repo:${repo.owner}/${repo.name} is:issue ${effectiveQuery}`.trim(); + const { data } = await octokit.search.issuesAndPullRequests({ + q: query, + per_page: limit, + sort: "updated", + order: "desc", + }); + return { + issues: data.items + .filter((item) => !item.pull_request) + .map((item) => ({ + issueNumber: item.number, + title: item.title, + url: item.html_url, + state: item.state, + authorLogin: item.user?.login ?? null, + })), + }; + } catch (err) { + console.warn("[workspaceCreation.searchGitHubIssues] failed", err); + return { issues: [] }; + } + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts new file mode 100644 index 00000000000..77c0d9bd2d7 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts @@ -0,0 +1,73 @@ +import { protectedProcedure } from "../../../index"; +import { normalizeGitHubQuery } from "../normalize-github-query"; +import { githubSearchInputSchema } from "../schemas"; +import { resolveGithubRepo } from "../shared/project-helpers"; + +export const searchPullRequests = protectedProcedure + .input(githubSearchInputSchema) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const limit = input.limit ?? 30; + + // Normalize the query: detect GitHub PR URLs, strip `#` shorthand + const raw = input.query?.trim() ?? ""; + const normalized = normalizeGitHubQuery(raw, repo, "pull"); + + if (normalized.repoMismatch) { + return { + pullRequests: [], + repoMismatch: `${repo.owner}/${repo.name}`, + }; + } + + const effectiveQuery = normalized.query; + const octokit = await ctx.github(); + + try { + // Direct lookup by PR number (from URL paste or `#123` shorthand) + if (normalized.isDirectLookup) { + const prNumber = Number.parseInt(effectiveQuery, 10); + const { data: pr } = await octokit.pulls.get({ + owner: repo.owner, + repo: repo.name, + pull_number: prNumber, + }); + return { + pullRequests: [ + { + prNumber: pr.number, + title: pr.title, + url: pr.html_url, + state: pr.state, + isDraft: pr.draft ?? false, + authorLogin: pr.user?.login ?? null, + }, + ], + }; + } + + const query = + `repo:${repo.owner}/${repo.name} is:pr ${effectiveQuery}`.trim(); + const { data } = await octokit.search.issuesAndPullRequests({ + q: query, + per_page: limit, + sort: "updated", + order: "desc", + }); + return { + pullRequests: data.items + .filter((item) => item.pull_request) + .map((item) => ({ + prNumber: item.number, + title: item.title, + url: item.html_url, + state: item.state, + isDraft: item.draft ?? false, + authorLogin: item.user?.login ?? null, + })), + }; + } catch (err) { + console.warn("[workspaceCreation.searchPullRequests] failed", err); + return { pullRequests: [] }; + } + }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts new file mode 100644 index 00000000000..b5ea31f492f --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; + +const attachmentSchema = z.object({ + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), +}); + +const linkedContextSchema = z + .object({ + internalIssueIds: z.array(z.string()).optional(), + githubIssueUrls: z.array(z.string()).optional(), + linkedPrUrl: z.string().optional(), + attachments: z.array(attachmentSchema).optional(), + }) + .optional(); + +export const getContextInputSchema = z.object({ projectId: z.string() }); + +export const searchBranchesInputSchema = z.object({ + projectId: z.string(), + query: z.string().optional(), + cursor: z.string().optional(), + limit: z.number().min(1).max(200).optional(), + refresh: z.boolean().optional(), + filter: z.enum(["branch", "worktree"]).optional(), +}); + +export const generateBranchNameInputSchema = z.object({ + projectId: z.string(), + prompt: z.string(), +}); + +export const getProgressInputSchema = z.object({ pendingId: z.string() }); + +export const createInputSchema = z.object({ + pendingId: z.string(), + projectId: z.string(), + names: z.object({ + workspaceName: z.string(), + branchName: z.string(), + // Renderer signal: true when `workspaceName` came from the + // friendly-random fallback (no user-typed title). Gates the + // post-create AI rename so a user-typed title is never + // overwritten. Optional for backcompat — defaults to allowing + // the rename, matching pre-field behavior. + workspaceNameWasAutoGenerated: z.boolean().optional(), + }), + composer: z.object({ + prompt: z.string().optional(), + baseBranch: z.string().optional(), + // Hint from the picker about which form of the base branch + // was selected. When provided, the server uses it directly + // instead of probing — avoids racing against stale cached + // remote refs that could win in a re-resolve. See + // `resolve-start-point.ts` for the fallback semantics. + baseBranchSource: z.enum(["local", "remote-tracking"]).optional(), + runSetupScript: z.boolean().optional(), + }), + linkedContext: linkedContextSchema, +}); + +const checkoutPrSchema = z.object({ + number: z.number().int().positive(), + url: z.string().url(), + title: z.string(), + headRefName: z.string(), + baseRefName: z.string(), + headRepositoryOwner: z.string(), + isCrossRepository: z.boolean(), + state: z.enum(["open", "closed", "merged"]), +}); + +export const checkoutInputSchema = z + .object({ + pendingId: z.string(), + projectId: z.string(), + workspaceName: z.string(), + // Exactly one of `branch` or `pr` must be set (refine below). + // Branch mode: caller supplies a branch name; server resolves it. + // PR mode: caller supplies PR metadata + runs `gh pr checkout`. + branch: z.string().optional(), + pr: checkoutPrSchema.optional(), + composer: z.object({ + prompt: z.string().optional(), + // Written to `branch..base` for the Changes tab. Client + // fills from picker in branch mode, or `pr.baseRefName` in PR + // mode. Server reads uniformly — no intent branching for this + // write. + baseBranch: z.string().optional(), + runSetupScript: z.boolean().optional(), + }), + linkedContext: linkedContextSchema, + }) + .refine((value) => Boolean(value.branch) !== Boolean(value.pr), { + message: "exactly one of `branch` or `pr` must be set", + }); + +export const adoptInputSchema = z.object({ + projectId: z.string(), + workspaceName: z.string(), + branch: z.string(), + // When provided, adopt the worktree at this explicit path instead + // of looking one up under /.worktrees/. Used by + // the v1→v2 migration to adopt worktrees at legacy paths (e.g. + // ~/.superset/worktrees/...) that aren't under the picker's + // Superset-managed prefix. + worktreePath: z.string().optional(), +}); + +export const githubSearchInputSchema = z.object({ + projectId: z.string(), + query: z.string().optional(), + limit: z.number().min(1).max(100).optional(), +}); + +export const githubIssueContentInputSchema = z.object({ + projectId: z.string(), + issueNumber: z.number().int().positive(), +}); + +export const githubPullRequestContentInputSchema = z.object({ + projectId: z.string(), + prNumber: z.number().int().positive(), +}); + +export const issueContentSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); + +export const pullRequestContentSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + headRefName: z.string(), + baseRefName: z.string(), + // `gh pr view` returns null when the PR's head fork repository has been + // deleted. Nullable so the schema parse doesn't fail; consumers decide + // how to handle a missing owner (client surfaces a clear error for + // cross-repo PRs — same-repo PRs shouldn't see null in practice). + headRepositoryOwner: z.object({ login: z.string() }).nullable(), + isCrossRepository: z.boolean(), + isDraft: z.boolean(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/branch-search.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/branch-search.ts new file mode 100644 index 00000000000..8edb5fde052 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/branch-search.ts @@ -0,0 +1,155 @@ +import { resolve as resolvePath, sep } from "node:path"; +import type { GitClient } from "./types"; + +function encodeCursor(offset: number): string { + return Buffer.from(JSON.stringify({ offset })).toString("base64url"); +} + +export function decodeCursor(cursor: string | undefined): number { + if (!cursor) return 0; + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ); + const offset = typeof parsed.offset === "number" ? parsed.offset : 0; + return Math.max(0, offset); + } catch { + return 0; + } +} + +export function encodeNextCursor( + offset: number, + limit: number, + total: number, +): string | null { + return offset + limit < total ? encodeCursor(offset + limit) : null; +} + +// 30s TTL on `git fetch` per project — keeps rapid searches from thrashing. +const REMOTE_REFETCH_TTL_MS = 30_000; +const lastRemoteRefetch = new Map(); + +export function shouldRefetchRemote(projectId: string): boolean { + const last = lastRemoteRefetch.get(projectId) ?? 0; + return Date.now() - last >= REMOTE_REFETCH_TTL_MS; +} + +export function markRefetchRemote(projectId: string): void { + lastRemoteRefetch.set(projectId, Date.now()); +} + +// FORK NOTE: listWorktreeBranches uses (git, repoPath) — not (ctx, git, projectId). +// Worktrees are under /.worktrees/, not ~/.superset/worktrees. +// This matches the fork's safeResolveWorktreePath in shared/worktree-paths.ts. +export async function listWorktreeBranches( + git: GitClient, + repoPath: string, +): Promise<{ + // Superset-managed worktrees only (under /.worktrees/). + // These count as "has a workspace" for the picker. + worktreeMap: Map; + // Every branch checked out in any git worktree, including the primary + // working tree. Used to disable the Checkout action when a branch is + // already in use elsewhere — `git worktree add ` would fail. + checkedOutBranches: Set; +}> { + const worktreesRoot = resolvePath(repoPath, ".worktrees"); + const worktreeMap = new Map(); + const checkedOutBranches = new Set(); + try { + const raw = await git.raw(["worktree", "list", "--porcelain"]); + let currentPath: string | null = null; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch refs/heads/") && currentPath) { + const branch = line.slice("branch refs/heads/".length).trim(); + if (!branch) continue; + checkedOutBranches.add(branch); + // Superset-managed worktrees live under /.worktrees/; + // the primary working tree is at repoPath itself and skipped here. + if (currentPath.startsWith(worktreesRoot + sep)) { + worktreeMap.set(branch, currentPath); + } + } else if (line === "") { + currentPath = null; + } + } + } catch (err) { + console.warn( + "[workspace-creation] git worktree list failed; treating no branches as checked out:", + err, + ); + } + return { worktreeMap, checkedOutBranches }; +} + +/** + * Check whether a git worktree is registered at `worktreePath` with the given + * branch checked out. Used by adopt when the caller provides an explicit path + * (e.g. v1→v2 migration) rather than a Superset-managed `.worktrees/` + * path discovered via `listWorktreeBranches`. + */ +export async function findWorktreeAtPath( + git: GitClient, + worktreePath: string, + expectedBranch: string, +): Promise { + const targetPath = resolvePath(worktreePath); + try { + const raw = await git.raw(["worktree", "list", "--porcelain"]); + let currentPath: string | null = null; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch refs/heads/") && currentPath) { + if (resolvePath(currentPath) !== targetPath) continue; + const branch = line.slice("branch refs/heads/".length).trim(); + return branch === expectedBranch; + } else if (line === "") { + currentPath = null; + } + } + } catch (err) { + console.warn( + "[workspace-creation] git worktree list failed in findWorktreeAtPath:", + err, + ); + } + return false; +} + +// Parses `git log -g` to return {branchName: ordinal} where 0 = most recent. +export async function getRecentBranchOrder( + git: GitClient, + limit: number, +): Promise> { + const order = new Map(); + try { + const raw = await git.raw([ + "log", + "-g", + "--pretty=%gs", + "--grep-reflog=checkout:", + "-n", + "500", + "HEAD", + "--", + ]); + const re = /^checkout: moving from .+ to (.+)$/; + for (const line of raw.split("\n")) { + const match = re.exec(line); + if (!match?.[1]) continue; + const name = match[1].trim(); + if (!name || /^[0-9a-f]{7,40}$/.test(name)) continue; + if (!order.has(name)) { + order.set(name, order.size); + if (order.size >= limit) break; + } + } + } catch { + // ignore (e.g. unborn branch) + } + return order; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts new file mode 100644 index 00000000000..d35af97c39e --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts @@ -0,0 +1,162 @@ +import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; +import { TRPCError } from "@trpc/server"; +import { workspaces } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import { clearProgress, setProgress } from "./progress-store"; +import { startSetupTerminalIfPresent } from "./setup-terminal"; +import type { CheckoutResult, GitClient, TerminalDescriptor } from "./types"; + +/** + * Shared postlude for `checkout` (both branch and PR paths). + * + * - Writes `branch..base` from `composer.baseBranch` for the Changes tab. + * - `ensureV2Host` + `v2Workspace.create` with rollback on failure. + * - Inserts the local `workspaces` row. + * - Optionally spawns the setup terminal. + * - Clears progress. + */ +export async function finishCheckout( + ctx: HostServiceContext, + args: { + pendingId: string; + projectId: string; + workspaceName: string; + branch: string; + worktreePath: string; + baseBranch: string | undefined; + runSetupScript: boolean; + git: GitClient; + extraWarnings: string[]; + }, +): Promise { + setProgress(args.pendingId, "registering"); + + // Record the base branch for the Changes tab (skipped if unset — matches + // `create`'s head-start-point behavior). + if (args.baseBranch) { + await args.git + .raw([ + "-C", + args.worktreePath, + "config", + `branch.${args.branch}.base`, + args.baseBranch, + ]) + .catch((err) => { + console.warn( + `[workspaceCreation.checkout] failed to record base branch ${args.baseBranch}:`, + err, + ); + }); + } + + const rollbackWorktree = async () => { + try { + await args.git.raw(["worktree", "remove", args.worktreePath]); + } catch (err) { + console.warn("[workspaceCreation.checkout] failed to rollback worktree", { + worktreePath: args.worktreePath, + err, + }); + } + }; + + const deviceClientId = getHashedDeviceId(); + const deviceName = getDeviceName(); + + let host: { id: string }; + try { + host = await ctx.api.device.ensureV2Host.mutate({ + organizationId: ctx.organizationId, + machineId: deviceClientId, + name: deviceName, + }); + } catch (err) { + console.error("[workspaceCreation.checkout] ensureV2Host failed", err); + clearProgress(args.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const cloudRow = await ctx.api.v2Workspace.create + .mutate({ + organizationId: ctx.organizationId, + projectId: args.projectId, + name: args.workspaceName, + branch: args.branch, + hostId: host.id, + }) + .catch(async (err) => { + console.error( + "[workspaceCreation.checkout] v2Workspace.create failed", + err, + ); + clearProgress(args.pendingId); + await rollbackWorktree(); + throw err; + }); + + if (!cloudRow) { + clearProgress(args.pendingId); + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloud workspace create returned no row", + }); + } + + try { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: args.projectId, + worktreePath: args.worktreePath, + branch: args.branch, + }) + .run(); + } catch (err) { + console.error( + "[workspaceCreation.checkout] local workspaces insert failed", + err, + ); + clearProgress(args.pendingId); + await rollbackWorktree(); + await ctx.api.v2Workspace.delete + .mutate({ id: cloudRow.id }) + .catch((cleanupErr) => { + console.warn( + "[workspaceCreation.checkout] failed to rollback cloud workspace", + { workspaceId: cloudRow.id, err: cleanupErr }, + ); + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const terminals: TerminalDescriptor[] = []; + const warnings: string[] = [...args.extraWarnings]; + + if (args.runSetupScript) { + const { terminal, warning } = startSetupTerminalIfPresent({ + ctx, + workspaceId: cloudRow.id, + worktreePath: args.worktreePath, + }); + if (warning) { + warnings.push(warning); + } + if (terminal) { + terminals.push(terminal); + } + } + + clearProgress(args.pendingId); + + return { workspace: cloudRow, terminals, warnings }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts new file mode 100644 index 00000000000..296722f4d56 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts @@ -0,0 +1,20 @@ +import type { GitClient } from "./types"; + +export async function enablePushAutoSetupRemote( + git: GitClient, + worktreePath: string, + logPrefix: string, +): Promise { + await git + .raw([ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]) + .catch((err) => { + console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); + }); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/local-project.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/local-project.ts new file mode 100644 index 00000000000..db85f6bebb8 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/local-project.ts @@ -0,0 +1,26 @@ +import { eq } from "drizzle-orm"; +import { projects } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import { projectNotSetupError } from "./project-helpers"; + +export type LocalProject = typeof projects.$inferSelect; + +export function findLocalProject( + ctx: HostServiceContext, + projectId: string, +): LocalProject | undefined { + return ctx.db.query.projects + .findFirst({ where: eq(projects.id, projectId) }) + .sync(); +} + +export function requireLocalProject( + ctx: HostServiceContext, + projectId: string, +): LocalProject { + const localProject = findLocalProject(ctx, projectId); + if (!localProject) { + throw projectNotSetupError(projectId); + } + return localProject; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts new file mode 100644 index 00000000000..92713c25fa0 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts @@ -0,0 +1,54 @@ +export interface ProgressStep { + id: string; + label: string; + status: "pending" | "active" | "done"; +} + +interface ProgressState { + steps: ProgressStep[]; + updatedAt: number; +} + +const STEP_DEFINITIONS = [ + { id: "ensuring_repo", label: "Ensuring local repository" }, + { id: "creating_worktree", label: "Creating worktree" }, + { id: "registering", label: "Registering workspace" }, +] as const; + +const createProgress = new Map(); + +export function setProgress(pendingId: string, activeStepId: string): void { + if (!STEP_DEFINITIONS.some((def) => def.id === activeStepId)) { + console.warn( + `[workspaceCreation.progress] unknown activeStepId "${activeStepId}" for pendingId "${pendingId}"`, + ); + return; + } + let reachedActive = false; + const steps: ProgressStep[] = STEP_DEFINITIONS.map((def) => { + if (def.id === activeStepId) { + reachedActive = true; + return { id: def.id, label: def.label, status: "active" }; + } + if (!reachedActive) { + return { id: def.id, label: def.label, status: "done" }; + } + return { id: def.id, label: def.label, status: "pending" }; + }); + createProgress.set(pendingId, { steps, updatedAt: Date.now() }); +} + +export function getProgress(pendingId: string): ProgressStep[] | null { + return createProgress.get(pendingId)?.steps ?? null; +} + +export function clearProgress(pendingId: string): void { + createProgress.delete(pendingId); +} + +export function sweepStaleProgress(): void { + const cutoff = Date.now() - 5 * 60 * 1000; + for (const [id, entry] of createProgress) { + if (entry.updatedAt < cutoff) createProgress.delete(id); + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/project-helpers.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/project-helpers.ts new file mode 100644 index 00000000000..1ec4c6b46b5 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/project-helpers.ts @@ -0,0 +1,32 @@ +import { TRPCError } from "@trpc/server"; +import type { HostServiceContext } from "../../../../types"; +import type { ProjectNotSetupCause } from "../../../error-types"; + +export function projectNotSetupError(projectId: string): TRPCError { + return new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Project is not set up on this host", + cause: { + kind: "PROJECT_NOT_SETUP", + projectId, + } satisfies ProjectNotSetupCause, + }); +} + +export async function resolveGithubRepo( + ctx: HostServiceContext, + projectId: string, +): Promise<{ owner: string; name: string }> { + const cloudProject = await ctx.api.v2Project.get.query({ + organizationId: ctx.organizationId, + id: projectId, + }); + const repo = cloudProject.githubRepository; + if (!repo?.owner || !repo?.name) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project has no linked GitHub repository", + }); + } + return { owner: repo.owner, name: repo.name }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts new file mode 100644 index 00000000000..9cef2e0eccc --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts @@ -0,0 +1,44 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createTerminalSessionInternal } from "../../../../terminal/terminal"; +import type { HostServiceContext } from "../../../../types"; +import type { TerminalDescriptor } from "./types"; + +export function startSetupTerminalIfPresent(args: { + ctx: HostServiceContext; + workspaceId: string; + worktreePath: string; +}): { terminal: TerminalDescriptor | null; warning: string | null } { + const setupScriptPath = join(args.worktreePath, ".superset", "setup.sh"); + if (!existsSync(setupScriptPath)) { + return { terminal: null, warning: null }; + } + + const terminalId = crypto.randomUUID(); + const result = createTerminalSessionInternal({ + terminalId, + workspaceId: args.workspaceId, + db: args.ctx.db, + initialCommand: `bash ${singleQuote(setupScriptPath)}`, + }); + if ("error" in result) { + return { + terminal: null, + warning: `Failed to start setup terminal: ${result.error}`, + }; + } + + return { + terminal: { + id: terminalId, + role: "setup", + label: "Workspace Setup", + }, + warning: null, + }; +} + +/** POSIX single-quote escape: safe for any path passed through a shell. */ +function singleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/start-point.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/start-point.ts new file mode 100644 index 00000000000..13eeab72941 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/start-point.ts @@ -0,0 +1,34 @@ +import { + asLocalRef, + asRemoteRef, + type ResolvedRef, +} from "../../../../runtime/git/refs"; + +/** + * Build a `ResolvedRef` directly from the picker-supplied hint without + * probing git. Used when the caller already knows whether the row was + * local or remote-only — the picker has this info per row. + * + * FORK NOTE: `baseBranchSource` captures the picker's intent so the + * server doesn't re-probe — avoids stale cached remote refs winning. + */ +export function buildStartPointFromHint( + branch: string, + source: "local" | "remote-tracking", +): ResolvedRef { + if (source === "local") { + return { + kind: "local", + fullRef: asLocalRef(branch), + shortName: branch, + }; + } + const remote = "origin"; + return { + kind: "remote-tracking", + fullRef: asRemoteRef(remote, branch), + shortName: branch, + remote, + remoteShortName: `${remote}/${branch}`, + }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts new file mode 100644 index 00000000000..302c2de8ac2 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts @@ -0,0 +1,30 @@ +import type { HostServiceContext } from "../../../../types"; + +export type GitClient = Awaited>; + +export type TerminalDescriptor = { + id: string; + role: string; + label: string; +}; + +export type BranchRow = { + name: string; + lastCommitDate: number; + isLocal: boolean; + isRemote: boolean; + recency: number | null; + worktreePath: string | null; + // True when a workspaces row exists for this (project, branch) on this + // host. A worktree can exist on disk without one (orphan); the Worktree + // tab distinguishes Open (hasWorkspace) from Create (orphan adopt). + hasWorkspace: boolean; + isCheckedOut: boolean; +}; + +export type CheckoutResult = { + workspace: { id: string }; + terminals: TerminalDescriptor[]; + warnings: string[]; + alreadyExists?: false; +}; diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts new file mode 100644 index 00000000000..289605a08da --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts @@ -0,0 +1,25 @@ +import { resolve, sep } from "node:path"; +import { TRPCError } from "@trpc/server"; + +// FORK NOTE: worktrees are kept under /.worktrees/ +// (repo-local, not global ~/.superset/worktrees). This keeps worktrees +// co-located with the repo for editors, file watchers, and ignore rules. +// Upstream split uses ~/.superset/worktrees//; we +// intentionally diverge to preserve existing workspace paths. +export function safeResolveWorktreePath( + repoPath: string, + branchName: string, +): string { + const worktreesRoot = resolve(repoPath, ".worktrees"); + const worktreePath = resolve(worktreesRoot, branchName); + if ( + worktreePath !== worktreesRoot && + !worktreePath.startsWith(worktreesRoot + sep) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid branch name: path traversal detected (${branchName})`, + }); + } + return worktreePath; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts index 2154986d162..10b036bc318 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts @@ -77,7 +77,11 @@ export async function generateWorkspaceNamesFromPrompt( id: "workspace-namer", name: "Workspace Namer", instructions: INSTRUCTIONS, - model, + // getSmallModel returns `unknown` because fork wraps multiple provider + // types behind a runtime-resolved model. Cast to Mastra's loose + // DynamicArgument shape since we validate at runtime. + // biome-ignore lint/suspicious/noExplicitAny: see comment above + model: model as any, }); try { @@ -113,6 +117,10 @@ interface ApplyAiRenameArgs { * update is source of truth; host-local DB only writes after cloud * confirms. On cloud failure the git rename is reverted so git, * host-local DB, and cloud stay in lockstep. + * + * No-op rollback: if `updateNameFromHost` returns the current row + * unchanged (expected-name guard fired), the git rename is reverted + * and the local DB update is skipped — all three stay in sync. */ export async function applyAiWorkspaceRename( args: ApplyAiRenameArgs, @@ -172,6 +180,7 @@ export async function applyAiWorkspaceRename( try { cloudResult = await ctx.api.v2Workspace.updateNameFromHost.mutate(patch); } catch (err) { + // Cloud update failed — roll back git rename to keep git and cloud in sync. if (gitRenamed) { await ctx .git(worktreePath) @@ -186,21 +195,16 @@ export async function applyAiWorkspaceRename( throw err; } - // Detect no-op caused by expectedCurrentName mismatch (concurrent user - // rename). The mutation returns the current row verbatim in that case, so - // the branch in the returned row is the pre-rename branch rather than our - // new `deduped` name. When this happens we must roll back the local git - // branch rename and skip the host-sqlite update to avoid divergence with - // the cloud row. - const cloudAcceptedBranch = - patch.branch === undefined || cloudResult.branch === deduped; - if (!cloudAcceptedBranch && gitRenamed) { + // No-op detection: if the cloud row's name still equals oldWorkspaceName, + // the expected-name guard fired (user renamed in the meantime). Revert + // the git rename and skip local DB update so all three stay in lockstep. + if (gitRenamed && cloudResult.branch !== deduped) { await ctx .git(worktreePath) .then((g) => g.raw(["branch", "-m", deduped, oldBranchName])) .catch((rollbackErr) => { console.warn( - `[applyAiWorkspaceRename] git branch rollback after cloud no-op failed (workspace ${workspaceId}, ${deduped} → ${oldBranchName})`, + `[applyAiWorkspaceRename] no-op rollback failed (workspace ${workspaceId}, ${deduped} → ${oldBranchName})`, rollbackErr, ); }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index be7baf369fd..9b5952fc1e4 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,1787 +1,28 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve, sep } from "node:path"; -import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; -import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { projects, workspaces } from "../../../db/schema"; +import { router } from "../../index"; import { - asLocalRef, - asRemoteRef, - type ResolvedRef, - resolveDefaultBranchName, - resolveRef, - resolveUpstream, -} from "../../../runtime/git/refs"; -import { createTerminalSessionInternal } from "../../../terminal/terminal"; -import type { HostServiceContext } from "../../../types"; -import type { ProjectNotSetupCause } from "../../error-types"; -import { protectedProcedure, router } from "../../index"; -import { generateBranchNameFromPrompt } from "./utils/ai-branch-name"; -import { applyAiWorkspaceRename } from "./utils/ai-workspace-names"; -import { execGh } from "./utils/exec-gh"; -import { listBranchNames } from "./utils/list-branch-names"; -import { derivePrLocalBranchName } from "./utils/pr-branch-name"; -import { resolveStartPoint } from "./utils/resolve-start-point"; -import { deduplicateBranchName } from "./utils/sanitize-branch"; - -// ── In-memory create progress (polled by renderer) ────────────────── - -interface ProgressStep { - id: string; - label: string; - status: "pending" | "active" | "done"; -} - -interface ProgressState { - steps: ProgressStep[]; - updatedAt: number; -} - -const STEP_DEFINITIONS = [ - { id: "ensuring_repo", label: "Ensuring local repository" }, - { id: "creating_worktree", label: "Creating worktree" }, - { id: "registering", label: "Registering workspace" }, -] as const; - -const createProgress = new Map(); - -function setProgress(pendingId: string, activeStepId: string): void { - let reachedActive = false; - const steps: ProgressStep[] = STEP_DEFINITIONS.map((def) => { - if (def.id === activeStepId) { - reachedActive = true; - return { id: def.id, label: def.label, status: "active" as const }; - } - if (!reachedActive) { - return { id: def.id, label: def.label, status: "done" as const }; - } - return { id: def.id, label: def.label, status: "pending" as const }; - }); - createProgress.set(pendingId, { steps, updatedAt: Date.now() }); -} - -function clearProgress(pendingId: string): void { - createProgress.delete(pendingId); -} - -function sweepStaleProgress(): void { - const cutoff = Date.now() - 5 * 60 * 1000; - for (const [id, entry] of createProgress) { - if (entry.updatedAt < cutoff) createProgress.delete(id); - } -} - -// ── Helpers ────────────────────────────────────────────────────────── - -function projectNotSetupError(projectId: string): TRPCError { - return new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Project is not set up on this host", - cause: { - kind: "PROJECT_NOT_SETUP", - projectId, - } satisfies ProjectNotSetupCause, - }); -} - -// Kept outside the primary checkout so editors, file watchers, and -// ignore rules treat worktrees as separate trees, not nested ones. -function supersetWorktreesRoot(): string { - return join(homedir(), ".superset", "worktrees"); -} - -function projectWorktreesRoot(projectId: string): string { - return resolve(supersetWorktreesRoot(), projectId); -} - -function safeResolveWorktreePath( - projectId: string, - branchName: string, -): string { - const projectRoot = projectWorktreesRoot(projectId); - const worktreePath = resolve(projectRoot, branchName); - if ( - worktreePath !== projectRoot && - !worktreePath.startsWith(projectRoot + sep) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid branch name: path traversal detected (${branchName})`, - }); - } - return worktreePath; -} - -async function resolveGithubRepo( - ctx: HostServiceContext, - projectId: string, -): Promise<{ owner: string; name: string }> { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: projectId, - }); - const repo = cloudProject.githubRepository; - if (!repo?.owner || !repo?.name) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository", - }); - } - return { owner: repo.owner, name: repo.name }; -} - -import { normalizeGitHubQuery } from "./normalize-github-query"; - -// ── searchBranches helpers ────────────────────────────────────────── - -type BranchRow = { - name: string; - lastCommitDate: number; - isLocal: boolean; - isRemote: boolean; - recency: number | null; - worktreePath: string | null; - // True when a workspaces row exists for this (project, branch) on this - // host. A worktree can exist on disk without one (orphan); the Worktree - // tab distinguishes Open (hasWorkspace) from Create (orphan adopt). - hasWorkspace: boolean; - isCheckedOut: boolean; -}; - -function encodeCursor(offset: number): string { - return Buffer.from(JSON.stringify({ offset })).toString("base64url"); -} - -function decodeCursor(cursor: string | undefined): number { - if (!cursor) return 0; - try { - const parsed = JSON.parse( - Buffer.from(cursor, "base64url").toString("utf8"), - ); - const offset = typeof parsed.offset === "number" ? parsed.offset : 0; - return Math.max(0, offset); - } catch { - return 0; - } -} - -// 30s TTL on `git fetch` per project — keeps rapid searches from thrashing. -const REMOTE_REFETCH_TTL_MS = 30_000; -const lastRemoteRefetch = new Map(); - -function shouldRefetchRemote(projectId: string): boolean { - const last = lastRemoteRefetch.get(projectId) ?? 0; - return Date.now() - last >= REMOTE_REFETCH_TTL_MS; -} - -function markRefetchRemote(projectId: string): void { - lastRemoteRefetch.set(projectId, Date.now()); -} - -type GitClient = Awaited>; - -async function listWorktreeBranches( - ctx: HostServiceContext, - git: GitClient, - projectId: string, -): Promise<{ - // A worktree counts as "ours" if its path either matches a row in - // the local `workspaces` table or lives under our managed root. The - // second case catches orphans (worktree on disk, no workspaces row, - // e.g. partial create rollback) so they surface for adoption. - worktreeMap: Map; - // Every branch checked out in any git worktree, including the primary - // working tree. Used to disable the Checkout action when a branch is - // already in use elsewhere — `git worktree add ` would fail. - checkedOutBranches: Set; -}> { - const managedRoot = projectWorktreesRoot(projectId); - const knownPaths = new Set( - ctx.db - .select({ path: workspaces.worktreePath }) - .from(workspaces) - .where(eq(workspaces.projectId, projectId)) - .all() - .map((w) => w.path), - ); - const worktreeMap = new Map(); - const checkedOutBranches = new Set(); - try { - const raw = await git.raw(["worktree", "list", "--porcelain"]); - let currentPath: string | null = null; - for (const line of raw.split("\n")) { - if (line.startsWith("worktree ")) { - currentPath = line.slice("worktree ".length).trim(); - } else if (line.startsWith("branch refs/heads/") && currentPath) { - const branch = line.slice("branch refs/heads/".length).trim(); - if (!branch) continue; - checkedOutBranches.add(branch); - if ( - knownPaths.has(currentPath) || - currentPath.startsWith(managedRoot + sep) - ) { - worktreeMap.set(branch, currentPath); - } - } else if (line === "") { - currentPath = null; - } - } - } catch (err) { - console.warn( - "[workspace-creation] git worktree list failed; treating no branches as checked out:", - err, - ); - } - return { worktreeMap, checkedOutBranches }; -} - -/** - * Check whether a git worktree is registered at `worktreePath` with the given - * branch checked out. Used by adopt when the caller provides an explicit path - * (e.g. v1→v2 migration) rather than a Superset-managed `.worktrees/` - * path discovered via `listWorktreeBranches`. - */ -async function findWorktreeAtPath( - git: GitClient, - worktreePath: string, - expectedBranch: string, -): Promise { - const targetPath = resolve(worktreePath); - try { - const raw = await git.raw(["worktree", "list", "--porcelain"]); - let currentPath: string | null = null; - for (const line of raw.split("\n")) { - if (line.startsWith("worktree ")) { - currentPath = line.slice("worktree ".length).trim(); - } else if (line.startsWith("branch refs/heads/") && currentPath) { - if (resolve(currentPath) !== targetPath) continue; - const branch = line.slice("branch refs/heads/".length).trim(); - return branch === expectedBranch; - } else if (line === "") { - currentPath = null; - } - } - } catch (err) { - console.warn( - "[workspace-creation] git worktree list failed in findWorktreeAtPath:", - err, - ); - } - return false; -} - -// Parses `git log -g` to return {branchName: ordinal} where 0 = most recent. -async function getRecentBranchOrder( - git: GitClient, - limit: number, -): Promise> { - const order = new Map(); - try { - const raw = await git.raw([ - "log", - "-g", - "--pretty=%gs", - "--grep-reflog=checkout:", - "-n", - "500", - "HEAD", - "--", - ]); - const re = /^checkout: moving from .+ to (.+)$/; - for (const line of raw.split("\n")) { - const m = re.exec(line); - if (!m?.[1]) continue; - const name = m[1].trim(); - if (!name || /^[0-9a-f]{7,40}$/.test(name)) continue; // skip detached SHAs - if (!order.has(name)) { - order.set(name, order.size); - if (order.size >= limit) break; - } - } - } catch { - // ignore (e.g. unborn branch) - } - return order; -} - -/** - * Build a `ResolvedRef` directly from the picker-supplied hint without - * probing git. Used when the caller already knows whether the row was - * local or remote-only — the picker has this info per row. - */ -function buildStartPointFromHint( - branch: string, - source: "local" | "remote-tracking", -): ResolvedRef { - if (source === "local") { - return { - kind: "local", - fullRef: asLocalRef(branch), - shortName: branch, - }; - } - const remote = "origin"; - return { - kind: "remote-tracking", - fullRef: asRemoteRef(remote, branch), - shortName: branch, - remote, - remoteShortName: `${remote}/${branch}`, - }; -} - -/** - * Shared postlude for `checkout` (both branch and PR paths). - * - * - Writes `branch..base` from `composer.baseBranch` for the Changes tab. - * - `ensureV2Host` + `v2Workspace.create` with rollback on failure. - * - Inserts the local `workspaces` row. - * - Optionally spawns the setup terminal. - * - Clears progress. - */ -async function finishCheckout( - ctx: HostServiceContext, - args: { - pendingId: string; - projectId: string; - workspaceName: string; - branch: string; - worktreePath: string; - baseBranch: string | undefined; - runSetupScript: boolean; - git: GitClient; - extraWarnings: string[]; - }, -): Promise<{ - workspace: { id: string }; - terminals: Array<{ id: string; role: string; label: string }>; - warnings: string[]; - alreadyExists?: false; -}> { - setProgress(args.pendingId, "registering"); - - // Record the base branch for the Changes tab (skipped if unset — matches - // `create`'s head-start-point behavior). - if (args.baseBranch) { - await args.git - .raw([ - "-C", - args.worktreePath, - "config", - `branch.${args.branch}.base`, - args.baseBranch, - ]) - .catch((err) => { - console.warn( - `[workspaceCreation.checkout] failed to record base branch ${args.baseBranch}:`, - err, - ); - }); - } - - const rollbackWorktree = async () => { - try { - await args.git.raw(["worktree", "remove", args.worktreePath]); - } catch (err) { - console.warn("[workspaceCreation.checkout] failed to rollback worktree", { - worktreePath: args.worktreePath, - err, - }); - } - }; - - const deviceClientId = getHashedDeviceId(); - const deviceName = getDeviceName(); - - let host: { id: string }; - try { - host = await ctx.api.device.ensureV2Host.mutate({ - organizationId: ctx.organizationId, - machineId: deviceClientId, - name: deviceName, - }); - } catch (err) { - console.error("[workspaceCreation.checkout] ensureV2Host failed", err); - clearProgress(args.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: args.projectId, - name: args.workspaceName, - branch: args.branch, - hostId: host.id, - }) - .catch(async (err) => { - console.error( - "[workspaceCreation.checkout] v2Workspace.create failed", - err, - ); - clearProgress(args.pendingId); - await rollbackWorktree(); - throw err; - }); - - if (!cloudRow) { - clearProgress(args.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } - - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: args.projectId, - worktreePath: args.worktreePath, - branch: args.branch, - }) - .run(); - - const terminals: Array<{ id: string; role: string; label: string }> = []; - const warnings: string[] = [...args.extraWarnings]; - - if (args.runSetupScript) { - const setupScriptPath = join(args.worktreePath, ".superset", "setup.sh"); - if (existsSync(setupScriptPath)) { - const terminalId = crypto.randomUUID(); - const result = createTerminalSessionInternal({ - terminalId, - workspaceId: cloudRow.id, - db: ctx.db, - initialCommand: `bash "${setupScriptPath}"`, - }); - if ("error" in result) { - warnings.push(`Failed to start setup terminal: ${result.error}`); - } else { - terminals.push({ - id: terminalId, - role: "setup", - label: "Workspace Setup", - }); - } - } - } - - clearProgress(args.pendingId); - - return { workspace: cloudRow, terminals, warnings }; -} - -// ── Router ─────────────────────────────────────────────────────────── + adopt, + checkout, + create, + generateBranchName, + getContext, + getGitHubIssueContent, + getGitHubPullRequestContent, + getProgress, + searchBranches, + searchGitHubIssues, + searchPullRequests, +} from "./procedures"; export const workspaceCreationRouter = router({ - getContext: protectedProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - - if (!localProject) { - return { - projectId: input.projectId, - hasLocalRepo: false, - defaultBranch: null as string | null, - }; - } - - const git = await ctx.git(localProject.repoPath); - const defaultBranch: string | null = await resolveDefaultBranchName(git); - - return { - projectId: input.projectId, - hasLocalRepo: true, - defaultBranch, - }; - }), - - searchBranches: protectedProcedure - .input( - z.object({ - projectId: z.string(), - query: z.string().optional(), - cursor: z.string().optional(), - limit: z.number().min(1).max(200).optional(), - refresh: z.boolean().optional(), - filter: z.enum(["branch", "worktree"]).optional(), - }), - ) - .query(async ({ ctx, input }) => { - const limit = input.limit ?? 50; - const offset = decodeCursor(input.cursor); - - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - - if (!localProject) { - return { - defaultBranch: null as string | null, - items: [] as BranchRow[], - nextCursor: null as string | null, - }; - } - - const git = await ctx.git(localProject.repoPath); - - // Honor `refresh` only if TTL elapsed — prevents thrashing `git fetch` - // on every keystroke when the client tags first-page requests. - if (input.refresh && shouldRefetchRemote(input.projectId)) { - markRefetchRemote(input.projectId); - try { - await git.fetch(["--prune", "--quiet", "--no-tags"]); - } catch { - // offline — proceed with cached refs - } - } - - const defaultBranch: string | null = await resolveDefaultBranchName(git); - - const { worktreeMap, checkedOutBranches } = await listWorktreeBranches( - ctx, - git, - input.projectId, - ); - const recencyMap = await getRecentBranchOrder(git, 30); - - // Branches that already have a workspace row on this host. The - // Worktree tab uses this to distinguish Open (has row) from - // Create (orphan worktree — worktree on disk, no workspace row). - const workspaceBranches = new Set( - ctx.db - .select() - .from(workspaces) - .where(eq(workspaces.projectId, input.projectId)) - .all() - .map((w) => w.branch) - .filter((b): b is string => !!b), - ); - - type BranchAccum = { - name: string; - lastCommitDate: number; - isLocal: boolean; - isRemote: boolean; - }; - const branchMap = new Map(); - try { - const raw = await git.raw([ - "for-each-ref", - "--sort=-committerdate", - "--format=%(refname)\t%(refname:short)\t%(committerdate:unix)", - "refs/heads/", - "refs/remotes/origin/", - ]); - for (const line of raw.trim().split("\n").filter(Boolean)) { - const [refname, _short, ts] = line.split("\t"); - if (!refname) continue; - - // Derive isLocal/isRemote and the user-facing name from - // the FULL refname's structural prefix — never from the - // short form. See GIT_REFS.md. - let name: string; - let isLocal = false; - let isRemote = false; - if (refname.startsWith("refs/heads/")) { - name = refname.slice("refs/heads/".length); - isLocal = true; - } else if (refname.startsWith("refs/remotes/origin/")) { - name = refname.slice("refs/remotes/origin/".length); - isRemote = true; - } else { - continue; - } - if (!name || name === "HEAD") continue; - - const existing = branchMap.get(name); - if (existing) { - existing.isLocal = existing.isLocal || isLocal; - existing.isRemote = existing.isRemote || isRemote; - } else { - branchMap.set(name, { - name, - lastCommitDate: Number.parseInt(ts ?? "0", 10), - isLocal, - isRemote, - }); - } - } - } catch { - // ignore - } - - let branches = Array.from(branchMap.values()); - - if (input.filter === "worktree") { - branches = branches.filter((b) => worktreeMap.has(b.name)); - } else { - // default "branch": any branch (local or remote) without a worktree - branches = branches.filter((b) => !worktreeMap.has(b.name)); - } - - if (input.query) { - const q = input.query.toLowerCase(); - branches = branches.filter((b) => b.name.toLowerCase().includes(q)); - } - - // Sort: default → reflog-recent → everything else by committerdate desc. - // for-each-ref already emits in committerdate-desc order, so the tail - // of this sort is a stable no-op for branches outside default/recency. - branches.sort((a, b) => { - const aDefault = a.name === defaultBranch ? 0 : 1; - const bDefault = b.name === defaultBranch ? 0 : 1; - if (aDefault !== bDefault) return aDefault - bDefault; - - const aRecency = recencyMap.get(a.name); - const bRecency = recencyMap.get(b.name); - if (aRecency !== undefined && bRecency !== undefined) { - return aRecency - bRecency; - } - if (aRecency !== undefined) return -1; - if (bRecency !== undefined) return 1; - - return b.lastCommitDate - a.lastCommitDate; - }); - - const page = branches.slice(offset, offset + limit); - const hasMore = offset + limit < branches.length; - const nextCursor = hasMore ? encodeCursor(offset + limit) : null; - - const items: BranchRow[] = page.map((b) => ({ - name: b.name, - lastCommitDate: b.lastCommitDate, - isLocal: b.isLocal, - isRemote: b.isRemote, - recency: recencyMap.get(b.name) ?? null, - worktreePath: worktreeMap.get(b.name) ?? null, - hasWorkspace: workspaceBranches.has(b.name), - isCheckedOut: checkedOutBranches.has(b.name), - })); - - return { defaultBranch, items, nextCursor }; - }), - - generateBranchName: protectedProcedure - .input(z.object({ projectId: z.string(), prompt: z.string() })) - .mutation(async ({ ctx, input }) => { - const trimmed = input.prompt.trim(); - if (!trimmed) return { branchName: null }; - - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - if (!localProject) return { branchName: null }; - - const existingBranches = await listBranchNames( - ctx, - localProject.repoPath, - ); - const branchName = await generateBranchNameFromPrompt( - trimmed, - existingBranches, - ); - return { branchName }; - }), - - /** - * Create a new workspace. Always creates — never opens an existing one. - * Branch name is sanitized and deduplicated server-side. - */ - getProgress: protectedProcedure - .input(z.object({ pendingId: z.string() })) - .query(({ input }) => { - sweepStaleProgress(); - const entry = createProgress.get(input.pendingId); - return entry ? { steps: entry.steps } : null; - }), - - create: protectedProcedure - .input( - z.object({ - pendingId: z.string(), - projectId: z.string(), - names: z.object({ - workspaceName: z.string(), - branchName: z.string(), - workspaceNameWasAutoGenerated: z.boolean().optional(), - }), - composer: z.object({ - prompt: z.string().optional(), - baseBranch: z.string().optional(), - // Hint from the picker about which form of the base branch - // was selected. When provided, the server uses it directly - // instead of probing — avoids racing against stale cached - // remote refs that could win in a re-resolve. See - // `resolve-start-point.ts` for the fallback semantics. - baseBranchSource: z.enum(["local", "remote-tracking"]).optional(), - runSetupScript: z.boolean().optional(), - }), - linkedContext: z - .object({ - internalIssueIds: z.array(z.string()).optional(), - githubIssueUrls: z.array(z.string()).optional(), - linkedPrUrl: z.string().optional(), - attachments: z - .array( - z.object({ - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), - }), - ) - .optional(), - }) - .optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const deviceClientId = getHashedDeviceId(); - const deviceName = getDeviceName(); - setProgress(input.pendingId, "ensuring_repo"); - - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - if (!localProject) { - throw projectNotSetupError(input.projectId); - } - - setProgress(input.pendingId, "creating_worktree"); - - // 2. Validate + deduplicate branch name - // Renderer already sanitized/slugified. Host-service only validates - // and deduplicates — doesn't re-sanitize (which would strip case, - // slashes, etc. the user intended). - if (!input.names.branchName.trim()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - const existingBranches = await listBranchNames( - ctx, - localProject.repoPath, - ); - const branchName = deduplicateBranchName( - input.names.branchName, - existingBranches, - ); - - // 3. Create worktree - const worktreePath = safeResolveWorktreePath(localProject.id, branchName); - mkdirSync(dirname(worktreePath), { recursive: true }); - - const git = await ctx.git(localProject.repoPath); - - // Trust the picker's hint when provided: it knows whether the row - // the user clicked was local or remote-only. Re-resolving here - // races against stale cached refs (a workspace branch with an - // incidental `refs/remotes/origin/` cache would silently win). - // Falls back to probing for callers that don't pass the hint. - let startPoint: ResolvedRef = - input.composer.baseBranch && input.composer.baseBranchSource - ? buildStartPointFromHint( - input.composer.baseBranch, - input.composer.baseBranchSource, - ) - : await resolveStartPoint(git, input.composer.baseBranch); - - // Local default branches are rarely fast-forwarded; swap to the - // branch's configured upstream so we fork from the real tip, not a - // stale local ref. Non-default branches stay local-first by design. - if (startPoint.kind === "local") { - const defaultBranchName = await resolveDefaultBranchName(git); - if (startPoint.shortName === defaultBranchName) { - const upstream = await resolveUpstream(git, defaultBranchName); - if (upstream) { - const remoteRef = asRemoteRef( - upstream.remote, - upstream.remoteBranch, - ); - const remoteExists = await git - .raw([ - "rev-parse", - "--verify", - "--quiet", - `${remoteRef}^{commit}`, - ]) - .then(() => true) - .catch(() => false); - if (remoteExists) { - startPoint = { - kind: "remote-tracking", - fullRef: remoteRef, - shortName: upstream.remoteBranch, - remote: upstream.remote, - remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, - }; - } - } - } - } - - console.log( - `[workspaceCreation.create] start point: ${startPoint.kind} (${ - input.composer.baseBranchSource ? "from hint" : "resolved" - })`, - ); - - // If we resolved to a remote-tracking ref, fetch just that branch - // to ensure we're branching from the latest remote state. - if (startPoint.kind === "remote-tracking") { - try { - await git.fetch([ - startPoint.remote, - startPoint.shortName, - "--quiet", - "--no-tags", - ]); - } catch (err) { - console.warn( - `[workspaceCreation.create] fetch ${startPoint.remoteShortName} failed, proceeding with local ref:`, - err, - ); - } - } - - // Always create a new branch — never check out an existing one. - // Checking out existing branches is a separate intent (createFromPr, - // or the picker's Check out action via the `checkout` procedure). - // --no-track keeps `git pull` / ahead-behind counts from treating - // the start point as the branch's home. Push targeting is handled - // separately by push.autoSetupRemote (set below). - const startPointArg = - startPoint.kind === "head" ? "HEAD" : startPoint.shortName; - await git.raw([ - "worktree", - "add", - "--no-track", - "-b", - branchName, - worktreePath, - startPoint.kind === "remote-tracking" - ? startPoint.remoteShortName - : startPointArg, - ]); - - // Enable autoSetupRemote so the first terminal `git push` creates - // origin/ and sets it as upstream without requiring - // `-u`. Note: `--local` in a linked worktree writes to the shared - // repo config, so this applies repo-wide — intentional, every - // workspace worktree wants the same ergonomics. Safe against - // wrong-upstream targeting because --no-track above guarantees no - // upstream exists at first push, so auto-create always wins and - // always uses the branch's own name (never the base branch). - await git - .raw([ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]) - .catch((err) => { - console.warn( - "[workspaceCreation.create] failed to set push.autoSetupRemote:", - err, - ); - }); - - // Record the base branch in git config so the Changes tab knows what - // to compare against on first open. startPoint.shortName is the ref - // we actually forked from (user selection, resolved against local / - // remote). Skipped for "head" start point — no meaningful base. - // FORK NOTE: only write for remote-tracking start points. Downstream - // (resolveBaseComparison) always rebuilds the compare ref as - // `origin/${baseBranch}`, so a local-only branch name would resolve - // to a non-existent `origin/` and the Changes tab would - // silently break. Skipping the write leaves baseBranch null for - // local-only bases — downstream falls back to the default branch. - if (startPoint.kind === "remote-tracking") { - await git - .raw(["config", `branch.${branchName}.base`, startPoint.shortName]) - .catch((err) => { - console.warn( - `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, - err, - ); - }); - } - - setProgress(input.pendingId, "registering"); - - // 4. Register cloud workspace row - const rollbackWorktree = async () => { - try { - await git.raw(["worktree", "remove", worktreePath]); - } catch (err) { - console.warn( - "[workspaceCreation.create] failed to rollback worktree", - { worktreePath, err }, - ); - } - }; - - let host: { id: string }; - try { - host = await ctx.api.device.ensureV2Host.mutate({ - organizationId: ctx.organizationId, - machineId: deviceClientId, - name: deviceName, - }); - } catch (err) { - console.error("[workspaceCreation.create] ensureV2Host failed", err); - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: input.projectId, - name: input.names.workspaceName, - branch: branchName, - hostId: host.id, - }) - .catch(async (err) => { - console.error( - "[workspaceCreation.create] v2Workspace.create failed", - err, - ); - clearProgress(input.pendingId); - await rollbackWorktree(); - throw err; - }); - - if (!cloudRow) { - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } - - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: input.projectId, - worktreePath, - branch: branchName, - }) - .run(); - - // Fire-and-forget AI rename from the composer prompt. A single - // structured-output call generates both a display title and a - // kebab-case branch name, and we apply each independently. - // Electric syncs updates to the renderer via v2_workspaces, so - // the pending/workspace page updates in place once the model - // responds. - // - // Name precedence (matches renderer `resolveNames`): - // 1. user-typed title → skip AI rename (flag = false) - // 2. friendly fallback + prompt → AI rename (this branch) - // 3. friendly fallback, no prompt → keep fallback - // - // `expectedCurrentName` covers the race where a user edits the - // title after create but before the AI response lands. - const composerPrompt = input.composer.prompt?.trim(); - const allowAiRename = input.names.workspaceNameWasAutoGenerated !== false; - if (composerPrompt && allowAiRename) { - void applyAiWorkspaceRename({ - ctx, - workspaceId: cloudRow.id, - repoPath: localProject.repoPath, - worktreePath, - oldBranchName: branchName, - oldWorkspaceName: input.names.workspaceName, - prompt: composerPrompt, - }).catch((err) => { - console.warn( - "[workspaceCreation.create] AI workspace rename failed", - err, - ); - }); - } - - // 5. Create setup terminal if setup script exists - const terminals: Array<{ - id: string; - role: string; - label: string; - }> = []; - const warnings: string[] = []; - - if (input.composer.runSetupScript) { - const setupScriptPath = join(worktreePath, ".superset", "setup.sh"); - if (existsSync(setupScriptPath)) { - const terminalId = crypto.randomUUID(); - const result = createTerminalSessionInternal({ - terminalId, - workspaceId: cloudRow.id, - db: ctx.db, - initialCommand: `bash "${setupScriptPath}"`, - }); - if ("error" in result) { - warnings.push(`Failed to start setup terminal: ${result.error}`); - } else { - terminals.push({ - id: terminalId, - role: "setup", - label: "Workspace Setup", - }); - } - } - } - - clearProgress(input.pendingId); - - return { - workspace: cloudRow, - terminals, - warnings, - }; - }), - - /** - * Check out an existing branch into a new workspace. Unlike `create`, this - * reuses the branch name as-is (no new branch) — `git worktree add` without - * `-b`. Fails if the branch is already checked out elsewhere. - */ - checkout: protectedProcedure - .input( - z - .object({ - pendingId: z.string(), - projectId: z.string(), - workspaceName: z.string(), - // Exactly one of `branch` or `pr` must be set (refine below). - // Branch mode: caller supplies a branch name; server resolves it. - // PR mode: caller supplies PR metadata; server derives branch name - // + runs `gh pr checkout`. - branch: z.string().optional(), - pr: z - .object({ - number: z.number().int().positive(), - url: z.string().url(), - title: z.string(), - headRefName: z.string(), - baseRefName: z.string(), - headRepositoryOwner: z.string(), - isCrossRepository: z.boolean(), - state: z.enum(["open", "closed", "merged"]), - }) - .optional(), - composer: z.object({ - prompt: z.string().optional(), - // Written to `branch..base` for the Changes tab. Client - // fills from picker in branch mode, or `pr.baseRefName` in PR - // mode. Server reads uniformly — no intent branching for this - // write. - baseBranch: z.string().optional(), - runSetupScript: z.boolean().optional(), - }), - linkedContext: z - .object({ - internalIssueIds: z.array(z.string()).optional(), - githubIssueUrls: z.array(z.string()).optional(), - linkedPrUrl: z.string().optional(), - attachments: z - .array( - z.object({ - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), - }), - ) - .optional(), - }) - .optional(), - }) - .refine((v) => Boolean(v.branch) !== Boolean(v.pr), { - message: "exactly one of `branch` or `pr` must be set", - }), - ) - .mutation(async ({ ctx, input }) => { - setProgress(input.pendingId, "ensuring_repo"); - - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - if (!localProject) { - throw projectNotSetupError(input.projectId); - } - - setProgress(input.pendingId, "creating_worktree"); - - // ── PR path ──────────────────────────────────────────────────────── - if (input.pr) { - const branch = derivePrLocalBranchName(input.pr); - - // Idempotency: existing workspace for this PR's branch → - // return it. Renderer navigates to it via `alreadyExists: true` - // instead of treating as a new create. - const existing = ctx.db.query.workspaces - .findFirst({ - where: and( - eq(workspaces.projectId, input.projectId), - eq(workspaces.branch, branch), - ), - }) - .sync(); - if (existing) { - clearProgress(input.pendingId); - return { - workspace: { id: existing.id }, - terminals: [], - warnings: [], - alreadyExists: true as const, - }; - } - - let worktreePath: string; - try { - worktreePath = safeResolveWorktreePath(localProject.id, branch); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - mkdirSync(dirname(worktreePath), { recursive: true }); - const git = await ctx.git(localProject.repoPath); - - // Detect a pre-existing local branch with the same derived name - // BEFORE running `gh pr checkout --force`. The idempotency check - // above rules out Superset-managed worktrees, but a branch can - // exist outside any workspace — e.g., from a prior manual - // `gh pr checkout` in the primary working tree. `--force` would - // reset it to the PR HEAD, silently losing any unpushed commits. - // We surface a warning pointing at reflog for recovery rather - // than blocking, so the point-and-click flow stays smooth. - let preExistingLocalBranch = false; - try { - await git.raw([ - "show-ref", - "--verify", - "--quiet", - `refs/heads/${branch}`, - ]); - preExistingLocalBranch = true; - } catch { - // Non-zero exit = branch doesn't exist. Expected path. - } - - // Detached worktree first — `gh pr checkout` inside it creates the - // branch with correct fork-remote + upstream config. Mirrors v1's - // `createWorktreeFromPr`. - try { - await git.raw(["worktree", "add", "--detach", worktreePath]); - } catch (err) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "CONFLICT", - message: - err instanceof Error - ? err.message - : "Failed to add detached worktree", - }); - } - - try { - await execGh( - [ - "pr", - "checkout", - String(input.pr.number), - "--branch", - branch, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, - ); - } catch (err) { - await git - .raw(["worktree", "remove", "--force", worktreePath]) - .catch(() => {}); - clearProgress(input.pendingId); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `gh pr checkout failed: ${ - err instanceof Error ? err.message : String(err) - }`, - }); - } - - // Push ergonomics. `gh pr checkout` sets per-branch push config - // to the fork URL for cross-repo PRs; this covers the same-repo - // case where upstream isn't auto-set. - await git - .raw([ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]) - .catch((err) => { - console.warn( - "[workspaceCreation.checkout] failed to set push.autoSetupRemote:", - err, - ); - }); - - const extraWarnings: string[] = []; - if (input.pr.state !== "open") { - extraWarnings.push( - `PR is ${input.pr.state} — commits are included, but the PR may not merge.`, - ); - } - if (preExistingLocalBranch) { - extraWarnings.push( - `Reset existing local branch "${branch}" to PR HEAD. If you had unpushed commits there, recover them via \`git reflog show ${branch}\`.`, - ); - } - - return await finishCheckout(ctx, { - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch, - worktreePath, - baseBranch: input.composer.baseBranch, - runSetupScript: input.composer.runSetupScript ?? false, - git, - extraWarnings, - }); - } - - // ── Branch path ──────────────────────────────────────────────────── - const branch = (input.branch ?? "").trim(); - if (!branch) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - let worktreePath: string; - try { - worktreePath = safeResolveWorktreePath(localProject.id, branch); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - mkdirSync(dirname(worktreePath), { recursive: true }); - const git = await ctx.git(localProject.repoPath); - - // Resolve via the discriminated-ref helper so we don't infer kind - // from a refname string (a local branch named `origin/foo` would - // otherwise be misclassified). See GIT_REFS.md. - const resolved = await resolveRef(git, branch); - if (!resolved || resolved.kind === "head" || resolved.kind === "tag") { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: - resolved?.kind === "tag" - ? `"${branch}" is a tag, not a branch — cannot check out into a workspace` - : `Branch "${branch}" does not exist locally or on origin`, - }); - } - - if (resolved.kind === "remote-tracking") { - try { - await git.fetch([ - resolved.remote, - resolved.shortName, - "--quiet", - "--no-tags", - ]); - } catch (err) { - console.warn( - `[workspaceCreation.checkout] fetch ${resolved.remoteShortName} failed:`, - err, - ); - } - } - - try { - // For a remote-only branch, create a local tracking branch - // explicitly. `git worktree add origin/` without - // --track/-b produces a detached HEAD because the fully-qualified - // ref is treated as a commit-ish, not a branch shorthand. - await git.raw( - resolved.kind === "remote-tracking" - ? [ - "worktree", - "add", - "--track", - "-b", - branch, - worktreePath, - resolved.remoteShortName, - ] - : ["worktree", "add", worktreePath, resolved.shortName], - ); - } catch (err) { - clearProgress(input.pendingId); - const message = - err instanceof Error ? err.message : "Failed to add worktree"; - // Most common cause here is "branch already checked out elsewhere". - // Client disables the button for known cases via isCheckedOut, but - // we still get here for races. - throw new TRPCError({ code: "CONFLICT", message }); - } - - // Enable autoSetupRemote so the first terminal `git push` on a - // local-only branch creates origin/ without requiring -u. - // Branches checked out from a remote already have upstream set - // via --track above, so this config is a no-op for them. - // `--local` in a linked worktree writes to the shared repo config, - // so this applies repo-wide — intentional. - await git - .raw([ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]) - .catch((err) => { - console.warn( - "[workspaceCreation.checkout] failed to set push.autoSetupRemote:", - err, - ); - }); - - return await finishCheckout(ctx, { - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch, - worktreePath, - baseBranch: input.composer.baseBranch, - runSetupScript: input.composer.runSetupScript ?? false, - git, - extraWarnings: [], - }); - }), - - /** - * Adopt an existing git worktree as a workspace. Used when the Worktree - * tab surfaces a branch whose worktree directory exists on disk but has - * no corresponding workspaces row (e.g. partial create rollback). No git - * ops — just registers the cloud + local workspace row over the - * existing worktree path. - */ - adopt: protectedProcedure - .input( - z.object({ - projectId: z.string(), - workspaceName: z.string(), - branch: z.string(), - // When provided, adopt the worktree at this explicit path instead - // of looking one up under /.worktrees/. Used by - // the v1→v2 migration to adopt worktrees at legacy paths (e.g. - // ~/.superset/worktrees/...) that aren't under the picker's - // Superset-managed prefix. - worktreePath: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const deviceClientId = getHashedDeviceId(); - const deviceName = getDeviceName(); - - const localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - if (!localProject) { - throw projectNotSetupError(input.projectId); - } - - const branch = input.branch.trim(); - if (!branch) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - const git = await ctx.git(localProject.repoPath); - - let worktreePath: string; - if (input.worktreePath) { - const found = await findWorktreeAtPath(git, input.worktreePath, branch); - if (!found) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `No git worktree registered at "${input.worktreePath}" on branch "${branch}"`, - }); - } - worktreePath = input.worktreePath; - } else { - const { worktreeMap } = await listWorktreeBranches( - ctx, - git, - input.projectId, - ); - const found = worktreeMap.get(branch); - if (!found) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `No existing worktree for branch "${branch}"`, - }); - } - worktreePath = found; - } - - // We used to short-circuit on an existing local `workspaces` row - // (returning its id without calling cloud). That returned a - // phantom id when the cloud row had been hard-deleted — the - // picker would navigate to a workspace that no longer exists. - // Always create a fresh cloud row; if a stale local row leftover - // from a prior delete exists, replace it below. Proper host-side - // cleanup on delete is owned by the follow-up delete PR. - const host = await ctx.api.device.ensureV2Host.mutate({ - organizationId: ctx.organizationId, - machineId: deviceClientId, - name: deviceName, - }); - - const cloudRow = await ctx.api.v2Workspace.create.mutate({ - organizationId: ctx.organizationId, - projectId: input.projectId, - name: input.workspaceName, - branch, - hostId: host.id, - }); - - if (!cloudRow) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } - - // Replace any stale local row for this (project, branch) — its - // id likely points at a deleted cloud row. The new cloudRow.id - // is the authoritative mapping. - const stale = ctx.db - .select() - .from(workspaces) - .where(eq(workspaces.projectId, input.projectId)) - .all() - .find((w) => w.branch === branch); - if (stale && stale.id !== cloudRow.id) { - ctx.db.delete(workspaces).where(eq(workspaces.id, stale.id)).run(); - } - - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: input.projectId, - worktreePath, - branch, - }) - .run(); - - return { - workspace: cloudRow, - terminals: [] as Array<{ id: string; role: string; label: string }>, - warnings: [] as string[], - }; - }), - - // ── GitHub endpoints for the link commands ──────────────────────── - - searchGitHubIssues: protectedProcedure - .input( - z.object({ - projectId: z.string(), - query: z.string().optional(), - limit: z.number().min(1).max(100).optional(), - }), - ) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - const limit = input.limit ?? 30; - - // Normalize the query: detect GitHub issue URLs, strip `#` shorthand - const raw = input.query?.trim() ?? ""; - const normalized = normalizeGitHubQuery(raw, repo, "issue"); - - if (normalized.repoMismatch) { - return { - issues: [], - repoMismatch: `${repo.owner}/${repo.name}`, - }; - } - - const effectiveQuery = normalized.query; - const octokit = await ctx.github(); - - try { - // Direct lookup by issue number (from URL paste or `#123` shorthand) - if (normalized.isDirectLookup) { - const issueNumber = Number.parseInt(effectiveQuery, 10); - const { data: issue } = await octokit.issues.get({ - owner: repo.owner, - repo: repo.name, - issue_number: issueNumber, - }); - // issues.get returns PRs too — filter them out - if (issue.pull_request) { - return { issues: [] }; - } - return { - issues: [ - { - issueNumber: issue.number, - title: issue.title, - url: issue.html_url, - state: issue.state, - authorLogin: issue.user?.login ?? null, - }, - ], - }; - } - - const q = - `repo:${repo.owner}/${repo.name} is:issue ${effectiveQuery}`.trim(); - const { data } = await octokit.search.issuesAndPullRequests({ - q, - per_page: limit, - sort: "updated", - order: "desc", - }); - return { - issues: data.items - .filter((item) => !item.pull_request) - .map((item) => ({ - issueNumber: item.number, - title: item.title, - url: item.html_url, - state: item.state, - authorLogin: item.user?.login ?? null, - })), - }; - } catch (err) { - console.warn("[workspaceCreation.searchGitHubIssues] failed", err); - return { issues: [] }; - } - }), - - searchPullRequests: protectedProcedure - .input( - z.object({ - projectId: z.string(), - query: z.string().optional(), - limit: z.number().min(1).max(100).optional(), - }), - ) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - const limit = input.limit ?? 30; - - // Normalize the query: detect GitHub PR URLs, strip `#` shorthand - const raw = input.query?.trim() ?? ""; - const normalized = normalizeGitHubQuery(raw, repo, "pull"); - - if (normalized.repoMismatch) { - return { - pullRequests: [], - repoMismatch: `${repo.owner}/${repo.name}`, - }; - } - - const effectiveQuery = normalized.query; - const octokit = await ctx.github(); - - try { - // Direct lookup by PR number (from URL paste or `#123` shorthand) - if (normalized.isDirectLookup) { - const prNumber = Number.parseInt(effectiveQuery, 10); - const { data: pr } = await octokit.pulls.get({ - owner: repo.owner, - repo: repo.name, - pull_number: prNumber, - }); - return { - pullRequests: [ - { - prNumber: pr.number, - title: pr.title, - url: pr.html_url, - state: pr.state, - isDraft: pr.draft ?? false, - authorLogin: pr.user?.login ?? null, - }, - ], - }; - } - - const q = - `repo:${repo.owner}/${repo.name} is:pr ${effectiveQuery}`.trim(); - const { data } = await octokit.search.issuesAndPullRequests({ - q, - per_page: limit, - sort: "updated", - order: "desc", - }); - return { - pullRequests: data.items - .filter((item) => item.pull_request) - .map((item) => ({ - prNumber: item.number, - title: item.title, - url: item.html_url, - state: item.state, - isDraft: item.draft ?? false, - authorLogin: item.user?.login ?? null, - })), - }; - } catch (err) { - console.warn("[workspaceCreation.searchPullRequests] failed", err); - return { pullRequests: [] }; - } - }), - - // Shell out to the user's `gh` CLI rather than host-service's - // octokit — `gh auth login` works out of the box while the - // credential-manager path requires setup most users don't have. - // Matches V1's projects.getIssueContent behavior. - - getGitHubIssueContent: protectedProcedure - .input( - z.object({ - projectId: z.string(), - issueNumber: z.number().int().positive(), - }), - ) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - try { - const raw = await execGh([ - "issue", - "view", - String(input.issueNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,createdAt,updatedAt", - ]); - const data = IssueSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - author: data.author?.login ?? null, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch issue #${input.issueNumber}: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }), - - getGitHubPullRequestContent: protectedProcedure - .input( - z.object({ - projectId: z.string(), - prNumber: z.number().int().positive(), - }), - ) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - try { - const raw = await execGh([ - "pr", - "view", - String(input.prNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", - ]); - const data = PrSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - branch: data.headRefName, - baseBranch: data.baseRefName, - headRepositoryOwner: data.headRepositoryOwner?.login ?? null, - isCrossRepository: data.isCrossRepository, - author: data.author?.login ?? null, - isDraft: data.isDraft, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }), -}); - -const IssueSchema = z.object({ - number: z.number(), - title: z.string(), - body: z.string().nullable().optional(), - url: z.string(), - state: z.string(), - author: z.object({ login: z.string() }).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); - -const PrSchema = z.object({ - number: z.number(), - title: z.string(), - body: z.string().nullable().optional(), - url: z.string(), - state: z.string(), - headRefName: z.string(), - baseRefName: z.string(), - // `gh pr view` returns null when the PR's head fork repository has been - // deleted. Nullable so the schema parse doesn't fail; consumers decide - // how to handle a missing owner (client surfaces a clear error for - // cross-repo PRs — same-repo PRs shouldn't see null in practice). - headRepositoryOwner: z.object({ login: z.string() }).nullable(), - isCrossRepository: z.boolean(), - isDraft: z.boolean(), - author: z.object({ login: z.string() }).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), + getContext, + searchBranches, + generateBranchName, + getProgress, + create, + checkout, + adopt, + searchGitHubIssues, + searchPullRequests, + getGitHubIssueContent, + getGitHubPullRequestContent, }); diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 26f292c8ea9..1f17a1f05e5 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -2,7 +2,7 @@ import { dbWs } from "@superset/db/client"; import { v2Hosts, v2Projects, v2Workspaces } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { jwtProcedure, protectedProcedure } from "../../trpc"; import { requireActiveOrgId } from "../utils/active-org"; @@ -184,47 +184,30 @@ export const v2WorkspaceRouter = { return updated; }), - // JWT-authed so host-service can apply AI-generated workspace names - // after create without an end-user session. Optional `expectedCurrentName` - // is folded into the UPDATE's WHERE so a concurrent user edit can't be - // clobbered between check and write. `branch` is optional so the same - // entry point covers the AI rename (name + branch together) and any - // future name-only or branch-only updates. + // JWT-authed rename endpoint called from host-service's AI rename flow. + // `expectedCurrentName` is used as a WHERE guard — if the name has been + // changed by the user between workspace creation and the AI response + // landing, the UPDATE is a no-op (returns current row unchanged) so the + // user-typed title wins. `branch` is set only when git rename succeeded. updateNameFromHost: jwtProcedure .input( - z - .object({ - id: z.string().uuid(), - name: z.string().min(1).optional(), - branch: z.string().min(1).optional(), - expectedCurrentName: z.string().optional(), - }) - .refine((v) => v.name !== undefined || v.branch !== undefined, { - message: "At least one of name or branch must be provided", - }), + z.object({ + id: z.string().uuid(), + name: z.string().min(1).optional(), + branch: z.string().min(1).optional(), + // When provided, the update only applies if the current cloud name + // matches this value. Mismatch = user already renamed → no-op. + expectedCurrentName: z.string().optional(), + }), ) .mutation(async ({ ctx, input }) => { - const conditions = [ - eq(v2Workspaces.id, input.id), - inArray(v2Workspaces.organizationId, ctx.organizationIds), - ]; - if (input.expectedCurrentName !== undefined) { - conditions.push(eq(v2Workspaces.name, input.expectedCurrentName)); - } - const patch: { name?: string; branch?: string } = {}; - if (input.name !== undefined) patch.name = input.name; - if (input.branch !== undefined) patch.branch = input.branch; - const [updated] = await dbWs - .update(v2Workspaces) - .set(patch) - .where(and(...conditions)) - .returning(); - if (updated) return updated; - - // Nothing updated — disambiguate for a useful error. Happy path - // already returned above, so this fetch only runs when id/org/name - // failed to match. const workspace = await dbWs.query.v2Workspaces.findFirst({ + columns: { + id: true, + organizationId: true, + name: true, + branch: true, + }, where: eq(v2Workspaces.id, input.id), }); if (!workspace) { @@ -239,9 +222,40 @@ export const v2WorkspaceRouter = { message: "Not a member of this organization", }); } - // Expected-name mismatch: a user edit landed first. Return the - // current row so host-service can observe the skip. - return workspace; + // Name guard: if the current name no longer matches the expected value, + // a user rename raced ahead — return the current row unchanged. + if ( + input.expectedCurrentName !== undefined && + workspace.name !== input.expectedCurrentName + ) { + return workspace; + } + const data: { name?: string; branch?: string } = {}; + if (input.name !== undefined) data.name = input.name; + if (input.branch !== undefined) data.branch = input.branch; + if (Object.keys(data).length === 0) return workspace; + // Atomic WHERE guard: the find-then-update window above lets another + // transaction (e.g. the user typing a new title) slip a rename in + // before this UPDATE lands. Pushing `expectedCurrentName` into the + // WHERE makes the update conditional at SQL level — if the name + // changed, the UPDATE matches zero rows and we return the current + // state so git/cloud/local stay in lockstep. + const conditions = [eq(v2Workspaces.id, workspace.id)]; + if (input.expectedCurrentName !== undefined) { + conditions.push(eq(v2Workspaces.name, input.expectedCurrentName)); + } + const [updated] = await dbWs + .update(v2Workspaces) + .set(data) + .where(and(...conditions)) + .returning(); + if (!updated) { + // Row still exists but name raced — return the current row so + // `applyAiWorkspaceRename` sees `branch !== deduped` and rolls + // back the git rename. + return workspace; + } + return updated; }), // JWT-authed so host-service can orchestrate the full delete saga