devpush 0.0.0-stage → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/README.md +22 -2
  2. package/agent-release.json +37 -0
  3. package/dist/package/LICENSE.md +21 -0
  4. package/dist/package/migrate.mjs +182 -0
  5. package/dist/package/plugins.mjs +125 -0
  6. package/dist/package/vite.mjs +439 -0
  7. package/package.json +168 -3
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/logo-email.png +0 -0
  11. package/public/robots.txt +3 -0
  12. package/public/social.png +0 -0
  13. package/src/cloudflare-runtime.d.ts +78 -0
  14. package/src/components/about-dialog.tsx +70 -0
  15. package/src/components/copy-button.tsx +74 -0
  16. package/src/components/logo-mark.tsx +20 -0
  17. package/src/components/pending-collection.tsx +103 -0
  18. package/src/components/resource-avatar.tsx +65 -0
  19. package/src/components/route-error-state.tsx +95 -0
  20. package/src/components/section-navigation.tsx +39 -0
  21. package/src/components/settings-layout.tsx +111 -0
  22. package/src/components/storage-capabilities.tsx +11 -0
  23. package/src/components/table-pager.tsx +101 -0
  24. package/src/components/ui/alert-dialog.tsx +180 -0
  25. package/src/components/ui/alert.tsx +78 -0
  26. package/src/components/ui/avatar.tsx +58 -0
  27. package/src/components/ui/badge.tsx +49 -0
  28. package/src/components/ui/button-group.tsx +38 -0
  29. package/src/components/ui/button.tsx +67 -0
  30. package/src/components/ui/card.tsx +82 -0
  31. package/src/components/ui/chart.tsx +307 -0
  32. package/src/components/ui/checkbox.tsx +30 -0
  33. package/src/components/ui/combobox.tsx +256 -0
  34. package/src/components/ui/dialog.tsx +163 -0
  35. package/src/components/ui/dropdown-menu.tsx +110 -0
  36. package/src/components/ui/empty.tsx +65 -0
  37. package/src/components/ui/field.tsx +192 -0
  38. package/src/components/ui/input-group.tsx +118 -0
  39. package/src/components/ui/input-otp.tsx +70 -0
  40. package/src/components/ui/input.tsx +19 -0
  41. package/src/components/ui/label.tsx +24 -0
  42. package/src/components/ui/pagination.tsx +34 -0
  43. package/src/components/ui/popover.tsx +86 -0
  44. package/src/components/ui/select.tsx +145 -0
  45. package/src/components/ui/skeleton.tsx +19 -0
  46. package/src/components/ui/sonner.tsx +47 -0
  47. package/src/components/ui/switch.tsx +31 -0
  48. package/src/components/ui/table.tsx +81 -0
  49. package/src/components/ui/textarea.tsx +18 -0
  50. package/src/components/ui/tooltip.tsx +55 -0
  51. package/src/features/admin/admin-settings.tsx +1048 -0
  52. package/src/features/auth/current-user-context-query.ts +11 -0
  53. package/src/features/auth/login-form.tsx +442 -0
  54. package/src/features/auth/sign-out-menu-item.tsx +42 -0
  55. package/src/features/deployments/deployment-actions.tsx +336 -0
  56. package/src/features/deployments/deployment-alias-badge.tsx +35 -0
  57. package/src/features/deployments/deployment-detail.tsx +596 -0
  58. package/src/features/deployments/deployment-filters.ts +66 -0
  59. package/src/features/deployments/deployment-list.tsx +465 -0
  60. package/src/features/deployments/deployment-visit-links.tsx +104 -0
  61. package/src/features/deployments/deployments-page.tsx +376 -0
  62. package/src/features/logs/log-scroll-area.tsx +69 -0
  63. package/src/features/notifications/email-preferences.tsx +232 -0
  64. package/src/features/onboarding/profile-onboarding.tsx +162 -0
  65. package/src/features/plugins/contributions.tsx +118 -0
  66. package/src/features/projects/backup-job-logs.tsx +143 -0
  67. package/src/features/projects/backup-policy-delete-dialog.tsx +82 -0
  68. package/src/features/projects/backup-restore-actions.tsx +573 -0
  69. package/src/features/projects/backup-status-icon.tsx +33 -0
  70. package/src/features/projects/backup-target-list.tsx +222 -0
  71. package/src/features/projects/backup-timestamp.tsx +32 -0
  72. package/src/features/projects/backup-volume-picker.tsx +158 -0
  73. package/src/features/projects/compose-service-input.tsx +64 -0
  74. package/src/features/projects/deploy-readiness.ts +28 -0
  75. package/src/features/projects/environment-dot.tsx +91 -0
  76. package/src/features/projects/log-keyword-filter.tsx +48 -0
  77. package/src/features/projects/manual-deploy-dialog.tsx +563 -0
  78. package/src/features/projects/pending-section.ts +26 -0
  79. package/src/features/projects/project-backup-details-pending.tsx +305 -0
  80. package/src/features/projects/project-backup-details.tsx +936 -0
  81. package/src/features/projects/project-backup-policy.tsx +644 -0
  82. package/src/features/projects/project-backup-utils.ts +190 -0
  83. package/src/features/projects/project-backups-pending.tsx +136 -0
  84. package/src/features/projects/project-backups.tsx +876 -0
  85. package/src/features/projects/project-environment-settings.tsx +1128 -0
  86. package/src/features/projects/project-heading.tsx +68 -0
  87. package/src/features/projects/project-layout.tsx +188 -0
  88. package/src/features/projects/project-logs.tsx +461 -0
  89. package/src/features/projects/project-overview.tsx +280 -0
  90. package/src/features/projects/project-route-pending.tsx +1064 -0
  91. package/src/features/projects/project-routing-settings.tsx +663 -0
  92. package/src/features/projects/project-settings-section.tsx +64 -0
  93. package/src/features/projects/project-settings-utils.ts +101 -0
  94. package/src/features/projects/project-settings.tsx +1472 -0
  95. package/src/features/projects/repository-picker-dialog.tsx +404 -0
  96. package/src/features/repositories/new-project-dialog-launcher.tsx +44 -0
  97. package/src/features/repositories/new-project-dialog.tsx +1204 -0
  98. package/src/features/repositories/new-project-pending.tsx +28 -0
  99. package/src/features/repositories/repository-list-pending.tsx +59 -0
  100. package/src/features/servers/server-availability-alert.tsx +44 -0
  101. package/src/features/servers/server-availability.ts +29 -0
  102. package/src/features/servers/server-avatar.tsx +52 -0
  103. package/src/features/servers/server-detail-pending.tsx +315 -0
  104. package/src/features/servers/server-detail.tsx +855 -0
  105. package/src/features/servers/server-metric-chart-pending.tsx +38 -0
  106. package/src/features/servers/server-metric-chart.tsx +182 -0
  107. package/src/features/servers/server-metric-format.ts +50 -0
  108. package/src/features/servers/server-metrics-pending.tsx +75 -0
  109. package/src/features/servers/server-metrics.tsx +136 -0
  110. package/src/features/servers/team-servers.tsx +432 -0
  111. package/src/features/settings/settings-route-pending.tsx +406 -0
  112. package/src/features/teams/backup-storage-settings.tsx +483 -0
  113. package/src/features/teams/navigation.ts +51 -0
  114. package/src/features/teams/new-team-dialog.tsx +132 -0
  115. package/src/features/teams/pending-section.ts +19 -0
  116. package/src/features/teams/team-header.tsx +1286 -0
  117. package/src/features/teams/team-heading.tsx +57 -0
  118. package/src/features/teams/team-home.tsx +391 -0
  119. package/src/features/teams/team-invite.tsx +155 -0
  120. package/src/features/teams/team-route-pending.tsx +505 -0
  121. package/src/features/teams/team-settings.tsx +1555 -0
  122. package/src/features/teams/use-workspace-updates.ts +126 -0
  123. package/src/features/users/api-keys-settings.tsx +544 -0
  124. package/src/features/users/api-scope-selection.tsx +269 -0
  125. package/src/features/users/connected-apps-settings.tsx +449 -0
  126. package/src/features/users/mcp-consent.tsx +220 -0
  127. package/src/features/users/profile-picture.tsx +171 -0
  128. package/src/features/users/user-settings.tsx +1064 -0
  129. package/src/lib/account-settings-schema.ts +78 -0
  130. package/src/lib/action-feedback.ts +81 -0
  131. package/src/lib/admin-search-schema.ts +8 -0
  132. package/src/lib/agent-release.d.mts +21 -0
  133. package/src/lib/agent-release.mjs +90 -0
  134. package/src/lib/allowlist-schema.ts +79 -0
  135. package/src/lib/api-key-schema.ts +176 -0
  136. package/src/lib/auth/client.ts +20 -0
  137. package/src/lib/avatar-schema.ts +11 -0
  138. package/src/lib/backup-cron.ts +118 -0
  139. package/src/lib/backup-database.ts +41 -0
  140. package/src/lib/backup-feature.ts +2 -0
  141. package/src/lib/backup-paths.ts +7 -0
  142. package/src/lib/backup-policy-schema.ts +85 -0
  143. package/src/lib/backup-resource-schema.ts +12 -0
  144. package/src/lib/backup-restore-schema.ts +15 -0
  145. package/src/lib/backup-storage-schema.ts +55 -0
  146. package/src/lib/deferred-module.ts +38 -0
  147. package/src/lib/deployment-logs-query.ts +63 -0
  148. package/src/lib/display-name.ts +7 -0
  149. package/src/lib/error-message.ts +119 -0
  150. package/src/lib/form-errors.ts +10 -0
  151. package/src/lib/internal-redirect.ts +25 -0
  152. package/src/lib/manual-deploy-options-query.ts +21 -0
  153. package/src/lib/notification-preferences.ts +43 -0
  154. package/src/lib/onboarding.ts +11 -0
  155. package/src/lib/operation-logs-query.ts +51 -0
  156. package/src/lib/page-head.ts +14 -0
  157. package/src/lib/project-context-query.ts +257 -0
  158. package/src/lib/project-creation-schema.ts +64 -0
  159. package/src/lib/project-env-schema.ts +23 -0
  160. package/src/lib/project-environments-schema.ts +56 -0
  161. package/src/lib/project-routing-schema.ts +94 -0
  162. package/src/lib/project-runtime-config.ts +131 -0
  163. package/src/lib/project-settings-schema.ts +75 -0
  164. package/src/lib/public-api-schema.ts +128 -0
  165. package/src/lib/public-error.ts +337 -0
  166. package/src/lib/query.ts +13 -0
  167. package/src/lib/repository-selection-query.ts +11 -0
  168. package/src/lib/resource-cache.ts +30 -0
  169. package/src/lib/runtime-id.ts +19 -0
  170. package/src/lib/search.ts +14 -0
  171. package/src/lib/server-context-query.ts +43 -0
  172. package/src/lib/server-name-schema.ts +11 -0
  173. package/src/lib/settings-conflict.ts +45 -0
  174. package/src/lib/settings-version.ts +7 -0
  175. package/src/lib/team-context-query.ts +126 -0
  176. package/src/lib/team-context-schema.ts +8 -0
  177. package/src/lib/team-forms-schema.ts +45 -0
  178. package/src/lib/team-role.ts +13 -0
  179. package/src/lib/team-settings-schema.ts +48 -0
  180. package/src/lib/urls.ts +3 -0
  181. package/src/lib/use-avatar-input.ts +61 -0
  182. package/src/lib/use-refresh-error.ts +18 -0
  183. package/src/lib/use-settings-version.ts +23 -0
  184. package/src/lib/utils.ts +6 -0
  185. package/src/lib/validation.ts +5 -0
  186. package/src/lib/workspace-updates.ts +53 -0
  187. package/src/package/config.ts +10 -0
  188. package/src/package/env.mjs +54 -0
  189. package/src/package/migrate-api.d.ts +5 -0
  190. package/src/package/migrate.ts +56 -0
  191. package/src/package/plugins.ts +141 -0
  192. package/src/package/sqlite.mjs +2 -0
  193. package/src/package/vite-api.d.ts +5 -0
  194. package/src/package/vite.ts +324 -0
  195. package/src/package/watch-guard.cjs +26 -0
  196. package/src/plugins/client.ts +57 -0
  197. package/src/plugins/contract.ts +188 -0
  198. package/src/plugins/default.server.ts +4 -0
  199. package/src/plugins/default.ui.ts +4 -0
  200. package/src/plugins/operations.ts +45 -0
  201. package/src/plugins/server.ts +197 -0
  202. package/src/plugins/version.ts +1 -0
  203. package/src/routeTree.gen.ts +1275 -0
  204. package/src/router.tsx +46 -0
  205. package/src/routes/$teamSlug.index.tsx +53 -0
  206. package/src/routes/$teamSlug.new-project.tsx +13 -0
  207. package/src/routes/$teamSlug.projects.$projectName.backups.$backupId.tsx +40 -0
  208. package/src/routes/$teamSlug.projects.$projectName.backups.tsx +82 -0
  209. package/src/routes/$teamSlug.projects.$projectName.deployments.$deploymentId.tsx +52 -0
  210. package/src/routes/$teamSlug.projects.$projectName.deployments.tsx +86 -0
  211. package/src/routes/$teamSlug.projects.$projectName.logs.tsx +55 -0
  212. package/src/routes/$teamSlug.projects.$projectName.settings.tsx +51 -0
  213. package/src/routes/$teamSlug.projects.$projectName.tsx +106 -0
  214. package/src/routes/$teamSlug.projects.tsx +40 -0
  215. package/src/routes/$teamSlug.servers.$serverId.tsx +31 -0
  216. package/src/routes/$teamSlug.servers.index.tsx +59 -0
  217. package/src/routes/$teamSlug.servers.tsx +7 -0
  218. package/src/routes/$teamSlug.settings.tsx +56 -0
  219. package/src/routes/$teamSlug.tsx +86 -0
  220. package/src/routes/[.]well-known.oauth-authorization-server.api.auth.ts +13 -0
  221. package/src/routes/[.]well-known.oauth-protected-resource.mcp.ts +6 -0
  222. package/src/routes/__root.tsx +155 -0
  223. package/src/routes/admin.tsx +55 -0
  224. package/src/routes/api/agents/certificates.ts +21 -0
  225. package/src/routes/api/agents/credentials/rotate.ts +22 -0
  226. package/src/routes/api/agents/heartbeat.ts +42 -0
  227. package/src/routes/api/agents/job-logs.ts +22 -0
  228. package/src/routes/api/agents/jobs/complete.ts +22 -0
  229. package/src/routes/api/agents/jobs/next.ts +29 -0
  230. package/src/routes/api/agents/jobs/progress.ts +32 -0
  231. package/src/routes/api/agents/jobs/status.ts +22 -0
  232. package/src/routes/api/agents/register.ts +45 -0
  233. package/src/routes/api/agents/runtime-logs.ts +22 -0
  234. package/src/routes/api/agents/switch.ts +25 -0
  235. package/src/routes/api/auth/$.ts +38 -0
  236. package/src/routes/api/cron/backups.ts +33 -0
  237. package/src/routes/api/cron/maintenance.ts +30 -0
  238. package/src/routes/api/github/install.callback.ts +51 -0
  239. package/src/routes/api/github/install.ts +41 -0
  240. package/src/routes/api/github/webhook.ts +11 -0
  241. package/src/routes/api/health.ts +19 -0
  242. package/src/routes/api/install/agent.$token.ts +50 -0
  243. package/src/routes/api/install/agent.binary.ts +83 -0
  244. package/src/routes/api/install/agent.checksum.ts +45 -0
  245. package/src/routes/api/install/agent.ts +26 -0
  246. package/src/routes/api/plugins/$.ts +11 -0
  247. package/src/routes/api/v1/$.ts +15 -0
  248. package/src/routes/api/workspaces/updates.ts +8 -0
  249. package/src/routes/auth.login.tsx +50 -0
  250. package/src/routes/deployments.$deploymentId.unavailable.tsx +21 -0
  251. package/src/routes/index.tsx +23 -0
  252. package/src/routes/invites.$token.tsx +35 -0
  253. package/src/routes/mcp.ts +13 -0
  254. package/src/routes/oauth.consent.tsx +54 -0
  255. package/src/routes/onboarding.tsx +37 -0
  256. package/src/routes/onboarding.workspace.tsx +44 -0
  257. package/src/routes/settings.tsx +30 -0
  258. package/src/routes/uploads/avatars/$fileName.ts +44 -0
  259. package/src/server/admin/access.ts +67 -0
  260. package/src/server/admin/functions.ts +122 -0
  261. package/src/server/admin/service.ts +260 -0
  262. package/src/server/agents/backup-state.ts +210 -0
  263. package/src/server/agents/certificates.ts +96 -0
  264. package/src/server/agents/claims.ts +116 -0
  265. package/src/server/agents/cleanup.ts +52 -0
  266. package/src/server/agents/compatibility.ts +116 -0
  267. package/src/server/agents/completion.ts +83 -0
  268. package/src/server/agents/container-cleanup-state.ts +51 -0
  269. package/src/server/agents/deployment-state.ts +111 -0
  270. package/src/server/agents/job-payloads.ts +159 -0
  271. package/src/server/agents/jobs.ts +1293 -0
  272. package/src/server/agents/protocol.ts +400 -0
  273. package/src/server/agents/reconciliation.ts +43 -0
  274. package/src/server/agents/runtime-logs.ts +337 -0
  275. package/src/server/agents/runtime-state.ts +125 -0
  276. package/src/server/api/admission.ts +12 -0
  277. package/src/server/api/authorization.ts +115 -0
  278. package/src/server/api/credentials.ts +569 -0
  279. package/src/server/api/cursors.ts +72 -0
  280. package/src/server/api/errors.ts +24 -0
  281. package/src/server/api/functions.ts +60 -0
  282. package/src/server/api/handler.ts +274 -0
  283. package/src/server/api/mutation-operations.ts +913 -0
  284. package/src/server/api/operation-context.ts +139 -0
  285. package/src/server/api/operation-contract.ts +1099 -0
  286. package/src/server/api/operations.ts +64 -0
  287. package/src/server/api/read-operations.ts +891 -0
  288. package/src/server/api/retention.ts +33 -0
  289. package/src/server/auth/access.ts +109 -0
  290. package/src/server/auth/auth-methods.ts +28 -0
  291. package/src/server/auth/better-auth.ts +518 -0
  292. package/src/server/auth/context.ts +28 -0
  293. package/src/server/auth/email-change.ts +47 -0
  294. package/src/server/auth/email-delivery.ts +51 -0
  295. package/src/server/auth/provider-identity.ts +19 -0
  296. package/src/server/auth/provider-tokens.ts +27 -0
  297. package/src/server/auth/session.server.ts +19 -0
  298. package/src/server/backups/database-commands.ts +40 -0
  299. package/src/server/backups/destination.ts +12 -0
  300. package/src/server/backups/feature.ts +6 -0
  301. package/src/server/backups/policy-validation.ts +51 -0
  302. package/src/server/backups/queue-state.ts +137 -0
  303. package/src/server/backups/service.ts +918 -0
  304. package/src/server/backups/storage-functions.ts +42 -0
  305. package/src/server/backups/storage-state.ts +67 -0
  306. package/src/server/backups/storage.ts +237 -0
  307. package/src/server/config.ts +274 -0
  308. package/src/server/db/atomic.ts +16 -0
  309. package/src/server/db/d1-connection.ts +83 -0
  310. package/src/server/db/errors.ts +17 -0
  311. package/src/server/db/index.cloudflare.ts +15 -0
  312. package/src/server/db/index.ts +35 -0
  313. package/src/server/db/libsql-connection.ts +72 -0
  314. package/src/server/db/log-schema.ts +102 -0
  315. package/src/server/db/migrate-libsql.ts +29 -0
  316. package/src/server/db/migrate-sqlite.ts +47 -0
  317. package/src/server/db/migration-history.ts +12 -0
  318. package/src/server/db/migrations-logs/0000_initial.sql +48 -0
  319. package/src/server/db/migrations-logs/0001_runtime_log_order.sql +2 -0
  320. package/src/server/db/migrations-logs/meta/0000_snapshot.json +327 -0
  321. package/src/server/db/migrations-logs/meta/0001_snapshot.json +329 -0
  322. package/src/server/db/migrations-logs/meta/_journal.json +20 -0
  323. package/src/server/db/migrations-sqlite/0000_initial.sql +894 -0
  324. package/src/server/db/migrations-sqlite/0001_agent_indexes.sql +5 -0
  325. package/src/server/db/migrations-sqlite/meta/0000_snapshot.json +5816 -0
  326. package/src/server/db/migrations-sqlite/meta/0001_snapshot.json +5841 -0
  327. package/src/server/db/migrations-sqlite/meta/_journal.json +20 -0
  328. package/src/server/db/oauth-schema.ts +216 -0
  329. package/src/server/db/remote-config.ts +47 -0
  330. package/src/server/db/schema.ts +1218 -0
  331. package/src/server/db/sqlite-connection.ts +94 -0
  332. package/src/server/db/sqlite-files.ts +27 -0
  333. package/src/server/db/sqlite-params.ts +19 -0
  334. package/src/server/db/worker-connection.ts +47 -0
  335. package/src/server/deleted-resources.ts +133 -0
  336. package/src/server/deletion-recovery.ts +272 -0
  337. package/src/server/email/layout.tsx +85 -0
  338. package/src/server/email/notification-template.tsx +62 -0
  339. package/src/server/email/otp-template.tsx +48 -0
  340. package/src/server/email/service.ts +251 -0
  341. package/src/server/email/team-invite-template.tsx +59 -0
  342. package/src/server/email/theme.ts +10 -0
  343. package/src/server/github/http.ts +14 -0
  344. package/src/server/github/install-state.ts +48 -0
  345. package/src/server/github/service.ts +831 -0
  346. package/src/server/github/webhook-handler.ts +198 -0
  347. package/src/server/github/webhooks.ts +205 -0
  348. package/src/server/http/auth.ts +11 -0
  349. package/src/server/http/error-diagnostics.ts +66 -0
  350. package/src/server/http/plugin-request.ts +15 -0
  351. package/src/server/http/proxy-request.ts +53 -0
  352. package/src/server/http/public-errors.ts +48 -0
  353. package/src/server/http/rate-limit.ts +59 -0
  354. package/src/server/http/request-body.ts +54 -0
  355. package/src/server/http/request-ip.ts +54 -0
  356. package/src/server/http/security-headers.ts +36 -0
  357. package/src/server/install/agent-binary.ts +230 -0
  358. package/src/server/install/agent-script.ts +565 -0
  359. package/src/server/install/release-binary.ts +65 -0
  360. package/src/server/logs/budget.ts +72 -0
  361. package/src/server/logs/cleanup.ts +64 -0
  362. package/src/server/logs/deployments.ts +98 -0
  363. package/src/server/logs/operations.ts +145 -0
  364. package/src/server/logs/retention.ts +39 -0
  365. package/src/server/maintenance.ts +61 -0
  366. package/src/server/mcp/connections.ts +358 -0
  367. package/src/server/mcp/functions.ts +63 -0
  368. package/src/server/mcp/handler.ts +295 -0
  369. package/src/server/mcp/oauth-discovery.cloudflare.ts +6 -0
  370. package/src/server/mcp/oauth-discovery.ts +8 -0
  371. package/src/server/mcp/oauth-provider.ts +220 -0
  372. package/src/server/mcp/urls.ts +20 -0
  373. package/src/server/notifications/functions.ts +51 -0
  374. package/src/server/notifications/observe.ts +303 -0
  375. package/src/server/notifications/outbox.ts +335 -0
  376. package/src/server/notifications/preferences.ts +131 -0
  377. package/src/server/notifications/query.ts +16 -0
  378. package/src/server/plugins/scope.ts +17 -0
  379. package/src/server/plugins/server-context.ts +157 -0
  380. package/src/server/plugins/services.server.ts +10 -0
  381. package/src/server/plugins/workspace-access.ts +74 -0
  382. package/src/server/projects/backup-policy-state.ts +68 -0
  383. package/src/server/projects/backups.ts +219 -0
  384. package/src/server/projects/compose-metadata.ts +174 -0
  385. package/src/server/projects/configuration-state.ts +90 -0
  386. package/src/server/projects/context.ts +909 -0
  387. package/src/server/projects/creation-state.ts +46 -0
  388. package/src/server/projects/deletion-state.ts +69 -0
  389. package/src/server/projects/deployment-config.ts +139 -0
  390. package/src/server/projects/deployment-logs.ts +97 -0
  391. package/src/server/projects/deployments.ts +1123 -0
  392. package/src/server/projects/domain-state.ts +71 -0
  393. package/src/server/projects/environment-branches.ts +75 -0
  394. package/src/server/projects/environment-state.ts +158 -0
  395. package/src/server/projects/environments.ts +424 -0
  396. package/src/server/projects/functions.ts +748 -0
  397. package/src/server/projects/generated-hostname.ts +77 -0
  398. package/src/server/projects/hostnames.ts +244 -0
  399. package/src/server/projects/mappers.ts +457 -0
  400. package/src/server/projects/names.ts +41 -0
  401. package/src/server/projects/project-env-vars.ts +28 -0
  402. package/src/server/projects/queue-state.ts +118 -0
  403. package/src/server/projects/repository.ts +368 -0
  404. package/src/server/projects/route-state.ts +37 -0
  405. package/src/server/projects/runtime-logs.ts +97 -0
  406. package/src/server/projects/settings-context.ts +58 -0
  407. package/src/server/projects/settings.ts +738 -0
  408. package/src/server/projects/types.ts +355 -0
  409. package/src/server/realtime/agent-events.ts +32 -0
  410. package/src/server/realtime/node-broadcaster.ts +71 -0
  411. package/src/server/realtime/node-handler.ts +55 -0
  412. package/src/server/realtime/service.ts +125 -0
  413. package/src/server/realtime/workspace-object.cloudflare.ts +62 -0
  414. package/src/server/realtime/workspace-object.ts +3 -0
  415. package/src/server/resources/color.ts +13 -0
  416. package/src/server/runtime/background.ts +21 -0
  417. package/src/server/runtime/node-asset-cache-plugin.ts +12 -0
  418. package/src/server/runtime/node-scheduler-plugin.ts +17 -0
  419. package/src/server/runtime/node-scheduler.ts +51 -0
  420. package/src/server/runtime/platform-env.cloudflare.ts +17 -0
  421. package/src/server/runtime/platform-env.ts +19 -0
  422. package/src/server/runtime/scheduled-task-error.ts +9 -0
  423. package/src/server/scheduled-tasks.ts +28 -0
  424. package/src/server/secrets/crypto.ts +56 -0
  425. package/src/server/servers/functions.ts +233 -0
  426. package/src/server/servers/lifecycle.ts +122 -0
  427. package/src/server/servers/metric-history-schema.ts +24 -0
  428. package/src/server/servers/metric-history.ts +55 -0
  429. package/src/server/servers/monitoring.ts +592 -0
  430. package/src/server/servers/service.ts +1211 -0
  431. package/src/server/servers/status.ts +32 -0
  432. package/src/server/servers/switch.ts +96 -0
  433. package/src/server/settings-version.ts +43 -0
  434. package/src/server/storage/capabilities.ts +6 -0
  435. package/src/server/storage/local-object-store.cloudflare.ts +7 -0
  436. package/src/server/storage/local-object-store.ts +50 -0
  437. package/src/server/storage/object-store.ts +160 -0
  438. package/src/server/teams/creation.ts +51 -0
  439. package/src/server/teams/delete-policy.ts +13 -0
  440. package/src/server/teams/deletion-state.ts +80 -0
  441. package/src/server/teams/functions.ts +324 -0
  442. package/src/server/teams/invitations.ts +38 -0
  443. package/src/server/teams/membership.ts +86 -0
  444. package/src/server/teams/queries.ts +261 -0
  445. package/src/server/teams/service.ts +1127 -0
  446. package/src/server/teams/types.ts +101 -0
  447. package/src/server/uploads/avatar.ts +133 -0
  448. package/src/server/users/deletion-state.ts +93 -0
  449. package/src/server/users/functions.ts +160 -0
  450. package/src/server/users/onboarding.ts +17 -0
  451. package/src/server/users/service.ts +644 -0
  452. package/src/server.ts +55 -0
  453. package/src/styles/shadcn.css +653 -0
  454. package/src/styles/theme.css +76 -0
  455. package/src/styles.css +177 -0
  456. package/wrangler.template.jsonc +37 -0
@@ -0,0 +1,1293 @@
1
+ import {
2
+ withWorkspaceAccess,
3
+ isWorkspacePolicyRefusal,
4
+ } from '#devpush/server/plugins/workspace-access'
5
+ import type { BackupDestination } from '#devpush/server/backups/service'
6
+ import { backupDestinationSnapshot } from '#devpush/server/backups/destination'
7
+ import { refreshBackupCredentials } from '#devpush/server/backups/storage'
8
+ import { getPublicErrorMessage } from '#devpush/lib/public-error'
9
+ import { config as serverConfig } from '#devpush/server/config'
10
+ import {
11
+ recoverStaleCleanupJobs,
12
+ finishContainerCleanup,
13
+ } from './container-cleanup-state'
14
+ import {
15
+ failStaleAgentJob,
16
+ failClaimedAgentJob,
17
+ finishBackup,
18
+ finishBackupCleanup,
19
+ } from './backup-state'
20
+ import { claimBackupCandidate, claimCleanup, claimDeployment } from './claims'
21
+ import { failRunningDeployments, finishDeployment } from './deployment-state'
22
+ import { isAbsolute, relative } from 'node:path/posix'
23
+
24
+ import { and, asc, eq, inArray, lt, lte, or, isNull, sql } from 'drizzle-orm'
25
+
26
+ import { db } from '#devpush/server/db'
27
+ import { notifyServer } from '#devpush/server/realtime/service'
28
+ import {
29
+ appendDeploymentLogs,
30
+ nextDeploymentLogSeq,
31
+ } from '#devpush/server/logs/deployments'
32
+ import {
33
+ agentJobs,
34
+ aliases,
35
+ backupRuns,
36
+ deployments,
37
+ environments,
38
+ projects,
39
+ servers,
40
+ } from '#devpush/server/db/schema'
41
+ import { createInstallationAccessToken } from '#devpush/server/github/service'
42
+ import { queueBackupRetentionCleanupJobs } from '#devpush/server/backups/service'
43
+ import {
44
+ decryptDeploymentEnvVars,
45
+ parseDeploymentConfig,
46
+ toDeploymentCommitMeta,
47
+ } from '#devpush/server/projects/deployment-config'
48
+ import type { DeploymentConfig } from '#devpush/server/projects/deployment-config'
49
+ import { requireAgentServer } from '#devpush/server/servers/service'
50
+ import { AgentRequestError } from './protocol'
51
+ import { decryptSecret } from '#devpush/server/secrets/crypto'
52
+ import {
53
+ currentAgentProtocolVersion,
54
+ hasAgentCapability,
55
+ } from './compatibility'
56
+ import {
57
+ parseBackupCleanupPayload,
58
+ parseBackupPayload,
59
+ parseDeprovisionPayload,
60
+ parseRestorePayload,
61
+ } from './job-payloads'
62
+ import type {
63
+ AgentBackupCleanupCompletion,
64
+ AgentBackupCleanupJob,
65
+ AgentBackupCompletion,
66
+ AgentBackupJob,
67
+ AgentCleanupCompletion,
68
+ AgentCleanupJob,
69
+ AgentDeployJob,
70
+ AgentJob,
71
+ AgentJobCompletion,
72
+ AgentJobProgress,
73
+ AgentJobStatus,
74
+ AgentRestoreCompletion,
75
+ AgentRestoreJob,
76
+ AgentEnvVar,
77
+ } from './protocol'
78
+
79
+ type InstallationAccessTokenFactory = typeof createInstallationAccessToken
80
+
81
+ export async function claimNextAgentJob({
82
+ serverId,
83
+ token,
84
+ protocolVersion,
85
+ capabilities,
86
+ createAccessToken = createInstallationAccessToken,
87
+ }: {
88
+ serverId: string
89
+ token: string
90
+ protocolVersion?: number
91
+ capabilities?: Array<string>
92
+ createAccessToken?: InstallationAccessTokenFactory
93
+ }): Promise<AgentJob | null> {
94
+ const server = await requireAgentServer({ serverId, token })
95
+ if (server.status !== 'deleted')
96
+ await recoverStaleCleanupJobs(db, { serverId, now: new Date() })
97
+ await Promise.all([
98
+ failStaleRunningDeployments(serverId),
99
+ failStaleAgentJobs(serverId),
100
+ ])
101
+
102
+ if (protocolVersion !== currentAgentProtocolVersion) {
103
+ return null
104
+ }
105
+
106
+ const cleanupJob = hasAgentCapability(capabilities, 'jobs.compose.cleanup.v2')
107
+ ? await claimNextCleanupJob({ serverId })
108
+ : null
109
+
110
+ if (cleanupJob) {
111
+ return cleanupJob
112
+ }
113
+
114
+ if (server.status === 'deleted') {
115
+ return hasAgentCapability(capabilities, 'jobs.compose.deploy.v1')
116
+ ? claimNextDeploymentJob({
117
+ serverId,
118
+ token,
119
+ createAccessToken,
120
+ skipAgentAuth: true,
121
+ })
122
+ : null
123
+ }
124
+
125
+ const backupTypes = [
126
+ ...(hasAgentCapability(capabilities, 'jobs.backup.v1')
127
+ ? (['backup'] as const)
128
+ : []),
129
+ ...(hasAgentCapability(capabilities, 'jobs.backup.cleanup.v1')
130
+ ? (['backup_cleanup'] as const)
131
+ : []),
132
+ ...(hasAgentCapability(capabilities, 'jobs.restore.v1')
133
+ ? (['restore'] as const)
134
+ : []),
135
+ ]
136
+ const backupJob =
137
+ backupTypes.length > 0
138
+ ? await claimNextBackupJob({
139
+ serverId,
140
+ types: backupTypes,
141
+ })
142
+ : null
143
+
144
+ if (backupJob) {
145
+ return backupJob
146
+ }
147
+
148
+ return hasAgentCapability(capabilities, 'jobs.compose.deploy.v1')
149
+ ? claimNextDeploymentJob({
150
+ serverId,
151
+ token,
152
+ createAccessToken,
153
+ skipAgentAuth: true,
154
+ })
155
+ : null
156
+ }
157
+
158
+ async function claimNextBackupJob({
159
+ serverId,
160
+ types,
161
+ }: {
162
+ serverId: string
163
+ types: Array<'backup' | 'restore' | 'backup_cleanup'>
164
+ }): Promise<AgentBackupJob | AgentBackupCleanupJob | AgentRestoreJob | null> {
165
+ const [owner] = await db
166
+ .select({ teamId: servers.teamId })
167
+ .from(servers)
168
+ .where(eq(servers.id, serverId))
169
+ .limit(1)
170
+ if (!owner) return null
171
+ const teamId = owner.teamId
172
+ let claimed: typeof agentJobs.$inferSelect | null = null
173
+ // A lost compare-and-set means another request consumed this candidate.
174
+ // Read the next one immediately instead of delaying it until another poll.
175
+ for (let attempt = 0; !claimed && attempt < 8; attempt++) {
176
+ const now = new Date()
177
+ const [snapshot] = await db
178
+ .select({
179
+ job: agentJobs,
180
+ payloadSnapshot: sql<string>`${agentJobs.payload}`,
181
+ })
182
+ .from(agentJobs)
183
+ .where(
184
+ and(
185
+ eq(agentJobs.serverId, serverId),
186
+ inArray(agentJobs.type, types),
187
+ eq(agentJobs.status, 'queued'),
188
+ lte(agentJobs.availableAt, now),
189
+ ),
190
+ )
191
+ .orderBy(asc(agentJobs.createdAt), asc(agentJobs.id))
192
+ .limit(1)
193
+ if (!snapshot) return null
194
+ const candidate = snapshot.job
195
+ const payload =
196
+ candidate.type === 'backup' ? parseBackupPayload(candidate.payload) : null
197
+ try {
198
+ const claim = () =>
199
+ claimBackupCandidate(db, {
200
+ job: candidate,
201
+ payloadSnapshot: snapshot.payloadSnapshot,
202
+ backupRunId: payload?.backupRunId ?? null,
203
+ now,
204
+ })
205
+ claimed =
206
+ candidate.type === 'backup_cleanup'
207
+ ? await claim()
208
+ : await withWorkspaceAccess(
209
+ teamId,
210
+ candidate.type === 'restore' ? 'restore.claim' : 'backup.claim',
211
+ claim,
212
+ { kind: 'system', source: 'agent' },
213
+ )
214
+ } catch (error) {
215
+ if (!isWorkspacePolicyRefusal(error)) throw error
216
+ // Skip productive work but continue looking for retention cleanup.
217
+ return types.includes('backup_cleanup')
218
+ ? claimNextBackupJob({ serverId, types: ['backup_cleanup'] })
219
+ : null
220
+ }
221
+ }
222
+
223
+ if (!claimed) return null
224
+
225
+ try {
226
+ if (claimed.type === 'backup') {
227
+ const payload = parseBackupPayload(claimed.payload)
228
+
229
+ if (!payload) {
230
+ await failCleanupJob(claimed.id, 'Backup job payload is invalid.')
231
+ return null
232
+ }
233
+
234
+ return {
235
+ type: 'backup',
236
+ id: claimed.id,
237
+ backup: await hydrateBackupPayload(payload, teamId),
238
+ }
239
+ }
240
+
241
+ if (claimed.type === 'backup_cleanup') {
242
+ const payload = parseBackupCleanupPayload(claimed.payload)
243
+
244
+ if (!payload) {
245
+ await failCleanupJob(
246
+ claimed.id,
247
+ 'Backup cleanup job payload is invalid.',
248
+ )
249
+ return null
250
+ }
251
+
252
+ return {
253
+ type: 'backup_cleanup',
254
+ id: claimed.id,
255
+ cleanup: await hydrateBackupCleanupPayload(payload, teamId),
256
+ }
257
+ }
258
+
259
+ if (claimed.type === 'restore') {
260
+ const payload = parseRestorePayload(claimed.payload)
261
+
262
+ if (!payload) {
263
+ await failCleanupJob(claimed.id, 'Restore job payload is invalid.')
264
+ return null
265
+ }
266
+
267
+ return {
268
+ type: 'restore',
269
+ id: claimed.id,
270
+ restore: await hydrateRestorePayload(payload, teamId),
271
+ }
272
+ }
273
+
274
+ return null
275
+ } catch (error) {
276
+ const payload =
277
+ claimed.type === 'backup' ? parseBackupPayload(claimed.payload) : null
278
+ const cleanup =
279
+ claimed.type === 'backup_cleanup'
280
+ ? parseBackupCleanupPayload(claimed.payload)
281
+ : null
282
+ await failClaimedAgentJob(db, {
283
+ job: claimed,
284
+ now: new Date(),
285
+ message: getPublicErrorMessage(
286
+ error,
287
+ 'Backup storage credentials could not be loaded.',
288
+ ),
289
+ backupRunId: payload?.backupRunId ?? null,
290
+ cleanupRunIds: cleanup?.backupRunIds ?? [],
291
+ })
292
+ return null
293
+ }
294
+ }
295
+
296
+ async function hydrateBackupPayload(
297
+ payload: NonNullable<ReturnType<typeof parseBackupPayload>>,
298
+ teamId: string,
299
+ ) {
300
+ return {
301
+ ...payload,
302
+ destination: await hydrateBackupDestination(
303
+ teamId,
304
+ payload.destination,
305
+ payload.retention.remote.enabled,
306
+ ),
307
+ }
308
+ }
309
+ async function hydrateBackupCleanupPayload(
310
+ payload: NonNullable<ReturnType<typeof parseBackupCleanupPayload>>,
311
+ teamId: string,
312
+ ) {
313
+ return {
314
+ ...payload,
315
+ ...(payload.destination
316
+ ? {
317
+ destination: await hydrateBackupDestination(
318
+ teamId,
319
+ payload.destination,
320
+ payload.remoteObjectKeys.length > 0,
321
+ ),
322
+ }
323
+ : {}),
324
+ }
325
+ }
326
+ async function hydrateRestorePayload(
327
+ payload: NonNullable<ReturnType<typeof parseRestorePayload>>,
328
+ teamId: string,
329
+ ) {
330
+ return {
331
+ ...payload,
332
+ ...(payload.destination
333
+ ? {
334
+ destination: await hydrateBackupDestination(
335
+ teamId,
336
+ payload.destination,
337
+ payload.artifacts.some((artifact) => artifact.storage === 'remote'),
338
+ ),
339
+ }
340
+ : {}),
341
+ }
342
+ }
343
+ async function hydrateBackupDestination(
344
+ teamId: string,
345
+ destination: BackupDestination,
346
+ remote: boolean,
347
+ ) {
348
+ const snapshot = backupDestinationSnapshot(destination)
349
+ if (!remote) return snapshot
350
+ const current = await refreshBackupCredentials(teamId, snapshot)
351
+ if (!current.secretAccessKeyCiphertext)
352
+ throw new Error('Backup storage credentials missing.')
353
+ return {
354
+ ...snapshot,
355
+ accessKeyId: current.accessKeyId,
356
+ secretAccessKey: decryptSecret(current.secretAccessKeyCiphertext),
357
+ }
358
+ }
359
+
360
+ export async function claimNextDeploymentJob({
361
+ serverId,
362
+ token,
363
+ createAccessToken = createInstallationAccessToken,
364
+ skipAgentAuth = false,
365
+ }: {
366
+ serverId: string
367
+ token: string
368
+ createAccessToken?: InstallationAccessTokenFactory
369
+ skipAgentAuth?: boolean
370
+ }): Promise<AgentDeployJob | null> {
371
+ if (!skipAgentAuth) {
372
+ await requireAgentServer({ serverId, token })
373
+ await failStaleRunningDeployments(serverId)
374
+ }
375
+
376
+ await failStaleQueuedDeployments(serverId)
377
+ const deployment = await claimDeployment(db, serverId, new Date())
378
+ if (!deployment) return null
379
+ const [identity] = await db
380
+ .select({ environment: environments, project: projects })
381
+ .from(environments)
382
+ .innerJoin(projects, eq(projects.id, environments.projectId))
383
+ .where(eq(environments.id, deployment.environmentId))
384
+ .limit(1)
385
+ const claimed = identity ? { deployment, ...identity } : null
386
+
387
+ if (!claimed) {
388
+ await failClaimedDeployment(
389
+ deployment.id,
390
+ 'Deployment environment no longer exists.',
391
+ )
392
+ return null
393
+ }
394
+
395
+ const deploymentConfig = parseDeploymentConfig(claimed.deployment.config)
396
+
397
+ if (!deploymentConfig) {
398
+ await failClaimedDeployment(
399
+ claimed.deployment.id,
400
+ 'Deployment config is invalid.',
401
+ )
402
+ return null
403
+ }
404
+
405
+ if (!claimed.project.githubInstallationId || !claimed.deployment.repoId) {
406
+ await failClaimedDeployment(
407
+ claimed.deployment.id,
408
+ 'Project is missing a GitHub App installation.',
409
+ )
410
+ return null
411
+ }
412
+
413
+ let accessToken: { token: string }
414
+ try {
415
+ accessToken = await createAccessToken({
416
+ installationId: claimed.project.githubInstallationId,
417
+ repositoryId: claimed.deployment.repoId,
418
+ repositoryFullName: claimed.deployment.repoFullName,
419
+ })
420
+ } catch (error) {
421
+ await failClaimedDeployment(
422
+ claimed.deployment.id,
423
+ error instanceof Error
424
+ ? error.message
425
+ : 'Unable to create GitHub installation token.',
426
+ )
427
+ return null
428
+ }
429
+ let envVars: Array<AgentEnvVar>
430
+
431
+ try {
432
+ envVars = decryptDeploymentEnvVars(claimed.deployment.envVars)
433
+ } catch (error) {
434
+ await failClaimedDeployment(
435
+ claimed.deployment.id,
436
+ error instanceof Error
437
+ ? error.message
438
+ : 'Unable to decrypt deployment environment variables.',
439
+ )
440
+ return null
441
+ }
442
+
443
+ const runtimeEnvVars = buildAgentDeploymentEnvVars({
444
+ deployment: claimed.deployment,
445
+ environment: claimed.environment,
446
+ envVars,
447
+ project: claimed.project,
448
+ config: deploymentConfig,
449
+ })
450
+
451
+ const [deploymentAlias] = await db
452
+ .select({ hostname: aliases.hostname })
453
+ .from(aliases)
454
+ .where(
455
+ and(
456
+ eq(aliases.deploymentId, claimed.deployment.id),
457
+ eq(aliases.type, 'deployment'),
458
+ ),
459
+ )
460
+ .limit(1)
461
+
462
+ return {
463
+ fallbackHost: deploymentAlias?.hostname,
464
+ type: 'deploy',
465
+ id: claimed.deployment.id,
466
+ logStartSeq: await nextDeploymentLogSeq(claimed.deployment.id),
467
+ repo: {
468
+ fullName: claimed.deployment.repoFullName,
469
+ branch: claimed.deployment.gitBranch,
470
+ sha: claimed.deployment.gitSha,
471
+ token: accessToken.token,
472
+ },
473
+ project: {
474
+ name: claimed.project.name,
475
+ },
476
+ runtime: {
477
+ ...deploymentConfig.runtime,
478
+ path: deploymentConfig.runtime.path ?? 'compose.yml',
479
+ projectName: claimed.deployment.runtimeId,
480
+ action: claimed.deployment.trigger === 'rollback' ? 'rollback' : 'deploy',
481
+ },
482
+ envVars: runtimeEnvVars,
483
+ routes: deploymentConfig.routes,
484
+ }
485
+ }
486
+
487
+ export function buildAgentDeploymentEnvVars({
488
+ deployment,
489
+ environment,
490
+ envVars,
491
+ project,
492
+ config,
493
+ }: {
494
+ deployment: {
495
+ id: string
496
+ createdAt: Date
497
+ gitBranch: string
498
+ gitSha: string
499
+ commitMeta: unknown
500
+ repoFullName: string
501
+ }
502
+ environment: {
503
+ id: string
504
+ slug: string
505
+ }
506
+ envVars: Array<AgentEnvVar>
507
+ project: {
508
+ id: string
509
+ teamId: string
510
+ }
511
+ config: DeploymentConfig
512
+ }) {
513
+ const next = [...envVars]
514
+ const rawCommitMeta =
515
+ typeof deployment.commitMeta === 'object' && deployment.commitMeta !== null
516
+ ? (deployment.commitMeta as {
517
+ authorName?: string | null
518
+ authoredAt?: string | null
519
+ message?: string | null
520
+ url?: string | null
521
+ })
522
+ : {}
523
+ const commitMeta = toDeploymentCommitMeta({
524
+ authorName: rawCommitMeta.authorName,
525
+ authoredAt: rawCommitMeta.authoredAt,
526
+ message: rawCommitMeta.message ?? null,
527
+ url: rawCommitMeta.url,
528
+ })
529
+ const [repoOwner, repoName] = deployment.repoFullName.split('/', 2)
530
+ const defaultRoute = config.routes.at(0)
531
+ const runtimeVars: Record<string, string | null | undefined> = {
532
+ DEVPUSH: 'true',
533
+ DEVPUSH_URL: defaultRoute
534
+ ? `${defaultRoute.https ? 'https' : 'http'}://${defaultRoute.host}`
535
+ : null,
536
+ DEVPUSH_DOMAIN: defaultRoute?.host,
537
+ DEVPUSH_TEAM_ID: project.teamId,
538
+ DEVPUSH_PROJECT_ID: project.id,
539
+ DEVPUSH_ENVIRONMENT: environment.slug || environment.id,
540
+ DEVPUSH_DEPLOYMENT_ID: deployment.id,
541
+ DEVPUSH_DEPLOYMENT_CREATED_AT: deployment.createdAt.toISOString(),
542
+ DEVPUSH_GIT_PROVIDER: 'github',
543
+ DEVPUSH_GIT_REPO: deployment.repoFullName,
544
+ DEVPUSH_GIT_REF: deployment.gitBranch,
545
+ DEVPUSH_GIT_COMMIT_SHA: deployment.gitSha,
546
+ DEVPUSH_GIT_COMMIT_AUTHOR: commitMeta.authorName,
547
+ DEVPUSH_GIT_COMMIT_MESSAGE: commitMeta.message,
548
+ DEVPUSH_GIT_REPO_OWNER: repoOwner,
549
+ DEVPUSH_GIT_REPO_NAME: repoName,
550
+ }
551
+
552
+ for (const [key, value] of Object.entries(runtimeVars)) {
553
+ if (value && !next.some((envVar) => envVar.key === key)) {
554
+ next.push({ key, value })
555
+ }
556
+ }
557
+
558
+ return next
559
+ }
560
+
561
+ async function claimNextCleanupJob({
562
+ serverId,
563
+ }: {
564
+ serverId: string
565
+ }): Promise<AgentCleanupJob | null> {
566
+ const claimed = await claimCleanup(db, serverId, new Date())
567
+
568
+ if (!claimed) {
569
+ return null
570
+ }
571
+
572
+ if (claimed.type !== 'deprovision' && claimed.type !== 'purge') {
573
+ await failCleanupJob(
574
+ claimed.id,
575
+ `Unsupported agent job type "${claimed.type}".`,
576
+ )
577
+ return null
578
+ }
579
+
580
+ const payload = parseDeprovisionPayload(claimed.payload)
581
+
582
+ if (!payload) {
583
+ await failCleanupJob(claimed.id, 'Cleanup job payload is invalid.')
584
+ return null
585
+ }
586
+
587
+ // A previously orphaned runtime may have been assigned back before this job
588
+ // became available. Never execute an old purge against a currently owned runtime.
589
+ const protectedRows = await db
590
+ .select({ runtimeId: environments.runtimeId })
591
+ .from(environments)
592
+ .innerJoin(projects, eq(projects.id, environments.projectId))
593
+ .where(
594
+ and(
595
+ eq(environments.status, 'active'),
596
+ sql`coalesce(${environments.serverId}, ${projects.defaultServerId}) = ${serverId}`,
597
+ sql`${environments.runtimeId} in (select value from json_each(${JSON.stringify(payload.runtimeIds)}))`,
598
+ ),
599
+ )
600
+ const activeRows = await db
601
+ .select({ runtimeId: deployments.runtimeId })
602
+ .from(deployments)
603
+ .where(
604
+ and(
605
+ eq(deployments.serverId, serverId),
606
+ inArray(deployments.status, ['queued', 'running']),
607
+ sql`${deployments.runtimeId} in (select value from json_each(${JSON.stringify(payload.runtimeIds)}))`,
608
+ ),
609
+ )
610
+ const protectedIds = new Set(
611
+ [...protectedRows, ...activeRows].map((row) => row.runtimeId),
612
+ )
613
+ const runtimeIds = payload.runtimeIds.filter((id) => !protectedIds.has(id))
614
+ if (runtimeIds.length === 0) {
615
+ await finishContainerCleanup(db, {
616
+ job: claimed,
617
+ completion: {
618
+ jobId: claimed.id,
619
+ attempt: claimed.attempt,
620
+ status: 'succeeded',
621
+ },
622
+ now: new Date(),
623
+ })
624
+ return null
625
+ }
626
+
627
+ if (runtimeIds.length !== payload.runtimeIds.length) {
628
+ const trimmed = await db
629
+ .update(agentJobs)
630
+ .set({ payload: { ...payload, runtimeIds } })
631
+ .where(
632
+ and(
633
+ eq(agentJobs.id, claimed.id),
634
+ eq(agentJobs.status, 'running'),
635
+ eq(agentJobs.attempt, claimed.attempt),
636
+ sql`${agentJobs.claimedAt} is ${claimed.claimedAt?.getTime() ?? null}`,
637
+ ),
638
+ )
639
+ .returning({ id: agentJobs.id })
640
+ if (trimmed.length === 0) return null
641
+ }
642
+
643
+ return {
644
+ type: claimed.type,
645
+ id: claimed.id,
646
+ attempt: claimed.attempt,
647
+ cleanup: {
648
+ runtimeIds,
649
+ removeVolumes: payload.removeVolumes,
650
+ reason: payload.reason,
651
+ },
652
+ }
653
+ }
654
+
655
+ const deploymentTimeoutMs = 3 * 60 * 60 * 1000
656
+ const lostAgentTimeoutMs = 10 * 60 * 1000
657
+ const backupJobTimeoutMs = 25 * 60 * 60 * 1000
658
+
659
+ function agentJobTimeoutMs(type: string) {
660
+ return type === 'backup' || type === 'backup_cleanup' || type === 'restore'
661
+ ? backupJobTimeoutMs
662
+ : deploymentTimeoutMs
663
+ }
664
+
665
+ export async function failStaleQueuedDeployments(
666
+ serverId?: string,
667
+ now = new Date(),
668
+ ) {
669
+ const cutoff = new Date(
670
+ now.getTime() - serverConfig.DEVPUSH_DEPLOYMENT_QUEUE_TTL_HOURS * 60 * 60 * 1000,
671
+ )
672
+ const changed = await db
673
+ .update(deployments)
674
+ .set({
675
+ status: 'failed',
676
+ phase: 'timed_out',
677
+ errorMessage: 'Deployment expired while waiting for the server.',
678
+ finishedAt: now,
679
+ })
680
+ .where(
681
+ and(
682
+ eq(deployments.status, 'queued'),
683
+ lte(deployments.createdAt, cutoff),
684
+ serverId ? eq(deployments.serverId, serverId) : undefined,
685
+ ),
686
+ )
687
+ .returning({ id: deployments.id, serverId: deployments.serverId })
688
+ await Promise.all(
689
+ [...new Set(changed.map((row) => row.serverId))].map((id) =>
690
+ notifyServer(id, 'deployments'),
691
+ ),
692
+ )
693
+ return changed.map((row) => row.id)
694
+ }
695
+
696
+ export async function failStaleRunningDeployments(serverId?: string) {
697
+ const now = new Date()
698
+ const cutoff = new Date(now.getTime() - deploymentTimeoutMs)
699
+ const heartbeatCutoff = new Date(now.getTime() - lostAgentTimeoutMs)
700
+ const staleRows = await db
701
+ .select({
702
+ id: deployments.id,
703
+ serverId: deployments.serverId,
704
+ startedAt: deployments.startedAt,
705
+ })
706
+ .from(deployments)
707
+ .innerJoin(servers, eq(servers.id, deployments.serverId))
708
+ .where(
709
+ and(
710
+ serverId ? eq(deployments.serverId, serverId) : undefined,
711
+ eq(deployments.status, 'running'),
712
+ or(
713
+ lt(deployments.startedAt, cutoff),
714
+ and(
715
+ lt(deployments.startedAt, heartbeatCutoff),
716
+ or(
717
+ isNull(servers.lastSeenAt),
718
+ lt(servers.lastSeenAt, heartbeatCutoff),
719
+ ),
720
+ ),
721
+ ),
722
+ ),
723
+ )
724
+ const expired = staleRows.filter(
725
+ (row) => row.startedAt && row.startedAt < cutoff,
726
+ )
727
+ const disconnected = staleRows.filter((row) => !expired.includes(row))
728
+ const failed = [
729
+ ...(await failRunningDeployments(db, {
730
+ ids: expired.map((row) => row.id),
731
+ message: 'Deployment exceeded the three-hour execution limit.',
732
+ phase: 'timed_out',
733
+ startedBefore: cutoff,
734
+ now,
735
+ })),
736
+ ...(await failRunningDeployments(db, {
737
+ ids: disconnected.map((row) => row.id),
738
+ message:
739
+ 'Deployment failed after ten minutes without an agent heartbeat.',
740
+ phase: 'timed_out',
741
+ startedBefore: heartbeatCutoff,
742
+ heartbeatBefore: heartbeatCutoff,
743
+ now,
744
+ })),
745
+ ]
746
+ const failedIds = new Set(failed)
747
+ const affectedServers = new Set(
748
+ staleRows.filter((row) => failedIds.has(row.id)).map((row) => row.serverId),
749
+ )
750
+ await Promise.all(
751
+ [...affectedServers].map((id) => notifyServer(id, 'deployments')),
752
+ )
753
+ return failed
754
+ }
755
+
756
+ export async function failStaleAgentJobs(serverId?: string) {
757
+ const now = new Date()
758
+ const cutoff = now.getTime() - backupJobTimeoutMs
759
+ const stale = await db
760
+ .select({
761
+ job: agentJobs,
762
+ payloadSnapshot: sql<string>`${agentJobs.payload}`,
763
+ })
764
+ .from(agentJobs)
765
+ .where(
766
+ and(
767
+ serverId ? eq(agentJobs.serverId, serverId) : undefined,
768
+ sql`${agentJobs.type} in ('backup', 'restore', 'backup_cleanup')`,
769
+ sql`((${agentJobs.status} = 'running' and (${agentJobs.claimedAt} is null or ${agentJobs.claimedAt} < ${cutoff}))
770
+ or (${agentJobs.status} = 'queued' and ${agentJobs.availableAt} < ${cutoff}))`,
771
+ ),
772
+ )
773
+ .orderBy(asc(agentJobs.availableAt))
774
+ .limit(500)
775
+ const failed: Array<{ id: string }> = []
776
+ const affectedServers = new Set<string>()
777
+ for (const { job, payloadSnapshot } of stale) {
778
+ const backup =
779
+ job.type === 'backup' ? parseBackupPayload(job.payload) : null
780
+ const cleanup =
781
+ job.type === 'backup_cleanup'
782
+ ? parseBackupCleanupPayload(job.payload)
783
+ : null
784
+ if (
785
+ await failStaleAgentJob(db, {
786
+ job,
787
+ payloadSnapshot,
788
+ now,
789
+ timeoutMs: agentJobTimeoutMs(job.type),
790
+ message: 'Agent job timed out before completion was reported.',
791
+ backupRunId: backup?.backupRunId ?? null,
792
+ cleanupRunIds: cleanup?.backupRunIds ?? [],
793
+ })
794
+ ) {
795
+ failed.push({ id: job.id })
796
+ affectedServers.add(job.serverId)
797
+ }
798
+ }
799
+ await Promise.all(
800
+ [...affectedServers].map((id) => notifyServer(id, 'deployments')),
801
+ )
802
+ return failed
803
+ }
804
+
805
+ export async function getDeploymentJobStatus({
806
+ serverId,
807
+ token,
808
+ deploymentId,
809
+ }: {
810
+ serverId: string
811
+ token: string
812
+ deploymentId: string
813
+ }): Promise<AgentJobStatus> {
814
+ await requireAgentServer({ serverId, token })
815
+
816
+ const rows = await db
817
+ .select({ status: deployments.status })
818
+ .from(deployments)
819
+ .where(
820
+ and(eq(deployments.id, deploymentId), eq(deployments.serverId, serverId)),
821
+ )
822
+ .limit(1)
823
+ const row = rows.at(0)
824
+
825
+ if (!row) {
826
+ throw new AgentRequestError(404, 'Deployment not found for this server.')
827
+ }
828
+
829
+ return {
830
+ deploymentId,
831
+ status: row.status,
832
+ }
833
+ }
834
+
835
+ export async function completeDeploymentJob({
836
+ serverId,
837
+ token,
838
+ completion,
839
+ }: {
840
+ serverId: string
841
+ token: string
842
+ completion: AgentJobCompletion
843
+ }) {
844
+ await requireAgentServer({ serverId, token })
845
+
846
+ const rows = await db
847
+ .select({
848
+ deployment: deployments,
849
+ projectId: environments.projectId,
850
+ })
851
+ .from(deployments)
852
+ .innerJoin(environments, eq(environments.id, deployments.environmentId))
853
+ .where(
854
+ and(
855
+ eq(deployments.id, completion.deploymentId),
856
+ eq(deployments.serverId, serverId),
857
+ ),
858
+ )
859
+ .limit(1)
860
+ const row = rows.at(0)
861
+
862
+ if (!row) {
863
+ throw new AgentRequestError(404, 'Deployment not found for this server.')
864
+ }
865
+
866
+ const terminal = ['canceled', 'failed', 'succeeded'].includes(
867
+ row.deployment.status,
868
+ )
869
+ if (!terminal && row.deployment.status !== 'running') {
870
+ throw new AgentRequestError(409, 'Deployment is not running.')
871
+ }
872
+
873
+ // Commit captured output before changing main state. Stable sequences make a
874
+ // retry safe after either store succeeds but the response is lost.
875
+ await appendDeploymentLogs(
876
+ {
877
+ projectId: row.projectId,
878
+ environmentId: row.deployment.environmentId,
879
+ deploymentId: row.deployment.id,
880
+ serverId,
881
+ branch: row.deployment.gitBranch,
882
+ },
883
+ completion.logStartSeq,
884
+ completion.logs,
885
+ )
886
+ if (terminal) return { ok: true, ignored: true }
887
+
888
+ const now = new Date()
889
+ const latestEvent = completion.events.at(-1)
890
+ const latestMessage = getLatestProgressMessage(completion)
891
+
892
+ await finishDeployment(db, {
893
+ deployment: row.deployment,
894
+ projectId: row.projectId,
895
+ status: completion.status,
896
+ phase:
897
+ completion.status === 'canceled'
898
+ ? 'canceled'
899
+ : (latestEvent?.phase ?? 'completed'),
900
+ // Preserve the queued full commit ID for later redeployments.
901
+ gitSha: row.deployment.gitSha,
902
+ errorMessage:
903
+ completion.status === 'failed' || completion.status === 'canceled'
904
+ ? latestMessage
905
+ : row.deployment.errorMessage,
906
+ now,
907
+ })
908
+
909
+ return { ok: true }
910
+ }
911
+
912
+ export async function completeCleanupJob({
913
+ serverId,
914
+ token,
915
+ completion,
916
+ }: {
917
+ serverId: string
918
+ token: string
919
+ completion: AgentCleanupCompletion
920
+ }) {
921
+ await requireAgentServer({
922
+ serverId,
923
+ token,
924
+ cleanupJobId: completion.jobId,
925
+ })
926
+
927
+ const rows = await db
928
+ .select({ job: agentJobs })
929
+ .from(agentJobs)
930
+ .where(
931
+ and(
932
+ eq(agentJobs.id, completion.jobId),
933
+ eq(agentJobs.serverId, serverId),
934
+ inArray(agentJobs.type, ['deprovision', 'purge']),
935
+ ),
936
+ )
937
+ .limit(1)
938
+ const row = rows.at(0)
939
+
940
+ if (!row) {
941
+ throw new AgentRequestError(404, 'Cleanup job not found for this server.')
942
+ }
943
+
944
+ if (row.job.status !== 'running' || row.job.attempt !== completion.attempt) {
945
+ return { ok: true, ignored: true }
946
+ }
947
+
948
+ const changed = await finishContainerCleanup(db, {
949
+ job: row.job,
950
+ completion,
951
+ now: new Date(),
952
+ })
953
+ if (!changed) return { ok: true, ignored: true }
954
+
955
+ return { ok: true }
956
+ }
957
+
958
+ export async function completeBackupJob({
959
+ serverId,
960
+ token,
961
+ completion,
962
+ }: {
963
+ serverId: string
964
+ token: string
965
+ completion: AgentBackupCompletion
966
+ }) {
967
+ await requireAgentServer({ serverId, token })
968
+
969
+ const rows = await db
970
+ .select({ job: agentJobs, backupRun: backupRuns })
971
+ .from(agentJobs)
972
+ .innerJoin(backupRuns, eq(backupRuns.id, completion.backupRunId))
973
+ .where(
974
+ and(
975
+ eq(agentJobs.id, completion.jobId),
976
+ eq(agentJobs.serverId, serverId),
977
+ eq(agentJobs.type, 'backup'),
978
+ eq(backupRuns.id, completion.backupRunId),
979
+ eq(backupRuns.serverId, serverId),
980
+ ),
981
+ )
982
+ .limit(1)
983
+ const row = rows.at(0)
984
+
985
+ if (!row) {
986
+ throw new AgentRequestError(404, 'Backup job not found for this server.')
987
+ }
988
+
989
+ if (row.job.status !== 'running') {
990
+ return { ok: true, ignored: true }
991
+ }
992
+
993
+ const payload = parseBackupPayload(row.job.payload)
994
+ if (!payload || payload.backupRunId !== completion.backupRunId) {
995
+ throw new AgentRequestError(
996
+ 409,
997
+ 'Backup completion does not match the claimed job.',
998
+ )
999
+ }
1000
+
1001
+ for (const artifact of completion.artifacts ?? []) {
1002
+ if (
1003
+ (artifact.storage === 'local' &&
1004
+ !pathIsWithin(payload.localBasePath, artifact.objectKey)) ||
1005
+ (artifact.storage === 'remote' &&
1006
+ !remoteKeyIsWithin(payload.artifactPrefix, artifact.objectKey))
1007
+ ) {
1008
+ throw new AgentRequestError(
1009
+ 409,
1010
+ 'Backup artifact is outside the claimed backup location.',
1011
+ )
1012
+ }
1013
+ }
1014
+
1015
+ const now = new Date()
1016
+ const errorMessage =
1017
+ completion.status === 'failed'
1018
+ ? (completion.message ?? 'Backup failed.')
1019
+ : completion.status === 'canceled'
1020
+ ? (completion.message ?? 'Backup canceled.')
1021
+ : null
1022
+
1023
+ const changed = await finishBackup(db, {
1024
+ serverId,
1025
+ completion,
1026
+ errorMessage,
1027
+ now,
1028
+ manifest: completion.manifest ?? row.backupRun.manifest,
1029
+ })
1030
+
1031
+ if (
1032
+ changed &&
1033
+ (completion.status === 'succeeded' ||
1034
+ (completion.status === 'failed' &&
1035
+ typeof completion.manifest === 'object' &&
1036
+ completion.manifest !== null &&
1037
+ 'localRecoveryAvailable' in completion.manifest &&
1038
+ completion.manifest.localRecoveryAvailable === true))
1039
+ ) {
1040
+ await queueBackupRetentionCleanupJobs({
1041
+ projectId: row.backupRun.projectId,
1042
+ environmentId: row.backupRun.environmentId,
1043
+ now,
1044
+ })
1045
+ }
1046
+
1047
+ return { ok: true }
1048
+ }
1049
+
1050
+ function pathIsWithin(root: string, candidate: string) {
1051
+ if (!isAbsolute(root) || !isAbsolute(candidate)) return false
1052
+ const path = relative(root, candidate)
1053
+ return path !== '' && path !== '..' && !path.startsWith('../')
1054
+ }
1055
+
1056
+ function remoteKeyIsWithin(prefix: string, candidate: string) {
1057
+ const normalizedPrefix = prefix.replace(/^\/+|\/+$/g, '')
1058
+ const normalizedCandidate = candidate.replace(/^\/+/, '')
1059
+ return (
1060
+ normalizedPrefix !== '' &&
1061
+ normalizedCandidate.startsWith(`${normalizedPrefix}/`) &&
1062
+ !normalizedCandidate.split('/').includes('..')
1063
+ )
1064
+ }
1065
+
1066
+ export async function completeBackupCleanupJob({
1067
+ serverId,
1068
+ token,
1069
+ completion,
1070
+ }: {
1071
+ serverId: string
1072
+ token: string
1073
+ completion: AgentBackupCleanupCompletion
1074
+ }) {
1075
+ await requireAgentServer({ serverId, token })
1076
+
1077
+ const rows = await db
1078
+ .select({ job: agentJobs })
1079
+ .from(agentJobs)
1080
+ .where(
1081
+ and(
1082
+ eq(agentJobs.id, completion.jobId),
1083
+ eq(agentJobs.serverId, serverId),
1084
+ eq(agentJobs.type, 'backup_cleanup'),
1085
+ ),
1086
+ )
1087
+ .limit(1)
1088
+ const row = rows.at(0)
1089
+
1090
+ if (!row) {
1091
+ throw new AgentRequestError(
1092
+ 404,
1093
+ 'Backup cleanup job not found for this server.',
1094
+ )
1095
+ }
1096
+
1097
+ if (row.job.status !== 'running') {
1098
+ return { ok: true, ignored: true }
1099
+ }
1100
+
1101
+ const payload = parseBackupCleanupPayload(row.job.payload)
1102
+ if (!payload) {
1103
+ throw new AgentRequestError(409, 'Backup cleanup job payload is invalid.')
1104
+ }
1105
+ const reportedBackupRunIds = Array.from(
1106
+ new Set(completion.backupRunIds.map((id) => id.trim()).filter(Boolean)),
1107
+ ).sort()
1108
+ const backupRunIds = Array.from(new Set(payload.backupRunIds)).sort()
1109
+
1110
+ if (
1111
+ backupRunIds.length === 0 ||
1112
+ reportedBackupRunIds.length !== backupRunIds.length ||
1113
+ reportedBackupRunIds.some((id, index) => id !== backupRunIds[index])
1114
+ ) {
1115
+ throw new AgentRequestError(
1116
+ 409,
1117
+ 'Backup cleanup completion does not match the claimed job.',
1118
+ )
1119
+ }
1120
+
1121
+ const now = new Date()
1122
+ const errorMessage =
1123
+ completion.status === 'failed'
1124
+ ? (completion.message ?? 'Backup cleanup failed.')
1125
+ : completion.status === 'canceled'
1126
+ ? (completion.message ?? 'Backup cleanup canceled.')
1127
+ : null
1128
+
1129
+ await finishBackupCleanup(db, {
1130
+ serverId,
1131
+ completion,
1132
+ errorMessage,
1133
+ now,
1134
+ backupRunIds,
1135
+ localObjectKeys: payload.localObjectKeys,
1136
+ remoteObjectKeys: payload.remoteObjectKeys,
1137
+ })
1138
+
1139
+ return { ok: true }
1140
+ }
1141
+
1142
+ export async function completeRestoreJob({
1143
+ serverId,
1144
+ token,
1145
+ completion,
1146
+ }: {
1147
+ serverId: string
1148
+ token: string
1149
+ completion: AgentRestoreCompletion
1150
+ }) {
1151
+ await requireAgentServer({ serverId, token })
1152
+
1153
+ const rows = await db
1154
+ .select({ job: agentJobs })
1155
+ .from(agentJobs)
1156
+ .where(
1157
+ and(
1158
+ eq(agentJobs.id, completion.jobId),
1159
+ eq(agentJobs.serverId, serverId),
1160
+ eq(agentJobs.type, 'restore'),
1161
+ ),
1162
+ )
1163
+ .limit(1)
1164
+ const row = rows.at(0)
1165
+
1166
+ if (!row) {
1167
+ throw new AgentRequestError(404, 'Restore job not found for this server.')
1168
+ }
1169
+
1170
+ if (row.job.status !== 'running') {
1171
+ return { ok: true, ignored: true }
1172
+ }
1173
+
1174
+ const now = new Date()
1175
+ const errorMessage =
1176
+ completion.status === 'failed'
1177
+ ? (completion.message ?? 'Restore failed.')
1178
+ : completion.status === 'canceled'
1179
+ ? (completion.message ?? 'Restore canceled.')
1180
+ : null
1181
+
1182
+ await db
1183
+ .update(agentJobs)
1184
+ .set({
1185
+ status: completion.status,
1186
+ errorMessage,
1187
+ finishedAt: now,
1188
+ updatedAt: now,
1189
+ })
1190
+ .where(
1191
+ and(eq(agentJobs.id, completion.jobId), eq(agentJobs.status, 'running')),
1192
+ )
1193
+
1194
+ return { ok: true }
1195
+ }
1196
+
1197
+ export async function recordDeploymentProgress({
1198
+ serverId,
1199
+ token,
1200
+ progress,
1201
+ }: {
1202
+ serverId: string
1203
+ token: string
1204
+ progress: AgentJobProgress
1205
+ }) {
1206
+ await requireAgentServer({ serverId, token })
1207
+
1208
+ const rows = await db
1209
+ .select({ deployment: deployments, projectId: environments.projectId })
1210
+ .from(deployments)
1211
+ .innerJoin(environments, eq(environments.id, deployments.environmentId))
1212
+ .where(
1213
+ and(
1214
+ eq(deployments.id, progress.deploymentId),
1215
+ eq(deployments.serverId, serverId),
1216
+ ),
1217
+ )
1218
+ .limit(1)
1219
+ const row = rows.at(0)
1220
+
1221
+ if (!row) {
1222
+ throw new AgentRequestError(404, 'Deployment not found for this server.')
1223
+ }
1224
+
1225
+ const terminal = ['canceled', 'failed', 'succeeded'].includes(
1226
+ row.deployment.status,
1227
+ )
1228
+ if (!terminal && row.deployment.status !== 'running') {
1229
+ throw new AgentRequestError(409, 'Deployment is not running.')
1230
+ }
1231
+
1232
+ await appendDeploymentLogs(
1233
+ {
1234
+ projectId: row.projectId,
1235
+ environmentId: row.deployment.environmentId,
1236
+ deploymentId: row.deployment.id,
1237
+ serverId,
1238
+ branch: row.deployment.gitBranch,
1239
+ },
1240
+ progress.logStartSeq,
1241
+ progress.logs,
1242
+ )
1243
+ if (terminal) return { ok: true, ignored: true }
1244
+ const latestEvent = progress.events.at(-1)
1245
+ if (latestEvent) {
1246
+ await db
1247
+ .update(deployments)
1248
+ .set({ phase: latestEvent.phase })
1249
+ .where(
1250
+ and(
1251
+ eq(deployments.id, progress.deploymentId),
1252
+ eq(deployments.status, 'running'),
1253
+ ),
1254
+ )
1255
+ }
1256
+
1257
+ return { ok: true }
1258
+ }
1259
+
1260
+ async function failCleanupJob(jobId: string, message: string) {
1261
+ const changed = await db
1262
+ .update(agentJobs)
1263
+ .set({
1264
+ status: 'failed',
1265
+ errorMessage: message,
1266
+ finishedAt: new Date(),
1267
+ updatedAt: new Date(),
1268
+ })
1269
+ .where(eq(agentJobs.id, jobId))
1270
+ .returning({ serverId: agentJobs.serverId })
1271
+ if (changed[0]) await notifyServer(changed[0].serverId, 'deployments')
1272
+ }
1273
+
1274
+ async function failClaimedDeployment(deploymentId: string, message: string) {
1275
+ const changed = await failRunningDeployments(db, {
1276
+ ids: [deploymentId],
1277
+ message,
1278
+ phase: 'failed',
1279
+ now: new Date(),
1280
+ })
1281
+ if (changed.length === 0) return
1282
+ const [deployment] = await db
1283
+ .select({ serverId: deployments.serverId })
1284
+ .from(deployments)
1285
+ .where(eq(deployments.id, deploymentId))
1286
+ .limit(1)
1287
+ if (deployment?.serverId)
1288
+ await notifyServer(deployment.serverId, 'deployments')
1289
+ }
1290
+
1291
+ function getLatestProgressMessage(progress: AgentJobCompletion) {
1292
+ return progress.events.at(-1)?.message ?? progress.logs.at(-1) ?? null
1293
+ }