agent-relay 2.0.22 → 2.0.24

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 (534) hide show
  1. package/bin/relay-pty-linux-arm64 +0 -0
  2. package/dist/src/cli/index.d.ts +3 -3
  3. package/dist/src/cli/index.js +58 -74
  4. package/package.json +21 -62
  5. package/packages/api-types/package.json +1 -1
  6. package/packages/bridge/package.json +8 -8
  7. package/packages/cli-tester/package.json +1 -1
  8. package/packages/config/package.json +2 -2
  9. package/packages/continuity/package.json +1 -1
  10. package/packages/daemon/dist/orchestrator.js +19 -1
  11. package/packages/daemon/package.json +12 -12
  12. package/packages/hooks/package.json +4 -4
  13. package/packages/mcp/package.json +2 -2
  14. package/packages/memory/package.json +2 -2
  15. package/packages/policy/package.json +2 -2
  16. package/packages/protocol/package.json +1 -1
  17. package/packages/resiliency/package.json +1 -1
  18. package/packages/sdk/package.json +2 -2
  19. package/packages/spawner/package.json +1 -1
  20. package/packages/state/package.json +1 -1
  21. package/packages/storage/package.json +2 -2
  22. package/packages/telemetry/package.json +1 -1
  23. package/packages/trajectory/package.json +2 -2
  24. package/packages/user-directory/package.json +2 -2
  25. package/packages/utils/dist/update-checker.js +4 -0
  26. package/packages/utils/package.json +1 -1
  27. package/packages/wrapper/package.json +6 -6
  28. package/deploy/init-db.sql +0 -5
  29. package/deploy/scripts/setup-fly-workspaces.sh +0 -69
  30. package/deploy/scripts/setup-railway.sh +0 -75
  31. package/deploy/workspace/codex.config.toml +0 -20
  32. package/deploy/workspace/entrypoint-browser.sh +0 -118
  33. package/deploy/workspace/entrypoint.sh +0 -612
  34. package/deploy/workspace/gh-credential-relay +0 -90
  35. package/deploy/workspace/gh-relay +0 -156
  36. package/deploy/workspace/git-credential-relay +0 -330
  37. package/deploy/workspace/git-credential-relay.test.sh +0 -230
  38. package/dist/dashboard/out/404.html +0 -1
  39. package/dist/dashboard/out/_next/static/91mkGYq3qbG8WHE6VytQ8/_buildManifest.js +0 -1
  40. package/dist/dashboard/out/_next/static/91mkGYq3qbG8WHE6VytQ8/_ssgManifest.js +0 -1
  41. package/dist/dashboard/out/_next/static/chunks/116-a883fca163f3a5bc.js +0 -1
  42. package/dist/dashboard/out/_next/static/chunks/117-c8afed19e821a35d.js +0 -2
  43. package/dist/dashboard/out/_next/static/chunks/282-980c2eb8fff20123.js +0 -1
  44. package/dist/dashboard/out/_next/static/chunks/320-a6304232cd0ee2ce.js +0 -1
  45. package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +0 -9
  46. package/dist/dashboard/out/_next/static/chunks/631-16b905e5920f9b59.js +0 -1
  47. package/dist/dashboard/out/_next/static/chunks/648-acb2ff9f77cbfbd3.js +0 -1
  48. package/dist/dashboard/out/_next/static/chunks/766-2aea80818f7eb0d8.js +0 -1
  49. package/dist/dashboard/out/_next/static/chunks/83-26d2bde54616ee90.js +0 -1
  50. package/dist/dashboard/out/_next/static/chunks/847-f1f467060f32afff.js +0 -1
  51. package/dist/dashboard/out/_next/static/chunks/891-5cb1513eeb97a891.js +0 -1
  52. package/dist/dashboard/out/_next/static/chunks/app/_not-found/page-60501fddbafba9dc.js +0 -1
  53. package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-9914652442f7e4fb.js +0 -1
  54. package/dist/dashboard/out/_next/static/chunks/app/app/page-366fb7c078d4e9e0.js +0 -1
  55. package/dist/dashboard/out/_next/static/chunks/app/cloud/link/page-fa1d5842aa90e8a6.js +0 -1
  56. package/dist/dashboard/out/_next/static/chunks/app/complete-profile/page-dd64bbdf66b639cd.js +0 -1
  57. package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-113060009ef35bc2.js +0 -1
  58. package/dist/dashboard/out/_next/static/chunks/app/history/page-9965d2483011b846.js +0 -1
  59. package/dist/dashboard/out/_next/static/chunks/app/layout-6b91e33784c20610.js +0 -1
  60. package/dist/dashboard/out/_next/static/chunks/app/login/page-435eceb0073be027.js +0 -1
  61. package/dist/dashboard/out/_next/static/chunks/app/metrics/page-1e37ef8e73940b40.js +0 -1
  62. package/dist/dashboard/out/_next/static/chunks/app/page-8119d4246743574e.js +0 -1
  63. package/dist/dashboard/out/_next/static/chunks/app/pricing/page-9db3ebdfa567a7c9.js +0 -1
  64. package/dist/dashboard/out/_next/static/chunks/app/providers/page-ecb16ffd3b36262b.js +0 -1
  65. package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-4dbe33f0f7691b7c.js +0 -1
  66. package/dist/dashboard/out/_next/static/chunks/app/signup/page-c7a0a28341365ae0.js +0 -1
  67. package/dist/dashboard/out/_next/static/chunks/e868780c-48e5f147c90a3a41.js +0 -18
  68. package/dist/dashboard/out/_next/static/chunks/fd9d1056-609918ca7b6280bb.js +0 -1
  69. package/dist/dashboard/out/_next/static/chunks/framework-f66176bb897dc684.js +0 -1
  70. package/dist/dashboard/out/_next/static/chunks/main-311c3db74dcfadb7.js +0 -1
  71. package/dist/dashboard/out/_next/static/chunks/main-app-fdbeb09028f57c9f.js +0 -1
  72. package/dist/dashboard/out/_next/static/chunks/pages/_app-72b849fbd24ac258.js +0 -1
  73. package/dist/dashboard/out/_next/static/chunks/pages/_error-7ba65e1336b92748.js +0 -1
  74. package/dist/dashboard/out/_next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
  75. package/dist/dashboard/out/_next/static/chunks/webpack-1cdd8ed57114d5e1.js +0 -1
  76. package/dist/dashboard/out/_next/static/css/4034f236dd1a3178.css +0 -1
  77. package/dist/dashboard/out/_next/static/css/6892f8422896ef7a.css +0 -1
  78. package/dist/dashboard/out/alt-logos/agent-relay-logo-128.png +0 -0
  79. package/dist/dashboard/out/alt-logos/agent-relay-logo-256.png +0 -0
  80. package/dist/dashboard/out/alt-logos/agent-relay-logo-32.png +0 -0
  81. package/dist/dashboard/out/alt-logos/agent-relay-logo-512.png +0 -0
  82. package/dist/dashboard/out/alt-logos/agent-relay-logo-64.png +0 -0
  83. package/dist/dashboard/out/alt-logos/agent-relay-logo.svg +0 -45
  84. package/dist/dashboard/out/alt-logos/logo.svg +0 -38
  85. package/dist/dashboard/out/alt-logos/monogram-logo-128.png +0 -0
  86. package/dist/dashboard/out/alt-logos/monogram-logo-256.png +0 -0
  87. package/dist/dashboard/out/alt-logos/monogram-logo-32.png +0 -0
  88. package/dist/dashboard/out/alt-logos/monogram-logo-512.png +0 -0
  89. package/dist/dashboard/out/alt-logos/monogram-logo-64.png +0 -0
  90. package/dist/dashboard/out/alt-logos/monogram-logo.svg +0 -38
  91. package/dist/dashboard/out/app/onboarding.html +0 -1
  92. package/dist/dashboard/out/app/onboarding.txt +0 -7
  93. package/dist/dashboard/out/app.html +0 -1
  94. package/dist/dashboard/out/app.txt +0 -7
  95. package/dist/dashboard/out/apple-icon.png +0 -0
  96. package/dist/dashboard/out/cloud/link.html +0 -1
  97. package/dist/dashboard/out/cloud/link.txt +0 -7
  98. package/dist/dashboard/out/complete-profile.html +0 -5
  99. package/dist/dashboard/out/complete-profile.txt +0 -7
  100. package/dist/dashboard/out/connect-repos.html +0 -1
  101. package/dist/dashboard/out/connect-repos.txt +0 -7
  102. package/dist/dashboard/out/history.html +0 -1
  103. package/dist/dashboard/out/history.txt +0 -7
  104. package/dist/dashboard/out/index.html +0 -1
  105. package/dist/dashboard/out/index.txt +0 -7
  106. package/dist/dashboard/out/login.html +0 -5
  107. package/dist/dashboard/out/login.txt +0 -7
  108. package/dist/dashboard/out/metrics.html +0 -1
  109. package/dist/dashboard/out/metrics.txt +0 -7
  110. package/dist/dashboard/out/pricing.html +0 -13
  111. package/dist/dashboard/out/pricing.txt +0 -7
  112. package/dist/dashboard/out/providers/setup/claude.html +0 -1
  113. package/dist/dashboard/out/providers/setup/claude.txt +0 -8
  114. package/dist/dashboard/out/providers/setup/codex.html +0 -1
  115. package/dist/dashboard/out/providers/setup/codex.txt +0 -8
  116. package/dist/dashboard/out/providers/setup/cursor.html +0 -1
  117. package/dist/dashboard/out/providers/setup/cursor.txt +0 -8
  118. package/dist/dashboard/out/providers.html +0 -1
  119. package/dist/dashboard/out/providers.txt +0 -7
  120. package/dist/dashboard/out/signup.html +0 -6
  121. package/dist/dashboard/out/signup.txt +0 -7
  122. package/dist/src/cloud/index.d.ts +0 -8
  123. package/dist/src/cloud/index.js +0 -8
  124. package/dist/src/dashboard-server/index.d.ts +0 -8
  125. package/dist/src/dashboard-server/index.js +0 -8
  126. package/packages/cloud/dist/api/admin.d.ts +0 -8
  127. package/packages/cloud/dist/api/admin.js +0 -225
  128. package/packages/cloud/dist/api/auth.d.ts +0 -20
  129. package/packages/cloud/dist/api/auth.js +0 -138
  130. package/packages/cloud/dist/api/billing.d.ts +0 -7
  131. package/packages/cloud/dist/api/billing.js +0 -564
  132. package/packages/cloud/dist/api/cli-pty-runner.d.ts +0 -53
  133. package/packages/cloud/dist/api/cli-pty-runner.js +0 -175
  134. package/packages/cloud/dist/api/codex-auth-helper.d.ts +0 -21
  135. package/packages/cloud/dist/api/codex-auth-helper.js +0 -327
  136. package/packages/cloud/dist/api/consensus.d.ts +0 -13
  137. package/packages/cloud/dist/api/consensus.js +0 -261
  138. package/packages/cloud/dist/api/coordinators.d.ts +0 -8
  139. package/packages/cloud/dist/api/coordinators.js +0 -750
  140. package/packages/cloud/dist/api/daemons.d.ts +0 -12
  141. package/packages/cloud/dist/api/daemons.js +0 -535
  142. package/packages/cloud/dist/api/email-auth.d.ts +0 -11
  143. package/packages/cloud/dist/api/email-auth.js +0 -347
  144. package/packages/cloud/dist/api/generic-webhooks.d.ts +0 -8
  145. package/packages/cloud/dist/api/generic-webhooks.js +0 -129
  146. package/packages/cloud/dist/api/git.d.ts +0 -8
  147. package/packages/cloud/dist/api/git.js +0 -269
  148. package/packages/cloud/dist/api/github-app.d.ts +0 -11
  149. package/packages/cloud/dist/api/github-app.js +0 -223
  150. package/packages/cloud/dist/api/middleware/planLimits.d.ts +0 -43
  151. package/packages/cloud/dist/api/middleware/planLimits.js +0 -202
  152. package/packages/cloud/dist/api/monitoring.d.ts +0 -11
  153. package/packages/cloud/dist/api/monitoring.js +0 -578
  154. package/packages/cloud/dist/api/nango-auth.d.ts +0 -9
  155. package/packages/cloud/dist/api/nango-auth.js +0 -741
  156. package/packages/cloud/dist/api/onboarding.d.ts +0 -15
  157. package/packages/cloud/dist/api/onboarding.js +0 -679
  158. package/packages/cloud/dist/api/policy.d.ts +0 -8
  159. package/packages/cloud/dist/api/policy.js +0 -229
  160. package/packages/cloud/dist/api/provider-env.d.ts +0 -26
  161. package/packages/cloud/dist/api/provider-env.js +0 -141
  162. package/packages/cloud/dist/api/providers.d.ts +0 -7
  163. package/packages/cloud/dist/api/providers.js +0 -574
  164. package/packages/cloud/dist/api/repos.d.ts +0 -8
  165. package/packages/cloud/dist/api/repos.js +0 -577
  166. package/packages/cloud/dist/api/sessions.d.ts +0 -11
  167. package/packages/cloud/dist/api/sessions.js +0 -302
  168. package/packages/cloud/dist/api/teams.d.ts +0 -7
  169. package/packages/cloud/dist/api/teams.js +0 -281
  170. package/packages/cloud/dist/api/test-helpers.d.ts +0 -10
  171. package/packages/cloud/dist/api/test-helpers.js +0 -745
  172. package/packages/cloud/dist/api/usage.d.ts +0 -7
  173. package/packages/cloud/dist/api/usage.js +0 -111
  174. package/packages/cloud/dist/api/webhooks.d.ts +0 -8
  175. package/packages/cloud/dist/api/webhooks.js +0 -645
  176. package/packages/cloud/dist/api/workspaces.d.ts +0 -25
  177. package/packages/cloud/dist/api/workspaces.js +0 -1799
  178. package/packages/cloud/dist/billing/index.d.ts +0 -9
  179. package/packages/cloud/dist/billing/index.js +0 -9
  180. package/packages/cloud/dist/billing/plans.d.ts +0 -39
  181. package/packages/cloud/dist/billing/plans.js +0 -245
  182. package/packages/cloud/dist/billing/service.d.ts +0 -80
  183. package/packages/cloud/dist/billing/service.js +0 -388
  184. package/packages/cloud/dist/billing/types.d.ts +0 -141
  185. package/packages/cloud/dist/billing/types.js +0 -7
  186. package/packages/cloud/dist/config.d.ts +0 -5
  187. package/packages/cloud/dist/config.js +0 -5
  188. package/packages/cloud/dist/db/bulk-ingest.d.ts +0 -89
  189. package/packages/cloud/dist/db/bulk-ingest.js +0 -268
  190. package/packages/cloud/dist/db/drizzle.d.ts +0 -290
  191. package/packages/cloud/dist/db/drizzle.js +0 -1422
  192. package/packages/cloud/dist/db/index.d.ts +0 -56
  193. package/packages/cloud/dist/db/index.js +0 -70
  194. package/packages/cloud/dist/db/schema.d.ts +0 -5117
  195. package/packages/cloud/dist/db/schema.js +0 -656
  196. package/packages/cloud/dist/index.d.ts +0 -11
  197. package/packages/cloud/dist/index.js +0 -38
  198. package/packages/cloud/dist/provisioner/index.d.ts +0 -207
  199. package/packages/cloud/dist/provisioner/index.js +0 -2118
  200. package/packages/cloud/dist/server.d.ts +0 -17
  201. package/packages/cloud/dist/server.js +0 -2034
  202. package/packages/cloud/dist/services/auto-scaler.d.ts +0 -152
  203. package/packages/cloud/dist/services/auto-scaler.js +0 -439
  204. package/packages/cloud/dist/services/capacity-manager.d.ts +0 -148
  205. package/packages/cloud/dist/services/capacity-manager.js +0 -449
  206. package/packages/cloud/dist/services/ci-agent-spawner.d.ts +0 -49
  207. package/packages/cloud/dist/services/ci-agent-spawner.js +0 -373
  208. package/packages/cloud/dist/services/cloud-message-bus.d.ts +0 -28
  209. package/packages/cloud/dist/services/cloud-message-bus.js +0 -19
  210. package/packages/cloud/dist/services/compute-enforcement.d.ts +0 -57
  211. package/packages/cloud/dist/services/compute-enforcement.js +0 -175
  212. package/packages/cloud/dist/services/coordinator.d.ts +0 -62
  213. package/packages/cloud/dist/services/coordinator.js +0 -389
  214. package/packages/cloud/dist/services/index.d.ts +0 -17
  215. package/packages/cloud/dist/services/index.js +0 -25
  216. package/packages/cloud/dist/services/intro-expiration.d.ts +0 -60
  217. package/packages/cloud/dist/services/intro-expiration.js +0 -252
  218. package/packages/cloud/dist/services/mention-handler.d.ts +0 -65
  219. package/packages/cloud/dist/services/mention-handler.js +0 -405
  220. package/packages/cloud/dist/services/nango.d.ts +0 -219
  221. package/packages/cloud/dist/services/nango.js +0 -424
  222. package/packages/cloud/dist/services/persistence.d.ts +0 -131
  223. package/packages/cloud/dist/services/persistence.js +0 -200
  224. package/packages/cloud/dist/services/planLimits.d.ts +0 -147
  225. package/packages/cloud/dist/services/planLimits.js +0 -335
  226. package/packages/cloud/dist/services/presence-registry.d.ts +0 -56
  227. package/packages/cloud/dist/services/presence-registry.js +0 -91
  228. package/packages/cloud/dist/services/scaling-orchestrator.d.ts +0 -159
  229. package/packages/cloud/dist/services/scaling-orchestrator.js +0 -502
  230. package/packages/cloud/dist/services/scaling-policy.d.ts +0 -121
  231. package/packages/cloud/dist/services/scaling-policy.js +0 -415
  232. package/packages/cloud/dist/services/ssh-security.d.ts +0 -31
  233. package/packages/cloud/dist/services/ssh-security.js +0 -63
  234. package/packages/cloud/dist/services/workspace-keepalive.d.ts +0 -76
  235. package/packages/cloud/dist/services/workspace-keepalive.js +0 -234
  236. package/packages/cloud/dist/shims/consensus.d.ts +0 -23
  237. package/packages/cloud/dist/shims/consensus.js +0 -5
  238. package/packages/cloud/dist/webhooks/index.d.ts +0 -24
  239. package/packages/cloud/dist/webhooks/index.js +0 -29
  240. package/packages/cloud/dist/webhooks/parsers/github.d.ts +0 -8
  241. package/packages/cloud/dist/webhooks/parsers/github.js +0 -234
  242. package/packages/cloud/dist/webhooks/parsers/index.d.ts +0 -23
  243. package/packages/cloud/dist/webhooks/parsers/index.js +0 -30
  244. package/packages/cloud/dist/webhooks/parsers/linear.d.ts +0 -9
  245. package/packages/cloud/dist/webhooks/parsers/linear.js +0 -258
  246. package/packages/cloud/dist/webhooks/parsers/slack.d.ts +0 -9
  247. package/packages/cloud/dist/webhooks/parsers/slack.js +0 -214
  248. package/packages/cloud/dist/webhooks/responders/github.d.ts +0 -8
  249. package/packages/cloud/dist/webhooks/responders/github.js +0 -73
  250. package/packages/cloud/dist/webhooks/responders/index.d.ts +0 -23
  251. package/packages/cloud/dist/webhooks/responders/index.js +0 -30
  252. package/packages/cloud/dist/webhooks/responders/linear.d.ts +0 -9
  253. package/packages/cloud/dist/webhooks/responders/linear.js +0 -149
  254. package/packages/cloud/dist/webhooks/responders/slack.d.ts +0 -20
  255. package/packages/cloud/dist/webhooks/responders/slack.js +0 -178
  256. package/packages/cloud/dist/webhooks/router.d.ts +0 -25
  257. package/packages/cloud/dist/webhooks/router.js +0 -504
  258. package/packages/cloud/dist/webhooks/rules-engine.d.ts +0 -24
  259. package/packages/cloud/dist/webhooks/rules-engine.js +0 -287
  260. package/packages/cloud/dist/webhooks/types.d.ts +0 -186
  261. package/packages/cloud/dist/webhooks/types.js +0 -8
  262. package/packages/cloud/package.json +0 -60
  263. package/packages/dashboard/README.md +0 -48
  264. package/packages/dashboard/dist/health-worker-manager.d.ts +0 -62
  265. package/packages/dashboard/dist/health-worker-manager.js +0 -144
  266. package/packages/dashboard/dist/health-worker.d.ts +0 -9
  267. package/packages/dashboard/dist/health-worker.js +0 -79
  268. package/packages/dashboard/dist/index.d.ts +0 -20
  269. package/packages/dashboard/dist/index.js +0 -19
  270. package/packages/dashboard/dist/metrics.d.ts +0 -105
  271. package/packages/dashboard/dist/metrics.js +0 -193
  272. package/packages/dashboard/dist/needs-attention.d.ts +0 -24
  273. package/packages/dashboard/dist/needs-attention.js +0 -78
  274. package/packages/dashboard/dist/server.d.ts +0 -25
  275. package/packages/dashboard/dist/server.js +0 -5270
  276. package/packages/dashboard/dist/start.d.ts +0 -6
  277. package/packages/dashboard/dist/start.js +0 -13
  278. package/packages/dashboard/dist/types/threading.d.ts +0 -8
  279. package/packages/dashboard/dist/types/threading.js +0 -2
  280. package/packages/dashboard/dist/user-bridge.d.ts +0 -154
  281. package/packages/dashboard/dist/user-bridge.js +0 -372
  282. package/packages/dashboard/package.json +0 -65
  283. package/packages/dashboard/ui/app/app/onboarding/page.tsx +0 -394
  284. package/packages/dashboard/ui/app/app/page.tsx +0 -667
  285. package/packages/dashboard/ui/app/apple-icon.png +0 -0
  286. package/packages/dashboard/ui/app/cloud/link/page.tsx +0 -464
  287. package/packages/dashboard/ui/app/complete-profile/page.tsx +0 -204
  288. package/packages/dashboard/ui/app/connect-repos/page.tsx +0 -410
  289. package/packages/dashboard/ui/app/favicon.png +0 -0
  290. package/packages/dashboard/ui/app/globals.css +0 -59
  291. package/packages/dashboard/ui/app/history/page.tsx +0 -658
  292. package/packages/dashboard/ui/app/layout.tsx +0 -25
  293. package/packages/dashboard/ui/app/login/page.tsx +0 -424
  294. package/packages/dashboard/ui/app/metrics/page.tsx +0 -751
  295. package/packages/dashboard/ui/app/page.tsx +0 -59
  296. package/packages/dashboard/ui/app/pricing/page.tsx +0 -7
  297. package/packages/dashboard/ui/app/providers/page.tsx +0 -193
  298. package/packages/dashboard/ui/app/providers/setup/[provider]/ProviderSetupClient.tsx +0 -148
  299. package/packages/dashboard/ui/app/providers/setup/[provider]/constants.ts +0 -35
  300. package/packages/dashboard/ui/app/providers/setup/[provider]/page.tsx +0 -42
  301. package/packages/dashboard/ui/app/signup/page.tsx +0 -533
  302. package/packages/dashboard/ui/index.ts +0 -49
  303. package/packages/dashboard/ui/landing/LandingPage.tsx +0 -713
  304. package/packages/dashboard/ui/landing/PricingPage.tsx +0 -559
  305. package/packages/dashboard/ui/landing/index.ts +0 -6
  306. package/packages/dashboard/ui/landing/styles.css +0 -2850
  307. package/packages/dashboard/ui/lib/agent-merge.ts +0 -35
  308. package/packages/dashboard/ui/lib/api.ts +0 -1155
  309. package/packages/dashboard/ui/lib/cloudApi.ts +0 -877
  310. package/packages/dashboard/ui/lib/colors.ts +0 -218
  311. package/packages/dashboard/ui/lib/hierarchy.ts +0 -242
  312. package/packages/dashboard/ui/lib/stuckDetection.ts +0 -142
  313. package/packages/dashboard/ui/next-env.d.ts +0 -5
  314. package/packages/dashboard/ui/next.config.js +0 -41
  315. package/packages/dashboard/ui/package-lock.json +0 -2882
  316. package/packages/dashboard/ui/package.json +0 -33
  317. package/packages/dashboard/ui/postcss.config.js +0 -5
  318. package/packages/dashboard/ui/react-components/ActivityFeed.tsx +0 -216
  319. package/packages/dashboard/ui/react-components/AddWorkspaceModal.tsx +0 -170
  320. package/packages/dashboard/ui/react-components/AgentCard.tsx +0 -587
  321. package/packages/dashboard/ui/react-components/AgentList.tsx +0 -411
  322. package/packages/dashboard/ui/react-components/AgentProfilePanel.tsx +0 -564
  323. package/packages/dashboard/ui/react-components/App.tsx +0 -3033
  324. package/packages/dashboard/ui/react-components/BillingPanel.tsx +0 -922
  325. package/packages/dashboard/ui/react-components/BillingResult.tsx +0 -447
  326. package/packages/dashboard/ui/react-components/BroadcastComposer.tsx +0 -690
  327. package/packages/dashboard/ui/react-components/ChannelAdminPanel.tsx +0 -773
  328. package/packages/dashboard/ui/react-components/ChannelBrowser.tsx +0 -385
  329. package/packages/dashboard/ui/react-components/ChannelChat.tsx +0 -261
  330. package/packages/dashboard/ui/react-components/ChannelSidebar.tsx +0 -399
  331. package/packages/dashboard/ui/react-components/CloudSessionProvider.tsx +0 -130
  332. package/packages/dashboard/ui/react-components/CommandPalette.tsx +0 -815
  333. package/packages/dashboard/ui/react-components/ConfirmationDialog.tsx +0 -133
  334. package/packages/dashboard/ui/react-components/ConversationHistory.tsx +0 -518
  335. package/packages/dashboard/ui/react-components/CoordinatorPanel.tsx +0 -944
  336. package/packages/dashboard/ui/react-components/DecisionQueue.tsx +0 -717
  337. package/packages/dashboard/ui/react-components/DirectMessageView.tsx +0 -164
  338. package/packages/dashboard/ui/react-components/FileAutocomplete.tsx +0 -368
  339. package/packages/dashboard/ui/react-components/FleetOverview.tsx +0 -278
  340. package/packages/dashboard/ui/react-components/LogViewer.tsx +0 -310
  341. package/packages/dashboard/ui/react-components/LogViewerPanel.tsx +0 -482
  342. package/packages/dashboard/ui/react-components/Logo.tsx +0 -284
  343. package/packages/dashboard/ui/react-components/MentionAutocomplete.tsx +0 -384
  344. package/packages/dashboard/ui/react-components/MessageComposer.tsx +0 -457
  345. package/packages/dashboard/ui/react-components/MessageList.tsx +0 -649
  346. package/packages/dashboard/ui/react-components/MessageSenderName.tsx +0 -91
  347. package/packages/dashboard/ui/react-components/MessageStatusIndicator.tsx +0 -142
  348. package/packages/dashboard/ui/react-components/NewConversationModal.tsx +0 -400
  349. package/packages/dashboard/ui/react-components/NotificationToast.tsx +0 -488
  350. package/packages/dashboard/ui/react-components/OnlineUsersIndicator.tsx +0 -164
  351. package/packages/dashboard/ui/react-components/Pagination.tsx +0 -124
  352. package/packages/dashboard/ui/react-components/PricingPlans.tsx +0 -386
  353. package/packages/dashboard/ui/react-components/ProjectList.tsx +0 -625
  354. package/packages/dashboard/ui/react-components/ProviderAuthFlow.tsx +0 -853
  355. package/packages/dashboard/ui/react-components/ProviderConnectionList.tsx +0 -378
  356. package/packages/dashboard/ui/react-components/ProvisioningProgress.tsx +0 -730
  357. package/packages/dashboard/ui/react-components/RepoAccessPanel.tsx +0 -549
  358. package/packages/dashboard/ui/react-components/ServerCard.tsx +0 -202
  359. package/packages/dashboard/ui/react-components/SessionExpiredModal.tsx +0 -128
  360. package/packages/dashboard/ui/react-components/SpawnModal.tsx +0 -804
  361. package/packages/dashboard/ui/react-components/TaskAssignmentUI.tsx +0 -375
  362. package/packages/dashboard/ui/react-components/TerminalProviderSetup.tsx +0 -608
  363. package/packages/dashboard/ui/react-components/ThemeProvider.tsx +0 -325
  364. package/packages/dashboard/ui/react-components/ThinkingIndicator.tsx +0 -231
  365. package/packages/dashboard/ui/react-components/ThreadList.tsx +0 -198
  366. package/packages/dashboard/ui/react-components/ThreadPanel.tsx +0 -346
  367. package/packages/dashboard/ui/react-components/TrajectoryViewer.tsx +0 -698
  368. package/packages/dashboard/ui/react-components/TypingIndicator.tsx +0 -69
  369. package/packages/dashboard/ui/react-components/UsageBanner.tsx +0 -231
  370. package/packages/dashboard/ui/react-components/UserProfilePanel.tsx +0 -233
  371. package/packages/dashboard/ui/react-components/WorkspaceContext.tsx +0 -107
  372. package/packages/dashboard/ui/react-components/WorkspaceSelector.tsx +0 -234
  373. package/packages/dashboard/ui/react-components/WorkspaceStatusIndicator.tsx +0 -370
  374. package/packages/dashboard/ui/react-components/XTermInteractive.tsx +0 -510
  375. package/packages/dashboard/ui/react-components/XTermLogViewer.tsx +0 -719
  376. package/packages/dashboard/ui/react-components/channels/ChannelDialogs.tsx +0 -1411
  377. package/packages/dashboard/ui/react-components/channels/ChannelHeader.tsx +0 -317
  378. package/packages/dashboard/ui/react-components/channels/ChannelMessageList.tsx +0 -463
  379. package/packages/dashboard/ui/react-components/channels/ChannelViewV1.tsx +0 -146
  380. package/packages/dashboard/ui/react-components/channels/MessageInput.tsx +0 -288
  381. package/packages/dashboard/ui/react-components/channels/SearchInput.tsx +0 -172
  382. package/packages/dashboard/ui/react-components/channels/SearchResults.tsx +0 -336
  383. package/packages/dashboard/ui/react-components/channels/api.ts +0 -697
  384. package/packages/dashboard/ui/react-components/channels/index.ts +0 -76
  385. package/packages/dashboard/ui/react-components/channels/mockApi.ts +0 -344
  386. package/packages/dashboard/ui/react-components/channels/types.ts +0 -566
  387. package/packages/dashboard/ui/react-components/hooks/index.ts +0 -57
  388. package/packages/dashboard/ui/react-components/hooks/useAgentLogs.ts +0 -394
  389. package/packages/dashboard/ui/react-components/hooks/useAgents.ts +0 -127
  390. package/packages/dashboard/ui/react-components/hooks/useBroadcastDedup.ts +0 -86
  391. package/packages/dashboard/ui/react-components/hooks/useChannelAdmin.ts +0 -329
  392. package/packages/dashboard/ui/react-components/hooks/useChannelBrowser.ts +0 -239
  393. package/packages/dashboard/ui/react-components/hooks/useChannelCommands.ts +0 -138
  394. package/packages/dashboard/ui/react-components/hooks/useChannels.ts +0 -328
  395. package/packages/dashboard/ui/react-components/hooks/useDebounce.ts +0 -29
  396. package/packages/dashboard/ui/react-components/hooks/useDirectMessage.ts +0 -141
  397. package/packages/dashboard/ui/react-components/hooks/useMessages.ts +0 -309
  398. package/packages/dashboard/ui/react-components/hooks/useOrchestrator.ts +0 -364
  399. package/packages/dashboard/ui/react-components/hooks/usePinnedAgents.ts +0 -140
  400. package/packages/dashboard/ui/react-components/hooks/usePresence.ts +0 -340
  401. package/packages/dashboard/ui/react-components/hooks/useRecentRepos.ts +0 -130
  402. package/packages/dashboard/ui/react-components/hooks/useSession.ts +0 -209
  403. package/packages/dashboard/ui/react-components/hooks/useTrajectory.ts +0 -265
  404. package/packages/dashboard/ui/react-components/hooks/useWebSocket.ts +0 -169
  405. package/packages/dashboard/ui/react-components/hooks/useWorkspaceMembers.ts +0 -120
  406. package/packages/dashboard/ui/react-components/hooks/useWorkspaceRepos.ts +0 -73
  407. package/packages/dashboard/ui/react-components/hooks/useWorkspaceStatus.ts +0 -237
  408. package/packages/dashboard/ui/react-components/index.ts +0 -81
  409. package/packages/dashboard/ui/react-components/layout/Header.tsx +0 -355
  410. package/packages/dashboard/ui/react-components/layout/RepoContextHeader.tsx +0 -361
  411. package/packages/dashboard/ui/react-components/layout/Sidebar.archive.test.tsx +0 -126
  412. package/packages/dashboard/ui/react-components/layout/Sidebar.test.tsx +0 -691
  413. package/packages/dashboard/ui/react-components/layout/Sidebar.tsx +0 -930
  414. package/packages/dashboard/ui/react-components/layout/index.ts +0 -7
  415. package/packages/dashboard/ui/react-components/settings/BillingSettingsPanel.tsx +0 -564
  416. package/packages/dashboard/ui/react-components/settings/SettingsPage.tsx +0 -544
  417. package/packages/dashboard/ui/react-components/settings/TeamSettingsPanel.tsx +0 -560
  418. package/packages/dashboard/ui/react-components/settings/WorkspaceSettingsPanel.tsx +0 -1386
  419. package/packages/dashboard/ui/react-components/settings/index.ts +0 -11
  420. package/packages/dashboard/ui/react-components/settings/types.ts +0 -53
  421. package/packages/dashboard/ui/react-components/utils/messageFormatting.tsx +0 -370
  422. package/packages/dashboard/ui/tailwind.config.js +0 -148
  423. package/packages/dashboard/ui/types/index.ts +0 -304
  424. package/packages/dashboard/ui/types/threading.ts +0 -7
  425. package/packages/dashboard/ui-dist/404.html +0 -1
  426. package/packages/dashboard/ui-dist/_next/static/91mkGYq3qbG8WHE6VytQ8/_buildManifest.js +0 -1
  427. package/packages/dashboard/ui-dist/_next/static/91mkGYq3qbG8WHE6VytQ8/_ssgManifest.js +0 -1
  428. package/packages/dashboard/ui-dist/_next/static/T2rV14eEU5OweDeV29SvG/_buildManifest.js +0 -1
  429. package/packages/dashboard/ui-dist/_next/static/T2rV14eEU5OweDeV29SvG/_ssgManifest.js +0 -1
  430. package/packages/dashboard/ui-dist/_next/static/chunks/116-a883fca163f3a5bc.js +0 -1
  431. package/packages/dashboard/ui-dist/_next/static/chunks/117-c8afed19e821a35d.js +0 -2
  432. package/packages/dashboard/ui-dist/_next/static/chunks/282-980c2eb8fff20123.js +0 -1
  433. package/packages/dashboard/ui-dist/_next/static/chunks/320-a6304232cd0ee2ce.js +0 -1
  434. package/packages/dashboard/ui-dist/_next/static/chunks/532-bace199897eeab37.js +0 -9
  435. package/packages/dashboard/ui-dist/_next/static/chunks/631-16b905e5920f9b59.js +0 -1
  436. package/packages/dashboard/ui-dist/_next/static/chunks/648-acb2ff9f77cbfbd3.js +0 -1
  437. package/packages/dashboard/ui-dist/_next/static/chunks/766-2aea80818f7eb0d8.js +0 -1
  438. package/packages/dashboard/ui-dist/_next/static/chunks/83-26d2bde54616ee90.js +0 -1
  439. package/packages/dashboard/ui-dist/_next/static/chunks/847-f1f467060f32afff.js +0 -1
  440. package/packages/dashboard/ui-dist/_next/static/chunks/891-5cb1513eeb97a891.js +0 -1
  441. package/packages/dashboard/ui-dist/_next/static/chunks/app/_not-found/page-60501fddbafba9dc.js +0 -1
  442. package/packages/dashboard/ui-dist/_next/static/chunks/app/app/onboarding/page-9914652442f7e4fb.js +0 -1
  443. package/packages/dashboard/ui-dist/_next/static/chunks/app/app/page-366fb7c078d4e9e0.js +0 -1
  444. package/packages/dashboard/ui-dist/_next/static/chunks/app/cloud/link/page-fa1d5842aa90e8a6.js +0 -1
  445. package/packages/dashboard/ui-dist/_next/static/chunks/app/complete-profile/page-dd64bbdf66b639cd.js +0 -1
  446. package/packages/dashboard/ui-dist/_next/static/chunks/app/connect-repos/page-113060009ef35bc2.js +0 -1
  447. package/packages/dashboard/ui-dist/_next/static/chunks/app/history/page-9965d2483011b846.js +0 -1
  448. package/packages/dashboard/ui-dist/_next/static/chunks/app/layout-6b91e33784c20610.js +0 -1
  449. package/packages/dashboard/ui-dist/_next/static/chunks/app/login/page-435eceb0073be027.js +0 -1
  450. package/packages/dashboard/ui-dist/_next/static/chunks/app/metrics/page-1e37ef8e73940b40.js +0 -1
  451. package/packages/dashboard/ui-dist/_next/static/chunks/app/page-8119d4246743574e.js +0 -1
  452. package/packages/dashboard/ui-dist/_next/static/chunks/app/pricing/page-9db3ebdfa567a7c9.js +0 -1
  453. package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/page-ecb16ffd3b36262b.js +0 -1
  454. package/packages/dashboard/ui-dist/_next/static/chunks/app/providers/setup/[provider]/page-4dbe33f0f7691b7c.js +0 -1
  455. package/packages/dashboard/ui-dist/_next/static/chunks/app/signup/page-c7a0a28341365ae0.js +0 -1
  456. package/packages/dashboard/ui-dist/_next/static/chunks/e868780c-48e5f147c90a3a41.js +0 -18
  457. package/packages/dashboard/ui-dist/_next/static/chunks/fd9d1056-609918ca7b6280bb.js +0 -1
  458. package/packages/dashboard/ui-dist/_next/static/chunks/framework-f66176bb897dc684.js +0 -1
  459. package/packages/dashboard/ui-dist/_next/static/chunks/main-311c3db74dcfadb7.js +0 -1
  460. package/packages/dashboard/ui-dist/_next/static/chunks/main-app-fdbeb09028f57c9f.js +0 -1
  461. package/packages/dashboard/ui-dist/_next/static/chunks/pages/_app-72b849fbd24ac258.js +0 -1
  462. package/packages/dashboard/ui-dist/_next/static/chunks/pages/_error-7ba65e1336b92748.js +0 -1
  463. package/packages/dashboard/ui-dist/_next/static/chunks/polyfills-42372ed130431b0a.js +0 -1
  464. package/packages/dashboard/ui-dist/_next/static/chunks/webpack-1cdd8ed57114d5e1.js +0 -1
  465. package/packages/dashboard/ui-dist/_next/static/css/4034f236dd1a3178.css +0 -1
  466. package/packages/dashboard/ui-dist/_next/static/css/6892f8422896ef7a.css +0 -1
  467. package/packages/dashboard/ui-dist/_next/static/l8L2OscDSR2vsMIlWcC48/_buildManifest.js +0 -1
  468. package/packages/dashboard/ui-dist/_next/static/l8L2OscDSR2vsMIlWcC48/_ssgManifest.js +0 -1
  469. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo-128.png +0 -0
  470. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo-256.png +0 -0
  471. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo-32.png +0 -0
  472. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo-512.png +0 -0
  473. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo-64.png +0 -0
  474. package/packages/dashboard/ui-dist/alt-logos/agent-relay-logo.svg +0 -45
  475. package/packages/dashboard/ui-dist/alt-logos/logo.svg +0 -38
  476. package/packages/dashboard/ui-dist/alt-logos/monogram-logo-128.png +0 -0
  477. package/packages/dashboard/ui-dist/alt-logos/monogram-logo-256.png +0 -0
  478. package/packages/dashboard/ui-dist/alt-logos/monogram-logo-32.png +0 -0
  479. package/packages/dashboard/ui-dist/alt-logos/monogram-logo-512.png +0 -0
  480. package/packages/dashboard/ui-dist/alt-logos/monogram-logo-64.png +0 -0
  481. package/packages/dashboard/ui-dist/alt-logos/monogram-logo.svg +0 -38
  482. package/packages/dashboard/ui-dist/app/onboarding.html +0 -1
  483. package/packages/dashboard/ui-dist/app/onboarding.txt +0 -7
  484. package/packages/dashboard/ui-dist/app.html +0 -1
  485. package/packages/dashboard/ui-dist/app.txt +0 -7
  486. package/packages/dashboard/ui-dist/apple-icon.png +0 -0
  487. package/packages/dashboard/ui-dist/cloud/link.html +0 -1
  488. package/packages/dashboard/ui-dist/cloud/link.txt +0 -7
  489. package/packages/dashboard/ui-dist/complete-profile.html +0 -5
  490. package/packages/dashboard/ui-dist/complete-profile.txt +0 -7
  491. package/packages/dashboard/ui-dist/connect-repos.html +0 -1
  492. package/packages/dashboard/ui-dist/connect-repos.txt +0 -7
  493. package/packages/dashboard/ui-dist/history.html +0 -1
  494. package/packages/dashboard/ui-dist/history.txt +0 -7
  495. package/packages/dashboard/ui-dist/index.html +0 -1
  496. package/packages/dashboard/ui-dist/index.txt +0 -7
  497. package/packages/dashboard/ui-dist/login.html +0 -5
  498. package/packages/dashboard/ui-dist/login.txt +0 -7
  499. package/packages/dashboard/ui-dist/metrics.html +0 -1
  500. package/packages/dashboard/ui-dist/metrics.txt +0 -7
  501. package/packages/dashboard/ui-dist/pricing.html +0 -13
  502. package/packages/dashboard/ui-dist/pricing.txt +0 -7
  503. package/packages/dashboard/ui-dist/providers/setup/claude.html +0 -1
  504. package/packages/dashboard/ui-dist/providers/setup/claude.txt +0 -8
  505. package/packages/dashboard/ui-dist/providers/setup/codex.html +0 -1
  506. package/packages/dashboard/ui-dist/providers/setup/codex.txt +0 -8
  507. package/packages/dashboard/ui-dist/providers/setup/cursor.html +0 -1
  508. package/packages/dashboard/ui-dist/providers/setup/cursor.txt +0 -8
  509. package/packages/dashboard/ui-dist/providers.html +0 -1
  510. package/packages/dashboard/ui-dist/providers.txt +0 -7
  511. package/packages/dashboard/ui-dist/signup.html +0 -6
  512. package/packages/dashboard/ui-dist/signup.txt +0 -7
  513. package/packages/dashboard-server/dist/health-worker-manager.d.ts +0 -62
  514. package/packages/dashboard-server/dist/health-worker-manager.js +0 -144
  515. package/packages/dashboard-server/dist/health-worker.d.ts +0 -9
  516. package/packages/dashboard-server/dist/health-worker.js +0 -79
  517. package/packages/dashboard-server/dist/index.d.ts +0 -18
  518. package/packages/dashboard-server/dist/index.js +0 -17
  519. package/packages/dashboard-server/dist/metrics.d.ts +0 -105
  520. package/packages/dashboard-server/dist/metrics.js +0 -193
  521. package/packages/dashboard-server/dist/needs-attention.d.ts +0 -24
  522. package/packages/dashboard-server/dist/needs-attention.js +0 -78
  523. package/packages/dashboard-server/dist/server.d.ts +0 -25
  524. package/packages/dashboard-server/dist/server.js +0 -5158
  525. package/packages/dashboard-server/dist/start.d.ts +0 -6
  526. package/packages/dashboard-server/dist/start.js +0 -13
  527. package/packages/dashboard-server/dist/types/threading.d.ts +0 -8
  528. package/packages/dashboard-server/dist/types/threading.js +0 -2
  529. package/packages/dashboard-server/dist/user-bridge.d.ts +0 -158
  530. package/packages/dashboard-server/dist/user-bridge.js +0 -390
  531. package/packages/dashboard-server/package.json +0 -55
  532. package/scripts/run-migrations.js +0 -43
  533. package/scripts/setup-stripe-products.ts +0 -312
  534. package/scripts/verify-schema.js +0 -134
@@ -1,2034 +0,0 @@
1
- /**
2
- * Agent Relay Cloud - Express Server
3
- */
4
- import express from 'express';
5
- import session from 'express-session';
6
- import cors from 'cors';
7
- import helmet from 'helmet';
8
- import crypto from 'crypto';
9
- import path from 'node:path';
10
- import http from 'node:http';
11
- import fs from 'node:fs';
12
- import { fileURLToPath } from 'node:url';
13
- import { createClient } from 'redis';
14
- import { RedisStore } from 'connect-redis';
15
- import { WebSocketServer, WebSocket } from 'ws';
16
- import { getConfig } from './config.js';
17
- import { runMigrations } from './db/index.js';
18
- import { getScalingOrchestrator, getComputeEnforcementService, getIntroExpirationService, getWorkspaceKeepaliveService } from './services/index.js';
19
- const __filename = fileURLToPath(import.meta.url);
20
- const __dirname = path.dirname(__filename);
21
- // API routers
22
- import { authRouter, requireAuth } from './api/auth.js';
23
- import { providersRouter } from './api/providers.js';
24
- import { workspacesRouter } from './api/workspaces.js';
25
- import { reposRouter } from './api/repos.js';
26
- import { onboardingRouter } from './api/onboarding.js';
27
- import { teamsRouter } from './api/teams.js';
28
- import { billingRouter } from './api/billing.js';
29
- import { usageRouter } from './api/usage.js';
30
- import { coordinatorsRouter } from './api/coordinators.js';
31
- import { daemonsRouter } from './api/daemons.js';
32
- import { monitoringRouter } from './api/monitoring.js';
33
- import { testHelpersRouter } from './api/test-helpers.js';
34
- import { webhooksRouter } from './api/webhooks.js';
35
- import { githubAppRouter } from './api/github-app.js';
36
- import { nangoAuthRouter } from './api/nango-auth.js';
37
- import { emailAuthRouter } from './api/email-auth.js';
38
- import { gitRouter } from './api/git.js';
39
- import { sessionsRouter } from './api/sessions.js';
40
- import { codexAuthHelperRouter } from './api/codex-auth-helper.js';
41
- import { adminRouter } from './api/admin.js';
42
- import { consensusRouter } from './api/consensus.js';
43
- import { db } from './db/index.js';
44
- import { validateSshSecurityConfig } from './services/ssh-security.js';
45
- import { registerUserPresence, unregisterUserPresence, updateUserLastSeen } from './services/presence-registry.js';
46
- import { cloudMessageBus } from './services/cloud-message-bus.js';
47
- /**
48
- * Proxy a request to the user's primary running workspace
49
- */
50
- async function proxyToUserWorkspace(req, res, path, options) {
51
- const userId = req.session.userId;
52
- if (!userId) {
53
- res.status(401).json({ error: 'Unauthorized' });
54
- return;
55
- }
56
- try {
57
- // Find user's running workspace
58
- const workspaces = await db.workspaces.findByUserId(userId);
59
- const runningWorkspace = workspaces.find(w => w.status === 'running' && w.publicUrl);
60
- if (!runningWorkspace || !runningWorkspace.publicUrl) {
61
- res.status(404).json({ error: 'No running workspace found', success: false });
62
- return;
63
- }
64
- // Proxy to workspace
65
- const targetUrl = `${runningWorkspace.publicUrl}${path}`;
66
- console.log(`[workspace-proxy] ${options?.method || 'GET'} ${targetUrl}`);
67
- const fetchOptions = {
68
- method: options?.method || 'GET',
69
- headers: { 'Content-Type': 'application/json' },
70
- };
71
- if (options?.body) {
72
- fetchOptions.body = JSON.stringify(options.body);
73
- }
74
- const proxyRes = await fetch(targetUrl, fetchOptions);
75
- const contentType = proxyRes.headers.get('content-type') || '';
76
- console.log(`[workspace-proxy] Response: ${proxyRes.status} ${proxyRes.statusText}, content-type: ${contentType}`);
77
- // Check if response is JSON
78
- if (!contentType.includes('application/json')) {
79
- const text = await proxyRes.text();
80
- console.error(`[workspace-proxy] Non-JSON response: ${text.substring(0, 200)}`);
81
- res.status(502).json({ error: 'Workspace returned non-JSON response', success: false });
82
- return;
83
- }
84
- const data = await proxyRes.json();
85
- res.status(proxyRes.status).json(data);
86
- }
87
- catch (error) {
88
- console.error('[workspace-proxy] Error:', error);
89
- res.status(500).json({ error: 'Failed to proxy request to workspace', success: false });
90
- }
91
- }
92
- export async function createServer() {
93
- const config = getConfig();
94
- // Validate security configuration at startup
95
- validateSshSecurityConfig();
96
- const app = express();
97
- app.set('trust proxy', 1);
98
- // Redis client for sessions
99
- const redisClient = createClient({ url: config.redisUrl });
100
- redisClient.on('error', (err) => {
101
- console.error('[redis] error', err);
102
- });
103
- redisClient.on('reconnecting', () => {
104
- console.warn('[redis] reconnecting...');
105
- });
106
- await redisClient.connect();
107
- // Middleware
108
- // Configure helmet to allow Next.js inline scripts and Nango Connect UI
109
- app.use(helmet({
110
- contentSecurityPolicy: {
111
- directives: {
112
- defaultSrc: ["'self'"],
113
- scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://connect.nango.dev"],
114
- styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://connect.nango.dev"],
115
- fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
116
- imgSrc: ["'self'", "data:", "https:", "blob:"],
117
- connectSrc: ["'self'", "wss:", "ws:", "https:", "https://api.nango.dev", "https://connect.nango.dev"],
118
- frameSrc: ["'self'", "https://connect.nango.dev", "https://github.com"],
119
- childSrc: ["'self'", "https://connect.nango.dev", "blob:"],
120
- workerSrc: ["'self'", "blob:"],
121
- },
122
- },
123
- }));
124
- app.use(cors({
125
- origin: config.publicUrl,
126
- credentials: true,
127
- }));
128
- // Custom JSON parser that preserves raw body for webhook signature verification
129
- // Increase limit to 10mb for base64 image uploads (screenshots)
130
- app.use(express.json({
131
- limit: '10mb',
132
- verify: (req, _res, buf) => {
133
- // Store raw body for webhook signature verification
134
- req.rawBody = buf.toString();
135
- },
136
- }));
137
- // Session middleware
138
- app.use(session({
139
- store: new RedisStore({ client: redisClient }),
140
- secret: config.sessionSecret,
141
- resave: false,
142
- saveUninitialized: false,
143
- cookie: {
144
- secure: config.publicUrl.startsWith('https'),
145
- httpOnly: true,
146
- sameSite: 'lax',
147
- maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
148
- },
149
- }));
150
- // Basic audit log (request/response)
151
- app.use((req, res, next) => {
152
- const started = Date.now();
153
- res.on('finish', () => {
154
- const duration = Date.now() - started;
155
- const user = req.session?.userId ?? 'anon';
156
- console.log(`[audit] ${req.method} ${req.originalUrl} ${res.statusCode} user=${user} ip=${req.ip} ${duration}ms`);
157
- });
158
- next();
159
- });
160
- // Simple in-memory rate limiting per IP
161
- const RATE_LIMIT_WINDOW_MS = 60_000;
162
- // Higher limit in development mode
163
- const RATE_LIMIT_MAX = process.env.NODE_ENV === 'development' ? 1000 : 300;
164
- const rateLimits = new Map();
165
- // Track channel WebSocket clients by workspaceId for broadcasting channel events
166
- const channelClientsByWorkspace = new Map();
167
- /**
168
- * Broadcast a channel event to all connected clients in a workspace.
169
- * Used for notifying clients about channel creation, archiving, etc.
170
- */
171
- const broadcastToWorkspaceChannelClients = (workspaceId, message) => {
172
- const clients = channelClientsByWorkspace.get(workspaceId);
173
- if (!clients || clients.size === 0) {
174
- console.log(`[ws/channels] No clients connected for workspace ${workspaceId}, skipping broadcast`);
175
- return;
176
- }
177
- const payload = JSON.stringify(message);
178
- let sentCount = 0;
179
- for (const client of clients) {
180
- if (client.readyState === WebSocket.OPEN) {
181
- client.send(payload);
182
- sentCount++;
183
- }
184
- }
185
- console.log(`[ws/channels] Broadcast to ${sentCount}/${clients.size} clients in workspace ${workspaceId}`);
186
- };
187
- app.use((req, res, next) => {
188
- // Skip rate limiting for localhost in development
189
- if (process.env.NODE_ENV === 'development') {
190
- const ip = req.ip || '';
191
- if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
192
- return next();
193
- }
194
- }
195
- const now = Date.now();
196
- const key = req.ip || 'unknown';
197
- const entry = rateLimits.get(key);
198
- if (!entry || entry.resetAt <= now) {
199
- rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
200
- }
201
- else {
202
- entry.count += 1;
203
- }
204
- const current = rateLimits.get(key);
205
- res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX.toString());
206
- res.setHeader('X-RateLimit-Remaining', Math.max(RATE_LIMIT_MAX - current.count, 0).toString());
207
- res.setHeader('X-RateLimit-Reset', Math.floor(current.resetAt / 1000).toString());
208
- if (current.count > RATE_LIMIT_MAX) {
209
- return res.status(429).json({ error: 'Too many requests' });
210
- }
211
- // Opportunistic cleanup
212
- if (rateLimits.size > 5000) {
213
- for (const [ip, data] of rateLimits) {
214
- if (data.resetAt <= now) {
215
- rateLimits.delete(ip);
216
- }
217
- }
218
- }
219
- next();
220
- });
221
- // Lightweight CSRF protection using session token
222
- const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
223
- // Paths exempt from CSRF (webhooks from external services, workspace proxy, local auth callbacks, admin API)
224
- const CSRF_EXEMPT_PATHS = [
225
- '/api/webhooks/',
226
- '/api/auth/nango/webhook',
227
- '/api/auth/codex-helper/callback',
228
- '/api/admin/', // Admin API uses X-Admin-Secret header auth
229
- '/api/channels/', // Channels API routes to local daemon, not cloud
230
- ];
231
- // Additional pattern for workspace proxy routes (contains /proxy/)
232
- const isWorkspaceProxyRoute = (path) => /^\/api\/workspaces\/[^/]+\/proxy\//.test(path);
233
- app.use((req, res, next) => {
234
- // Skip CSRF for webhook endpoints and workspace proxy routes
235
- const isExemptPath = CSRF_EXEMPT_PATHS.some(exemptPath => req.path.startsWith(exemptPath));
236
- if (isExemptPath || isWorkspaceProxyRoute(req.path)) {
237
- return next();
238
- }
239
- if (!req.session)
240
- return res.status(500).json({ error: 'Session unavailable' });
241
- // Generate CSRF token if not present
242
- // Use session.save() to ensure the session is persisted even for unauthenticated users
243
- // This is necessary because saveUninitialized: false won't auto-save new sessions
244
- if (!req.session.csrfToken) {
245
- req.session.csrfToken = crypto.randomBytes(32).toString('hex');
246
- // Explicitly save session to persist the CSRF token
247
- req.session.save((err) => {
248
- if (err) {
249
- console.error('[csrf] Failed to save session:', err);
250
- }
251
- });
252
- }
253
- res.setHeader('X-CSRF-Token', req.session.csrfToken);
254
- if (SAFE_METHODS.has(req.method.toUpperCase())) {
255
- return next();
256
- }
257
- // Skip CSRF for Bearer-authenticated endpoints (daemon API, test helpers)
258
- const authHeader = req.get('authorization');
259
- if (authHeader?.startsWith('Bearer ')) {
260
- return next();
261
- }
262
- // Skip CSRF for admin API key authenticated requests
263
- const adminSecret = req.get('x-admin-secret');
264
- if (adminSecret) {
265
- return next();
266
- }
267
- // Skip CSRF for test endpoints in non-production
268
- if (process.env.NODE_ENV !== 'production' && req.path.startsWith('/api/test/')) {
269
- return next();
270
- }
271
- const token = req.get('x-csrf-token');
272
- if (!token || token !== req.session.csrfToken) {
273
- console.log(`[csrf] Token mismatch: received=${token?.substring(0, 8)}... expected=${req.session.csrfToken?.substring(0, 8)}...`);
274
- return res.status(403).json({
275
- error: 'CSRF token invalid or missing',
276
- code: 'CSRF_MISMATCH',
277
- });
278
- }
279
- return next();
280
- });
281
- // Health check
282
- app.get('/health', (req, res) => {
283
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
284
- });
285
- // API routes
286
- //
287
- // IMPORTANT: Route order matters! Routes with non-session auth (webhooks, API keys, tokens)
288
- // must be mounted BEFORE teamsRouter, which catches all /api/* with requireAuth.
289
- //
290
- // --- Routes with alternative auth (must be before teamsRouter) ---
291
- app.use('/api/auth', authRouter); // Login endpoints (public)
292
- app.use('/api/auth/email', emailAuthRouter); // Email/password authentication
293
- app.use('/api/auth/nango', nangoAuthRouter); // Nango webhook (signature verification)
294
- app.use('/api/auth/codex-helper', codexAuthHelperRouter);
295
- app.use('/api/git', gitRouter); // Workspace token auth
296
- app.use('/api/sessions', sessionsRouter); // Workspace token auth (agent session persistence)
297
- app.use('/api/webhooks', webhooksRouter); // GitHub webhooks (signature verification)
298
- app.use('/api/monitoring', monitoringRouter); // Daemon API key auth endpoints
299
- app.use('/api/daemons', daemonsRouter); // Daemon API key auth endpoints
300
- app.use('/api/admin', adminRouter); // Admin API secret auth
301
- // --- Routes with session auth ---
302
- app.use('/api/providers', providersRouter);
303
- app.use('/api/workspaces', workspacesRouter);
304
- app.use('/api', consensusRouter); // Consensus API (nested under /api/workspaces/:id/consensus)
305
- app.use('/api/repos', reposRouter);
306
- app.use('/api/onboarding', onboardingRouter);
307
- app.use('/api/billing', billingRouter);
308
- app.use('/api/usage', usageRouter);
309
- app.use('/api/project-groups', coordinatorsRouter);
310
- app.use('/api/github-app', githubAppRouter);
311
- // Trajectory proxy routes - auto-detect user's workspace and forward
312
- // These are convenience routes so the dashboard doesn't need to know the workspace ID
313
- // MUST be before teamsRouter to avoid being caught by its catch-all
314
- app.get('/api/trajectory', requireAuth, async (req, res) => {
315
- await proxyToUserWorkspace(req, res, '/api/trajectory');
316
- });
317
- app.get('/api/trajectory/steps', requireAuth, async (req, res) => {
318
- const queryString = req.query.trajectoryId
319
- ? `?trajectoryId=${encodeURIComponent(req.query.trajectoryId)}`
320
- : '';
321
- await proxyToUserWorkspace(req, res, `/api/trajectory/steps${queryString}`);
322
- });
323
- app.get('/api/trajectory/history', requireAuth, async (req, res) => {
324
- await proxyToUserWorkspace(req, res, '/api/trajectory/history');
325
- });
326
- // Channel proxy routes - forward to local dashboard-server (not workspace)
327
- // Channels talk to the local daemon, so they need the local dashboard-server
328
- // MUST be before teamsRouter to avoid being caught by its catch-all
329
- // Auto-detect local dashboard URL if not configured
330
- let localDashboardUrl = config.localDashboardUrl;
331
- const defaultPorts = [3889, 3888, 3890]; // 3889 first (common alternate port)
332
- async function detectLocalDashboard() {
333
- console.log('[channel-proxy] Auto-detecting local dashboard...');
334
- for (const port of defaultPorts) {
335
- try {
336
- const controller = new AbortController();
337
- const timeout = setTimeout(() => controller.abort(), 2000);
338
- const res = await fetch(`http://localhost:${port}/health`, {
339
- method: 'GET',
340
- signal: controller.signal,
341
- });
342
- clearTimeout(timeout);
343
- if (res.ok) {
344
- console.log(`[channel-proxy] Detected local dashboard at http://localhost:${port}`);
345
- return `http://localhost:${port}`;
346
- }
347
- console.log(`[channel-proxy] Port ${port}: responded but not OK (${res.status})`);
348
- }
349
- catch (err) {
350
- const msg = err instanceof Error ? err.message : String(err);
351
- console.log(`[channel-proxy] Port ${port}: ${msg}`);
352
- }
353
- }
354
- console.log('[channel-proxy] No local dashboard detected, using fallback');
355
- return null;
356
- }
357
- // Detect at startup if not configured - use a promise to ensure detection completes before first use
358
- let detectionPromise = null;
359
- if (localDashboardUrl) {
360
- console.log(`[channel-proxy] Using configured dashboard URL: ${localDashboardUrl}`);
361
- }
362
- else {
363
- // Start detection immediately
364
- detectionPromise = detectLocalDashboard().then((detected) => {
365
- if (detected) {
366
- localDashboardUrl = detected;
367
- }
368
- else {
369
- localDashboardUrl = 'http://localhost:3889';
370
- console.log(`[channel-proxy] Falling back to ${localDashboardUrl}`);
371
- }
372
- });
373
- }
374
- async function getLocalDashboardUrl() {
375
- // Wait for detection to complete if it's in progress
376
- if (detectionPromise) {
377
- await detectionPromise;
378
- detectionPromise = null;
379
- }
380
- // If still not set (shouldn't happen), detect now
381
- if (!localDashboardUrl) {
382
- const detected = await detectLocalDashboard();
383
- localDashboardUrl = detected || 'http://localhost:3889';
384
- }
385
- return localDashboardUrl;
386
- }
387
- async function proxyToLocalDashboard(req, res, path, options) {
388
- try {
389
- const dashboardUrl = await getLocalDashboardUrl();
390
- const targetUrl = `${dashboardUrl}${path}`;
391
- console.log(`[channel-proxy] ${options?.method || 'GET'} ${targetUrl}`);
392
- const fetchOptions = {
393
- method: options?.method || 'GET',
394
- headers: { 'Content-Type': 'application/json' },
395
- };
396
- if (options?.body) {
397
- fetchOptions.body = JSON.stringify(options.body);
398
- }
399
- const proxyRes = await fetch(targetUrl, fetchOptions);
400
- const contentType = proxyRes.headers.get('content-type') || '';
401
- if (!contentType.includes('application/json')) {
402
- const text = await proxyRes.text();
403
- console.error(`[channel-proxy] Non-JSON response from ${targetUrl}: ${text.substring(0, 100)}`);
404
- res.status(502).json({
405
- error: 'Local dashboard not available or returned non-JSON response',
406
- hint: 'Make sure the dashboard-server is running (agent-relay start)',
407
- });
408
- return;
409
- }
410
- const data = await proxyRes.json();
411
- res.status(proxyRes.status).json(data);
412
- }
413
- catch (error) {
414
- console.error('[channel-proxy] Error:', error);
415
- res.status(502).json({
416
- error: 'Failed to connect to local dashboard',
417
- hint: 'Make sure the dashboard-server is running (agent-relay start)',
418
- });
419
- }
420
- }
421
- // =========================================================================
422
- // Channel metadata endpoints (stored in cloud PostgreSQL)
423
- // =========================================================================
424
- /**
425
- * GET /api/channels - List channels for a workspace
426
- * Channels are workspace-scoped, not user-scoped
427
- */
428
- app.get('/api/channels', requireAuth, async (req, res) => {
429
- try {
430
- const workspaceId = req.query.workspaceId;
431
- if (!workspaceId) {
432
- return res.status(400).json({ error: 'workspaceId query param required' });
433
- }
434
- // Verify user has access to this workspace
435
- const userId = req.session.userId;
436
- const workspace = await db.workspaces.findById(workspaceId);
437
- if (!workspace) {
438
- return res.status(404).json({ error: 'Workspace not found' });
439
- }
440
- if (workspace.userId !== userId) {
441
- const membership = await db.workspaceMembers.findMembership(workspaceId, userId);
442
- if (!membership || !membership.acceptedAt) {
443
- return res.status(403).json({ error: 'Access denied' });
444
- }
445
- }
446
- const allChannels = await db.channels.findByWorkspaceId(workspaceId);
447
- const activeChannels = allChannels.filter(c => c.status === 'active');
448
- const archivedChannels = allChannels.filter(c => c.status === 'archived');
449
- // Get member counts for all channels in one query
450
- const channelUuids = allChannels.map(c => c.id);
451
- const memberCounts = await db.channelMembers.countByChannelIds(channelUuids);
452
- // Transform to API response format
453
- // IMPORTANT: Channel IDs must include # prefix to match daemon convention
454
- // The daemon uses "#channelName" format for CHANNEL_MESSAGE routing
455
- const mapChannel = (c) => ({
456
- id: c.channelId.startsWith('#') ? c.channelId : `#${c.channelId}`,
457
- name: c.name,
458
- description: c.description,
459
- visibility: c.visibility,
460
- status: c.status,
461
- createdAt: c.createdAt.toISOString(),
462
- createdBy: c.createdBy || '__system__',
463
- lastActivityAt: c.lastActivityAt?.toISOString(),
464
- memberCount: memberCounts.get(c.id) ?? 0,
465
- unreadCount: 0,
466
- hasMentions: false,
467
- isDm: c.channelId.startsWith('dm:'),
468
- });
469
- res.json({
470
- channels: activeChannels.map(mapChannel),
471
- archivedChannels: archivedChannels.map(mapChannel),
472
- });
473
- }
474
- catch (error) {
475
- console.error('[channels] Error listing channels:', error);
476
- res.status(500).json({ error: 'Failed to list channels' });
477
- }
478
- });
479
- /**
480
- * POST /api/channels - Create a new channel
481
- */
482
- app.post('/api/channels', requireAuth, express.json(), async (req, res) => {
483
- try {
484
- const { name, description, isPrivate, workspaceId, invites } = req.body;
485
- if (!name || !workspaceId) {
486
- return res.status(400).json({ error: 'name and workspaceId are required' });
487
- }
488
- // Verify user has access to this workspace
489
- const userId = req.session.userId;
490
- const workspace = await db.workspaces.findById(workspaceId);
491
- if (!workspace) {
492
- return res.status(404).json({ error: 'Workspace not found' });
493
- }
494
- if (workspace.userId !== userId) {
495
- const membership = await db.workspaceMembers.findMembership(workspaceId, userId);
496
- if (!membership || !membership.acceptedAt) {
497
- return res.status(403).json({ error: 'Access denied' });
498
- }
499
- }
500
- // Get creator username from session
501
- const user = await db.users.findById(userId);
502
- const createdBy = user?.githubUsername || 'unknown';
503
- // Normalize channel name (remove # prefix if present)
504
- const channelId = name.startsWith('#') ? name.slice(1) : name;
505
- const displayName = channelId;
506
- // Check if channel already exists
507
- const existing = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
508
- if (existing) {
509
- return res.status(409).json({ error: 'Channel already exists' });
510
- }
511
- // Create the channel
512
- console.log('[channels] Creating channel:', { workspaceId, channelId, displayName, createdBy });
513
- let channel;
514
- try {
515
- channel = await db.channels.create({
516
- workspaceId,
517
- channelId,
518
- name: displayName,
519
- description,
520
- visibility: isPrivate ? 'private' : 'public',
521
- status: 'active',
522
- createdBy,
523
- });
524
- console.log('[channels] Channel created:', channel.id);
525
- }
526
- catch (createError) {
527
- const err = createError;
528
- console.error('[channels] Failed to create channel in database:', {
529
- message: err.message,
530
- stack: err.stack,
531
- });
532
- throw createError;
533
- }
534
- // Add creator as owner
535
- try {
536
- await db.channelMembers.addMember({
537
- channelId: channel.id,
538
- memberId: createdBy,
539
- memberType: 'user',
540
- role: 'owner',
541
- });
542
- console.log('[channels] Added creator as owner:', createdBy);
543
- }
544
- catch (memberError) {
545
- const err = memberError;
546
- console.error('[channels] Failed to add channel member:', {
547
- message: err.message,
548
- stack: err.stack,
549
- channelId: channel.id,
550
- memberId: createdBy,
551
- });
552
- throw memberError;
553
- }
554
- // Handle invites if provided
555
- // Supports: comma-separated string, array of strings, or array of {id, type} objects
556
- const addedMembers = [
557
- { id: createdBy, type: 'user', role: 'owner' },
558
- ];
559
- const memberWarnings = [];
560
- if (invites) {
561
- let inviteList = [];
562
- if (typeof invites === 'string') {
563
- // Comma-separated string: "alice,bob" -> all as users
564
- inviteList = invites.split(',')
565
- .map((s) => s.trim())
566
- .filter(Boolean)
567
- .map(id => ({ id, type: 'user' }));
568
- }
569
- else if (Array.isArray(invites)) {
570
- // Array of strings or objects
571
- inviteList = invites.map((inv) => {
572
- if (typeof inv === 'string') {
573
- return { id: inv, type: 'user' };
574
- }
575
- return {
576
- id: inv.id,
577
- type: (inv.type === 'agent' ? 'agent' : 'user'),
578
- };
579
- });
580
- }
581
- for (const invitee of inviteList) {
582
- await db.channelMembers.addMember({
583
- channelId: channel.id,
584
- memberId: invitee.id,
585
- memberType: invitee.type,
586
- role: 'member',
587
- invitedBy: createdBy,
588
- });
589
- addedMembers.push({ id: invitee.id, type: invitee.type, role: 'member' });
590
- // For agent members, sync to workspace daemon's in-memory channel membership
591
- // IMPORTANT: Must use workspace.publicUrl where agents are connected
592
- if (invitee.type === 'agent') {
593
- try {
594
- const channelName = channelId.startsWith('#') ? channelId : `#${channelId}`;
595
- // Route to workspace's dashboard where agents are connected
596
- const dashboardUrl = workspace.publicUrl || await getLocalDashboardUrl();
597
- const joinResponse = await fetch(`${dashboardUrl}/api/channels/admin-join`, {
598
- method: 'POST',
599
- headers: { 'Content-Type': 'application/json' },
600
- body: JSON.stringify({ channel: channelName, member: invitee.id, workspaceId }),
601
- });
602
- const joinResult = await joinResponse.json();
603
- console.log(`[channels] Synced agent ${invitee.id} to channel ${channelName} via workspace daemon`);
604
- // Check for warning about unconnected agent
605
- if (joinResult.warning) {
606
- memberWarnings.push({ member: invitee.id, warning: joinResult.warning });
607
- console.log(`[channels] Warning for ${invitee.id}: ${joinResult.warning}`);
608
- }
609
- }
610
- catch (err) {
611
- // Non-fatal - daemon sync is best-effort
612
- console.warn(`[channels] Failed to sync agent ${invitee.id} to daemon:`, err);
613
- }
614
- }
615
- }
616
- }
617
- // Subscribe the channel creator to the daemon for real-time messages
618
- // Use # prefix for channel ID to match daemon convention
619
- const normalizedChannelId = channel.channelId.startsWith('#') ? channel.channelId : `#${channel.channelId}`;
620
- try {
621
- const workspace = await db.workspaces.findById(workspaceId);
622
- const dashboardUrl = workspace?.publicUrl || await getLocalDashboardUrl();
623
- await fetch(`${dashboardUrl}/api/channels/subscribe`, {
624
- method: 'POST',
625
- headers: { 'Content-Type': 'application/json' },
626
- body: JSON.stringify({
627
- username: createdBy,
628
- channels: [normalizedChannelId],
629
- workspaceId,
630
- }),
631
- });
632
- console.log(`[channels] Subscribed creator ${createdBy} to ${normalizedChannelId} on workspace daemon`);
633
- }
634
- catch (err) {
635
- // Non-fatal - daemon sync is best-effort
636
- console.warn(`[channels] Failed to sync creator to daemon:`, err);
637
- }
638
- // Broadcast channel creation to all connected clients in this workspace
639
- const channelData = {
640
- id: normalizedChannelId,
641
- name: channel.name,
642
- description: channel.description,
643
- visibility: channel.visibility,
644
- status: channel.status,
645
- createdAt: channel.createdAt.toISOString(),
646
- createdBy: channel.createdBy,
647
- memberCount: addedMembers.length,
648
- unreadCount: 0,
649
- hasMentions: false,
650
- isDm: false,
651
- };
652
- broadcastToWorkspaceChannelClients(workspaceId, {
653
- type: 'channel_created',
654
- channel: channelData,
655
- });
656
- console.log(`[channels] Broadcast channel_created event for ${channelId} to workspace ${workspaceId}`);
657
- res.status(201).json({
658
- success: true,
659
- channel: {
660
- id: normalizedChannelId,
661
- name: channel.name,
662
- description: channel.description,
663
- visibility: channel.visibility,
664
- status: channel.status,
665
- createdAt: channel.createdAt.toISOString(),
666
- createdBy: channel.createdBy,
667
- members: addedMembers,
668
- },
669
- warnings: memberWarnings.length > 0 ? memberWarnings : undefined,
670
- });
671
- }
672
- catch (error) {
673
- const err = error;
674
- console.error('[channels] Error creating channel:', {
675
- message: err.message,
676
- stack: err.stack,
677
- name: err.name,
678
- workspaceId: req.body.workspaceId,
679
- channelName: req.body.name,
680
- });
681
- // Include error message for debugging (safe since this is authenticated)
682
- res.status(500).json({
683
- error: 'Failed to create channel',
684
- message: err.message,
685
- });
686
- }
687
- });
688
- /**
689
- * POST /api/channels/join - Join a channel
690
- */
691
- app.post('/api/channels/join', requireAuth, express.json(), async (req, res) => {
692
- try {
693
- const { channel: rawChannelId, workspaceId, username } = req.body;
694
- if (!rawChannelId || !workspaceId) {
695
- return res.status(400).json({ error: 'channel and workspaceId are required' });
696
- }
697
- // Normalize channel ID (remove # prefix if present)
698
- const channelId = rawChannelId.startsWith('#') ? rawChannelId.slice(1) : rawChannelId;
699
- const userId = req.session.userId;
700
- const user = await db.users.findById(userId);
701
- const memberId = username || user?.githubUsername || 'unknown';
702
- // Find the channel
703
- const channel = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
704
- if (!channel) {
705
- return res.status(404).json({ error: 'Channel not found' });
706
- }
707
- // Check if already a member
708
- const existing = await db.channelMembers.findMembership(channel.id, memberId);
709
- if (!existing) {
710
- await db.channelMembers.addMember({
711
- channelId: channel.id,
712
- memberId,
713
- memberType: 'user',
714
- role: 'member',
715
- });
716
- }
717
- // Also subscribe the user on the daemon side for real-time messages
718
- // IMPORTANT: Must use workspace.publicUrl where agents are connected
719
- try {
720
- const workspace = await db.workspaces.findById(workspaceId);
721
- const dashboardUrl = workspace?.publicUrl || await getLocalDashboardUrl();
722
- const channelWithHash = rawChannelId.startsWith('#') ? rawChannelId : `#${rawChannelId}`;
723
- await fetch(`${dashboardUrl}/api/channels/subscribe`, {
724
- method: 'POST',
725
- headers: { 'Content-Type': 'application/json' },
726
- body: JSON.stringify({
727
- username: memberId,
728
- channels: [channelWithHash],
729
- workspaceId,
730
- }),
731
- });
732
- console.log(`[cloud] Subscribed ${memberId} to ${channelWithHash} on workspace daemon`);
733
- }
734
- catch (err) {
735
- // Non-fatal - daemon sync is best-effort
736
- console.warn(`[cloud] Failed to sync join to daemon:`, err);
737
- }
738
- res.json({ success: true, channel: channelId });
739
- }
740
- catch (error) {
741
- console.error('[channels] Error joining channel:', error);
742
- res.status(500).json({ error: 'Failed to join channel' });
743
- }
744
- });
745
- /**
746
- * POST /api/channels/leave - Leave a channel
747
- */
748
- app.post('/api/channels/leave', requireAuth, express.json(), async (req, res) => {
749
- try {
750
- const { channel: rawChannelId, workspaceId, username } = req.body;
751
- if (!rawChannelId || !workspaceId) {
752
- return res.status(400).json({ error: 'channel and workspaceId are required' });
753
- }
754
- // Normalize channel ID (remove # prefix if present)
755
- const channelId = rawChannelId.startsWith('#') ? rawChannelId.slice(1) : rawChannelId;
756
- const userId = req.session.userId;
757
- const user = await db.users.findById(userId);
758
- const memberId = username || user?.githubUsername || 'unknown';
759
- const channel = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
760
- if (!channel) {
761
- return res.status(404).json({ error: 'Channel not found' });
762
- }
763
- await db.channelMembers.removeMember(channel.id, memberId);
764
- res.json({ success: true, channel: channelId });
765
- }
766
- catch (error) {
767
- console.error('[channels] Error leaving channel:', error);
768
- res.status(500).json({ error: 'Failed to leave channel' });
769
- }
770
- });
771
- /**
772
- * POST /api/channels/invite - Invite users or agents to a channel
773
- * Invites can be:
774
- * - Array of strings (usernames, assumed to be users)
775
- * - Comma-separated string of usernames
776
- * - Array of objects with { id: string, type: 'user' | 'agent' }
777
- */
778
- app.post('/api/channels/invite', requireAuth, express.json(), async (req, res) => {
779
- try {
780
- const { channel: rawChannelId, workspaceId, invites, invitedBy } = req.body;
781
- if (!rawChannelId || !workspaceId || !invites) {
782
- return res.status(400).json({ error: 'channel, workspaceId, and invites are required' });
783
- }
784
- // Normalize channel ID (remove # prefix if present)
785
- const channelId = rawChannelId.startsWith('#') ? rawChannelId.slice(1) : rawChannelId;
786
- const channel = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
787
- if (!channel) {
788
- return res.status(404).json({ error: 'Channel not found' });
789
- }
790
- // Get workspace for daemon sync
791
- const workspace = await db.workspaces.findById(workspaceId);
792
- let inviteList;
793
- if (typeof invites === 'string') {
794
- // Comma-separated string - assume users
795
- inviteList = invites.split(',').map((s) => s.trim()).filter(Boolean)
796
- .map(id => ({ id, type: 'user' }));
797
- }
798
- else if (Array.isArray(invites)) {
799
- // Array - could be strings or objects
800
- inviteList = invites.map(item => {
801
- if (typeof item === 'string') {
802
- return { id: item, type: 'user' };
803
- }
804
- return { id: item.id, type: item.type || 'user' };
805
- });
806
- }
807
- else {
808
- return res.status(400).json({ error: 'invites must be a string or array' });
809
- }
810
- const results = [];
811
- const agentWarnings = [];
812
- for (const invitee of inviteList) {
813
- const existing = await db.channelMembers.findMembership(channel.id, invitee.id);
814
- if (!existing) {
815
- await db.channelMembers.addMember({
816
- channelId: channel.id,
817
- memberId: invitee.id,
818
- memberType: invitee.type,
819
- role: 'member',
820
- invitedBy,
821
- });
822
- // For agents, sync to workspace daemon's in-memory channel membership
823
- if (invitee.type === 'agent' && workspace) {
824
- try {
825
- const channelName = `#${channelId}`;
826
- const dashboardUrl = workspace.publicUrl || await getLocalDashboardUrl();
827
- const joinResponse = await fetch(`${dashboardUrl}/api/channels/admin-join`, {
828
- method: 'POST',
829
- headers: { 'Content-Type': 'application/json' },
830
- body: JSON.stringify({ channel: channelName, member: invitee.id, workspaceId }),
831
- });
832
- const joinResult = await joinResponse.json();
833
- console.log(`[channels] Synced agent ${invitee.id} to channel ${channelName} via workspace daemon`);
834
- if (joinResult.warning) {
835
- agentWarnings.push({ member: invitee.id, warning: joinResult.warning });
836
- }
837
- }
838
- catch (err) {
839
- console.warn(`[channels] Failed to sync agent ${invitee.id} to daemon:`, err);
840
- }
841
- }
842
- results.push({ id: invitee.id, type: invitee.type, success: true });
843
- }
844
- else {
845
- results.push({ id: invitee.id, type: invitee.type, success: true, reason: 'already_member' });
846
- }
847
- }
848
- res.json({ channel: channelId, invited: results, warnings: agentWarnings.length > 0 ? agentWarnings : undefined });
849
- }
850
- catch (error) {
851
- console.error('[channels] Error inviting to channel:', error);
852
- res.status(500).json({ error: 'Failed to invite to channel' });
853
- }
854
- });
855
- /**
856
- * POST /api/channels/archive - Archive a channel
857
- */
858
- app.post('/api/channels/archive', requireAuth, express.json(), async (req, res) => {
859
- try {
860
- const { channel: rawChannelId, workspaceId } = req.body;
861
- if (!rawChannelId || !workspaceId) {
862
- return res.status(400).json({ error: 'channel and workspaceId are required' });
863
- }
864
- // Normalize channel ID (remove # prefix if present)
865
- const channelId = rawChannelId.startsWith('#') ? rawChannelId.slice(1) : rawChannelId;
866
- const channel = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
867
- if (!channel) {
868
- return res.status(404).json({ error: 'Channel not found' });
869
- }
870
- await db.channels.archive(channel.id);
871
- res.json({ success: true, channel: channelId, status: 'archived' });
872
- }
873
- catch (error) {
874
- console.error('[channels] Error archiving channel:', error);
875
- res.status(500).json({ error: 'Failed to archive channel' });
876
- }
877
- });
878
- /**
879
- * POST /api/channels/unarchive - Unarchive a channel
880
- */
881
- app.post('/api/channels/unarchive', requireAuth, express.json(), async (req, res) => {
882
- try {
883
- const { channel: rawChannelId, workspaceId } = req.body;
884
- if (!rawChannelId || !workspaceId) {
885
- return res.status(400).json({ error: 'channel and workspaceId are required' });
886
- }
887
- // Normalize channel ID (remove # prefix if present)
888
- const channelId = rawChannelId.startsWith('#') ? rawChannelId.slice(1) : rawChannelId;
889
- const channel = await db.channels.findByWorkspaceAndChannelId(workspaceId, channelId);
890
- if (!channel) {
891
- return res.status(404).json({ error: 'Channel not found' });
892
- }
893
- await db.channels.unarchive(channel.id);
894
- res.json({ success: true, channel: channelId, status: 'active' });
895
- }
896
- catch (error) {
897
- console.error('[channels] Error unarchiving channel:', error);
898
- res.status(500).json({ error: 'Failed to unarchive channel' });
899
- }
900
- });
901
- // =========================================================================
902
- // Channel message endpoints (proxied to workspace container)
903
- // Messages are stored in the daemon's SQLite for real-time performance
904
- // =========================================================================
905
- app.post('/api/channels/message', requireAuth, express.json(), async (req, res) => {
906
- // Route to the workspace's dashboard where the daemon and agents run
907
- // IMPORTANT: Must use workspace.publicUrl (not getLocalDashboardUrl) because
908
- // agents are connected to the workspace's daemon, so messages must route there
909
- const { workspaceId } = req.body;
910
- let dashboardUrl = await getLocalDashboardUrl(); // Default for local mode
911
- if (workspaceId) {
912
- try {
913
- const workspace = await db.workspaces.findById(workspaceId);
914
- if (workspace?.publicUrl) {
915
- dashboardUrl = workspace.publicUrl;
916
- }
917
- }
918
- catch (err) {
919
- console.warn(`[channel-message] Failed to lookup workspace ${workspaceId}:`, err);
920
- }
921
- }
922
- const targetUrl = `${dashboardUrl}/api/channels/message`;
923
- console.log(`[channel-message] POST ${targetUrl}`);
924
- try {
925
- const proxyRes = await fetch(targetUrl, {
926
- method: 'POST',
927
- headers: { 'Content-Type': 'application/json' },
928
- body: JSON.stringify(req.body),
929
- });
930
- const data = await proxyRes.json();
931
- res.status(proxyRes.status).json(data);
932
- }
933
- catch (error) {
934
- console.error('[channel-message] Error:', error);
935
- res.status(502).json({ error: 'Failed to send message to workspace' });
936
- }
937
- });
938
- app.get('/api/channels/:channel/messages', requireAuth, async (req, res) => {
939
- // Route to the workspace's dashboard where the daemon stores messages
940
- // IMPORTANT: Must use workspace.publicUrl (not getLocalDashboardUrl) because
941
- // messages are stored in the workspace's daemon SQLite, not the cloud server's
942
- const workspaceId = req.query.workspaceId;
943
- let dashboardUrl = await getLocalDashboardUrl(); // Default for local mode
944
- if (workspaceId) {
945
- try {
946
- const workspace = await db.workspaces.findById(workspaceId);
947
- if (workspace?.publicUrl) {
948
- dashboardUrl = workspace.publicUrl;
949
- }
950
- }
951
- catch (err) {
952
- console.warn(`[channel-messages] Failed to lookup workspace ${workspaceId}:`, err);
953
- }
954
- }
955
- const channel = encodeURIComponent(String(req.params.channel));
956
- const params = new URLSearchParams();
957
- const limit = Array.isArray(req.query.limit) ? req.query.limit[0] : req.query.limit;
958
- const before = Array.isArray(req.query.before) ? req.query.before[0] : req.query.before;
959
- if (limit)
960
- params.set('limit', String(limit));
961
- if (before)
962
- params.set('before', String(before));
963
- if (workspaceId)
964
- params.set('workspaceId', workspaceId);
965
- const queryString = params.toString() ? `?${params.toString()}` : '';
966
- const targetUrl = `${dashboardUrl}/api/channels/${channel}/messages${queryString}`;
967
- console.log(`[channel-messages] GET ${targetUrl}`);
968
- try {
969
- const proxyRes = await fetch(targetUrl, {
970
- method: 'GET',
971
- headers: { 'Content-Type': 'application/json' },
972
- });
973
- const contentType = proxyRes.headers.get('content-type') || '';
974
- if (!contentType.includes('application/json')) {
975
- const text = await proxyRes.text();
976
- console.error(`[channel-messages] Non-JSON response from ${targetUrl}: ${text.substring(0, 100)}`);
977
- res.status(502).json({
978
- error: 'Workspace dashboard not available or returned non-JSON response',
979
- hint: 'Make sure the workspace daemon is running',
980
- });
981
- return;
982
- }
983
- const data = await proxyRes.json();
984
- res.status(proxyRes.status).json(data);
985
- }
986
- catch (error) {
987
- console.error('[channel-messages] Error:', error);
988
- res.status(502).json({ error: 'Failed to fetch messages from workspace' });
989
- }
990
- });
991
- /**
992
- * GET /api/channels/:channel/members - Get members of a channel
993
- * Cloud mode: Query database for channel members instead of proxying to local dashboard
994
- */
995
- app.get('/api/channels/:channel/members', requireAuth, async (req, res) => {
996
- const channelParam = req.params.channel;
997
- const workspaceId = req.query.workspaceId;
998
- const userId = req.session.userId;
999
- try {
1000
- // Find the channel in the database
1001
- // Channel ID can be passed as "random" or "#random" - normalize to find in DB
1002
- const channelName = channelParam.replace(/^#/, '');
1003
- // Get workspace ID - either from query param or user's default workspace
1004
- let targetWorkspaceId = workspaceId;
1005
- if (!targetWorkspaceId) {
1006
- const memberships = await db.workspaceMembers.findByUserId(userId);
1007
- if (memberships.length > 0) {
1008
- targetWorkspaceId = memberships[0].workspaceId;
1009
- }
1010
- }
1011
- if (!targetWorkspaceId) {
1012
- return res.json({ members: [] });
1013
- }
1014
- // Verify user has access to this workspace
1015
- const canView = await db.workspaceMembers.canView(targetWorkspaceId, userId);
1016
- if (!canView) {
1017
- const workspace = await db.workspaces.findById(targetWorkspaceId);
1018
- if (!workspace || workspace.userId !== userId) {
1019
- return res.status(403).json({ error: 'Access denied' });
1020
- }
1021
- }
1022
- // Find the channel by name and workspace
1023
- const channels = await db.channels.findByWorkspaceId(targetWorkspaceId);
1024
- const channel = channels.find(c => c.channelId === channelName ||
1025
- c.channelId === `#${channelName}` ||
1026
- c.name === channelName);
1027
- if (!channel) {
1028
- // Channel not found in database - return empty or fallback to dashboard proxy
1029
- console.log(`[channels] Channel ${channelParam} not found in database, proxying to dashboard`);
1030
- const encodedChannel = encodeURIComponent(channelParam);
1031
- return proxyToLocalDashboard(req, res, `/api/channels/${encodedChannel}/members`);
1032
- }
1033
- // Get all members of this channel from the database
1034
- const channelMembers = await db.channelMembers.findByChannelId(channel.id);
1035
- // Build response with entity type info and user details
1036
- const members = await Promise.all(channelMembers.map(async (member) => {
1037
- let displayName = member.memberId;
1038
- let avatarUrl;
1039
- // If it's a user, look up their details (stored by GitHub username)
1040
- if (member.memberType === 'user') {
1041
- const user = await db.users.findByGithubUsername(member.memberId);
1042
- if (user) {
1043
- displayName = user.githubUsername || member.memberId;
1044
- avatarUrl = user.avatarUrl || undefined;
1045
- }
1046
- }
1047
- return {
1048
- id: member.memberId,
1049
- displayName,
1050
- avatarUrl,
1051
- entityType: member.memberType,
1052
- role: member.role || 'member',
1053
- status: 'offline', // TODO: Get actual online status from daemon
1054
- joinedAt: member.joinedAt.toISOString(),
1055
- };
1056
- }));
1057
- return res.json({ members });
1058
- }
1059
- catch (error) {
1060
- console.error('[channels] Error getting channel members:', error);
1061
- return res.status(500).json({ error: 'Failed to get channel members' });
1062
- }
1063
- });
1064
- /**
1065
- * GET /api/channels/available-members - Get available members for channel invites
1066
- * Returns workspace members (humans) and agents from linked daemons
1067
- */
1068
- app.get('/api/channels/available-members', requireAuth, async (req, res) => {
1069
- try {
1070
- const userId = req.session.userId;
1071
- const workspaceId = req.query.workspaceId;
1072
- // Get workspace ID - either from query param or user's default workspace
1073
- let targetWorkspaceId = workspaceId;
1074
- if (!targetWorkspaceId) {
1075
- // Find user's default or first workspace
1076
- const memberships = await db.workspaceMembers.findByUserId(userId);
1077
- if (memberships.length > 0) {
1078
- targetWorkspaceId = memberships[0].workspaceId;
1079
- }
1080
- }
1081
- if (!targetWorkspaceId) {
1082
- return res.json({ members: [], agents: [] });
1083
- }
1084
- // Verify user has access to this workspace
1085
- const canView = await db.workspaceMembers.canView(targetWorkspaceId, userId);
1086
- if (!canView) {
1087
- const workspace = await db.workspaces.findById(targetWorkspaceId);
1088
- if (!workspace || workspace.userId !== userId) {
1089
- return res.status(403).json({ error: 'Access denied' });
1090
- }
1091
- }
1092
- // Get workspace members (humans)
1093
- const workspaceMembers = await db.workspaceMembers.findByWorkspaceId(targetWorkspaceId);
1094
- const members = await Promise.all(workspaceMembers.map(async (m) => {
1095
- const user = await db.users.findById(m.userId);
1096
- return {
1097
- id: user?.githubUsername || m.userId,
1098
- displayName: user?.githubUsername || 'Unknown',
1099
- type: 'user',
1100
- avatarUrl: user?.avatarUrl ?? undefined,
1101
- };
1102
- }));
1103
- // Get agents from linked daemons for this workspace
1104
- const daemons = await db.linkedDaemons.findByWorkspaceId(targetWorkspaceId);
1105
- const agents = [];
1106
- for (const daemon of daemons) {
1107
- const metadata = daemon.metadata;
1108
- const daemonAgents = metadata?.agents || [];
1109
- for (const agent of daemonAgents) {
1110
- // Skip human users from daemon agent list (they're in workspace members)
1111
- if (agent.isHuman)
1112
- continue;
1113
- // Avoid duplicates
1114
- if (!agents.some((a) => a.id === agent.name)) {
1115
- agents.push({
1116
- id: agent.name,
1117
- displayName: agent.name,
1118
- type: 'agent',
1119
- status: agent.status,
1120
- });
1121
- }
1122
- }
1123
- }
1124
- res.json({ members, agents });
1125
- }
1126
- catch (error) {
1127
- console.error('[channels] Error getting available members:', error);
1128
- res.status(500).json({ error: 'Failed to get available members' });
1129
- }
1130
- });
1131
- app.get('/api/channels/users', requireAuth, async (req, res) => {
1132
- await proxyToLocalDashboard(req, res, '/api/channels/users');
1133
- });
1134
- /**
1135
- * POST /api/channels/admin-remove - Remove a member from a channel (admin operation)
1136
- * Proxies to workspace dashboard where the daemon maintains channel membership
1137
- */
1138
- app.post('/api/channels/admin-remove', requireAuth, express.json(), async (req, res) => {
1139
- await proxyToLocalDashboard(req, res, '/api/channels/admin-remove');
1140
- });
1141
- // Bridge API - returns empty state in cloud mode
1142
- // Bridge is for local multi-project coordination; cloud workspaces are already separate
1143
- // MUST be before teamsRouter to avoid auth interception
1144
- app.get('/api/bridge', requireAuth, (_req, res) => {
1145
- res.json({ projects: [], messages: [], connected: false });
1146
- });
1147
- // Test helper routes (only available in non-production)
1148
- // MUST be before teamsRouter to avoid auth interception
1149
- if (process.env.NODE_ENV !== 'production') {
1150
- app.use('/api/test', testHelpersRouter);
1151
- console.log('[cloud] Test helper routes enabled (non-production mode)');
1152
- }
1153
- // Teams router - MUST BE LAST among /api routes
1154
- // Handles /workspaces/:id/members and /invites with requireAuth on all routes
1155
- app.use('/api', teamsRouter);
1156
- // Serve static dashboard files (Next.js static export)
1157
- // Path: packages/cloud/dist/server.js -> ../../dashboard/ui/out
1158
- // In Docker: /app/packages/cloud/dist -> /app/packages/dashboard/ui/out
1159
- const dashboardPath = path.join(__dirname, '../../dashboard/ui/out');
1160
- // Serve static files (JS, CSS, images, etc.)
1161
- app.use(express.static(dashboardPath));
1162
- // Handle clean URLs for Next.js static export
1163
- // When a directory exists (e.g., /app/), express.static won't serve app.html
1164
- // So we need to explicitly check for .html files
1165
- app.get('/{*splat}', (req, res, next) => {
1166
- // Don't handle API routes
1167
- if (req.path.startsWith('/api/')) {
1168
- return next();
1169
- }
1170
- // Clean the path (remove trailing slash)
1171
- const cleanPath = req.path.replace(/\/$/, '') || '/';
1172
- // Try to serve the corresponding .html file
1173
- const htmlFile = cleanPath === '/' ? 'index.html' : `${cleanPath}.html`;
1174
- const htmlPath = path.join(dashboardPath, htmlFile);
1175
- // Check if the HTML file exists
1176
- if (fs.existsSync(htmlPath)) {
1177
- res.sendFile(htmlPath);
1178
- }
1179
- else {
1180
- // Fallback to index.html for SPA-style routing
1181
- res.sendFile(path.join(dashboardPath, 'index.html'));
1182
- }
1183
- });
1184
- // Error handler
1185
- app.use((err, req, res, _next) => {
1186
- console.error('Error:', err);
1187
- res.status(500).json({
1188
- error: 'Internal server error',
1189
- message: process.env.NODE_ENV === 'development' ? err.message : undefined,
1190
- });
1191
- });
1192
- // Server lifecycle
1193
- let server = null;
1194
- let scalingOrchestrator = null;
1195
- let computeEnforcement = null;
1196
- let introExpiration = null;
1197
- let workspaceKeepalive = null;
1198
- let daemonStaleCheckInterval = null;
1199
- // Create HTTP server for WebSocket upgrade handling
1200
- const httpServer = http.createServer(app);
1201
- // ===== Presence WebSocket =====
1202
- const wssPresence = new WebSocketServer({
1203
- noServer: true,
1204
- perMessageDeflate: false,
1205
- maxPayload: 1024 * 1024, // 1MB - presence messages are small
1206
- });
1207
- const onlineUsers = new Map();
1208
- // Track workspace per WebSocket connection for filtering
1209
- const connectionWorkspace = new Map();
1210
- // Validation helpers
1211
- const isValidUsername = (username) => {
1212
- if (typeof username !== 'string')
1213
- return false;
1214
- // Username can be:
1215
- // - GitHub-style: alphanumeric with hyphens (e.g., "khaliqgant")
1216
- // - Display name style: allows spaces for email users (e.g., "Khaliq Gant")
1217
- // Max 50 chars, must start/end with alphanumeric, no consecutive spaces
1218
- if (username.length === 0 || username.length > 50)
1219
- return false;
1220
- // Must start and end with alphanumeric
1221
- if (!/^[a-zA-Z0-9]/.test(username) || !/[a-zA-Z0-9]$/.test(username))
1222
- return false;
1223
- // Only allow alphanumeric, spaces, hyphens, underscores, and periods
1224
- if (!/^[a-zA-Z0-9][a-zA-Z0-9 _.-]*[a-zA-Z0-9]$/.test(username) && username.length > 1)
1225
- return false;
1226
- // Single character usernames must be alphanumeric
1227
- if (username.length === 1 && !/^[a-zA-Z0-9]$/.test(username))
1228
- return false;
1229
- // No consecutive spaces
1230
- if (/ /.test(username))
1231
- return false;
1232
- return true;
1233
- };
1234
- const isValidAvatarUrl = (url) => {
1235
- if (url === undefined || url === null)
1236
- return true;
1237
- if (typeof url !== 'string')
1238
- return false;
1239
- // Must be a valid HTTPS URL from known avatar providers
1240
- try {
1241
- const parsed = new URL(url);
1242
- if (parsed.protocol !== 'https:')
1243
- return false;
1244
- // Allow GitHub avatars
1245
- if (parsed.hostname === 'avatars.githubusercontent.com' ||
1246
- parsed.hostname === 'github.com' ||
1247
- parsed.hostname.endsWith('.githubusercontent.com'))
1248
- return true;
1249
- // Allow Gravatar for email-based avatars
1250
- if (parsed.hostname === 'www.gravatar.com' ||
1251
- parsed.hostname === 'gravatar.com' ||
1252
- parsed.hostname === 'secure.gravatar.com')
1253
- return true;
1254
- // Allow UI Avatars (placeholder service)
1255
- if (parsed.hostname === 'ui-avatars.com')
1256
- return true;
1257
- return false;
1258
- }
1259
- catch {
1260
- return false;
1261
- }
1262
- };
1263
- // WebSocket server for agent logs (proxied to workspace daemon)
1264
- const wssLogs = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1265
- // WebSocket server for channel messages (proxied to workspace daemon)
1266
- const wssChannels = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1267
- // Handle agent logs WebSocket connections
1268
- wssLogs.on('connection', async (clientWs, workspaceId, agentName) => {
1269
- console.log(`[ws/logs] Client connected for workspace=${workspaceId} agent=${agentName}`);
1270
- let daemonWs = null;
1271
- try {
1272
- // Find the workspace (needed to verify it exists and get its URL)
1273
- const workspace = await db.workspaces.findById(workspaceId);
1274
- if (!workspace) {
1275
- clientWs.send(JSON.stringify({ type: 'error', message: 'Workspace not found' }));
1276
- clientWs.close();
1277
- return;
1278
- }
1279
- // Connect to the workspace's dashboard where the agent was spawned
1280
- // IMPORTANT: Must use workspace.publicUrl (not getLocalDashboardUrl) because
1281
- // agents are spawned on the workspace server, so logs must connect there too
1282
- const dashboardUrl = workspace.publicUrl || await getLocalDashboardUrl();
1283
- const baseUrl = dashboardUrl.replace(/^http/, 'ws').replace(/\/$/, '');
1284
- const daemonWsUrl = `${baseUrl}/ws/logs/${encodeURIComponent(agentName)}`;
1285
- console.log(`[ws/logs] Connecting to daemon: ${daemonWsUrl}`);
1286
- daemonWs = new WebSocket(daemonWsUrl, { perMessageDeflate: false });
1287
- daemonWs.on('open', () => {
1288
- console.log(`[ws/logs] Connected to daemon for ${agentName}`);
1289
- // Note: No need to send subscribe message - the agent name in the URL path
1290
- // triggers auto-subscription in the dashboard server
1291
- });
1292
- daemonWs.on('message', (data) => {
1293
- // Forward daemon messages to client
1294
- if (clientWs.readyState === WebSocket.OPEN) {
1295
- clientWs.send(data.toString());
1296
- }
1297
- });
1298
- daemonWs.on('close', () => {
1299
- console.log(`[ws/logs] Daemon connection closed for ${agentName}`);
1300
- if (clientWs.readyState === WebSocket.OPEN) {
1301
- clientWs.close();
1302
- }
1303
- });
1304
- daemonWs.on('error', (err) => {
1305
- console.error(`[ws/logs] Daemon WebSocket error:`, err);
1306
- if (clientWs.readyState === WebSocket.OPEN) {
1307
- clientWs.send(JSON.stringify({ type: 'error', message: 'Daemon connection error' }));
1308
- clientWs.close();
1309
- }
1310
- });
1311
- // Forward client messages to daemon (for user input)
1312
- clientWs.on('message', (data) => {
1313
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1314
- daemonWs.send(data.toString());
1315
- }
1316
- });
1317
- clientWs.on('close', () => {
1318
- console.log(`[ws/logs] Client disconnected for ${agentName}`);
1319
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1320
- daemonWs.close();
1321
- }
1322
- });
1323
- clientWs.on('error', (err) => {
1324
- console.error(`[ws/logs] Client WebSocket error:`, err);
1325
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1326
- daemonWs.close();
1327
- }
1328
- });
1329
- }
1330
- catch (err) {
1331
- console.error(`[ws/logs] Setup error:`, err);
1332
- if (clientWs.readyState === WebSocket.OPEN) {
1333
- clientWs.send(JSON.stringify({ type: 'error', message: 'Failed to connect to workspace' }));
1334
- clientWs.close();
1335
- }
1336
- }
1337
- });
1338
- // Handle channel WebSocket connections (proxied to workspace daemon)
1339
- // This allows cloud users to receive real-time channel messages
1340
- wssChannels.on('connection', async (clientWs, workspaceId, username) => {
1341
- console.log(`[ws/channels] Client connected for workspace=${workspaceId} user=${username}`);
1342
- // Track client for broadcasting channel events
1343
- if (!channelClientsByWorkspace.has(workspaceId)) {
1344
- channelClientsByWorkspace.set(workspaceId, new Set());
1345
- }
1346
- channelClientsByWorkspace.get(workspaceId).add(clientWs);
1347
- console.log(`[ws/channels] Now tracking ${channelClientsByWorkspace.get(workspaceId).size} clients for workspace ${workspaceId}`);
1348
- let daemonWs = null;
1349
- try {
1350
- // Find the workspace (needed to verify it exists)
1351
- const workspace = await db.workspaces.findById(workspaceId);
1352
- if (!workspace) {
1353
- clientWs.send(JSON.stringify({ type: 'error', message: 'Workspace not found' }));
1354
- clientWs.close();
1355
- return;
1356
- }
1357
- // Connect to the workspace's dashboard where the daemon and agents run
1358
- // IMPORTANT: Must use workspace.publicUrl (not getLocalDashboardUrl) because
1359
- // agents are connected to the workspace's daemon, so channels must connect there too
1360
- const dashboardUrl = workspace.publicUrl || await getLocalDashboardUrl();
1361
- const baseUrl = dashboardUrl.replace(/^http/, 'ws').replace(/\/$/, '');
1362
- const daemonWsUrl = `${baseUrl}/ws/presence`;
1363
- console.log(`[ws/channels] Connecting to workspace daemon: ${daemonWsUrl}`);
1364
- daemonWs = new WebSocket(daemonWsUrl, { perMessageDeflate: false });
1365
- daemonWs.on('open', () => {
1366
- console.log(`[ws/channels] Connected to daemon for ${username}`);
1367
- // Register with the daemon's presence system
1368
- daemonWs.send(JSON.stringify({
1369
- type: 'presence',
1370
- action: 'join',
1371
- user: { username },
1372
- }));
1373
- });
1374
- daemonWs.on('message', (data) => {
1375
- // Forward daemon messages to client
1376
- // Forward channel_message, direct_message, and presence updates for this user
1377
- try {
1378
- const msg = JSON.parse(data.toString());
1379
- if (msg.type === 'channel_message') {
1380
- // Channel messages are sent to all members - the user's connection
1381
- // to the daemon via UserBridge ensures they only receive messages
1382
- // for channels they've joined
1383
- console.log(`[ws/channels] Forwarding channel message to ${username}: ${msg.from} -> ${msg.channel}`);
1384
- clientWs.send(data.toString());
1385
- }
1386
- // Forward direct messages from agents to this cloud user
1387
- if (msg.type === 'direct_message') {
1388
- console.log(`[ws/channels] Forwarding direct message to ${username}: ${msg.from}`);
1389
- clientWs.send(data.toString());
1390
- }
1391
- // Also forward presence updates so client stays in sync
1392
- if (msg.type === 'presence_join' || msg.type === 'presence_leave' || msg.type === 'presence_list') {
1393
- clientWs.send(data.toString());
1394
- }
1395
- }
1396
- catch {
1397
- // Non-JSON message, skip
1398
- }
1399
- });
1400
- daemonWs.on('close', () => {
1401
- console.log(`[ws/channels] Daemon connection closed for ${username}`);
1402
- if (clientWs.readyState === WebSocket.OPEN) {
1403
- clientWs.close();
1404
- }
1405
- });
1406
- daemonWs.on('error', (err) => {
1407
- console.error(`[ws/channels] Daemon WebSocket error:`, err);
1408
- if (clientWs.readyState === WebSocket.OPEN) {
1409
- clientWs.send(JSON.stringify({ type: 'error', message: 'Daemon connection error' }));
1410
- clientWs.close();
1411
- }
1412
- });
1413
- // Forward client messages to daemon (for sending channel messages)
1414
- clientWs.on('message', (data) => {
1415
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1416
- daemonWs.send(data.toString());
1417
- }
1418
- });
1419
- clientWs.on('close', () => {
1420
- console.log(`[ws/channels] Client disconnected for ${username}`);
1421
- // Remove from tracking
1422
- const clients = channelClientsByWorkspace.get(workspaceId);
1423
- if (clients) {
1424
- clients.delete(clientWs);
1425
- if (clients.size === 0) {
1426
- channelClientsByWorkspace.delete(workspaceId);
1427
- }
1428
- }
1429
- // Send leave message to daemon
1430
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1431
- daemonWs.send(JSON.stringify({
1432
- type: 'presence',
1433
- action: 'leave',
1434
- username,
1435
- }));
1436
- daemonWs.close();
1437
- }
1438
- });
1439
- clientWs.on('error', (err) => {
1440
- console.error(`[ws/channels] Client WebSocket error:`, err);
1441
- // Remove from tracking
1442
- const clients = channelClientsByWorkspace.get(workspaceId);
1443
- if (clients) {
1444
- clients.delete(clientWs);
1445
- if (clients.size === 0) {
1446
- channelClientsByWorkspace.delete(workspaceId);
1447
- }
1448
- }
1449
- if (daemonWs && daemonWs.readyState === WebSocket.OPEN) {
1450
- daemonWs.close();
1451
- }
1452
- });
1453
- }
1454
- catch (err) {
1455
- console.error(`[ws/channels] Setup error:`, err);
1456
- if (clientWs.readyState === WebSocket.OPEN) {
1457
- clientWs.send(JSON.stringify({ type: 'error', message: 'Failed to connect to workspace' }));
1458
- clientWs.close();
1459
- }
1460
- }
1461
- });
1462
- // Handle HTTP upgrade for WebSocket
1463
- httpServer.on('upgrade', (request, socket, head) => {
1464
- const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
1465
- if (pathname === '/ws/presence') {
1466
- wssPresence.handleUpgrade(request, socket, head, (ws) => {
1467
- wssPresence.emit('connection', ws, request);
1468
- });
1469
- }
1470
- else if (pathname.startsWith('/ws/logs/')) {
1471
- // Parse /ws/logs/:workspaceId/:agentName
1472
- const parts = pathname.split('/').filter(Boolean);
1473
- if (parts.length >= 4) {
1474
- const workspaceId = decodeURIComponent(parts[2]);
1475
- const agentName = decodeURIComponent(parts[3]);
1476
- wssLogs.handleUpgrade(request, socket, head, (ws) => {
1477
- wssLogs.emit('connection', ws, workspaceId, agentName);
1478
- });
1479
- }
1480
- else {
1481
- socket.destroy();
1482
- }
1483
- }
1484
- else if (pathname.startsWith('/ws/channels/')) {
1485
- // Parse /ws/channels/:workspaceId/:username
1486
- const parts = pathname.split('/').filter(Boolean);
1487
- if (parts.length >= 4) {
1488
- const workspaceId = decodeURIComponent(parts[2]);
1489
- const username = decodeURIComponent(parts[3]);
1490
- wssChannels.handleUpgrade(request, socket, head, (ws) => {
1491
- wssChannels.emit('connection', ws, workspaceId, username);
1492
- });
1493
- }
1494
- else {
1495
- socket.destroy();
1496
- }
1497
- }
1498
- else {
1499
- // Unknown WebSocket path - destroy socket
1500
- socket.destroy();
1501
- }
1502
- });
1503
- // Broadcast to all presence clients
1504
- const broadcastPresence = (message, exclude) => {
1505
- const payload = JSON.stringify(message);
1506
- wssPresence.clients.forEach((client) => {
1507
- if (client !== exclude && client.readyState === WebSocket.OPEN) {
1508
- client.send(payload);
1509
- }
1510
- });
1511
- };
1512
- // Get online users list, optionally filtered by workspace
1513
- const getOnlineUsersList = (workspaceId) => {
1514
- if (!workspaceId) {
1515
- // No workspace filter - return all (for backwards compat)
1516
- return Array.from(onlineUsers.values()).map((state) => state.info);
1517
- }
1518
- // Filter to only users in this workspace
1519
- return Array.from(onlineUsers.values())
1520
- .filter((state) => state.workspaceIds.has(workspaceId))
1521
- .map((state) => state.info);
1522
- };
1523
- // Heartbeat interval to detect dead connections (30 seconds)
1524
- const PRESENCE_HEARTBEAT_INTERVAL = 30000;
1525
- const _PRESENCE_HEARTBEAT_TIMEOUT = 35000; // Allow 5s grace period (reserved for future use)
1526
- // Track connection health for heartbeat
1527
- const connectionHealth = new WeakMap();
1528
- // Heartbeat interval to clean up dead connections
1529
- const presenceHeartbeat = setInterval(() => {
1530
- const now = Date.now();
1531
- wssPresence.clients.forEach((ws) => {
1532
- const health = connectionHealth.get(ws);
1533
- if (!health) {
1534
- // New connection without health tracking - initialize it
1535
- connectionHealth.set(ws, { isAlive: true, lastPing: now });
1536
- return;
1537
- }
1538
- if (!health.isAlive) {
1539
- // Connection didn't respond to last ping - terminate it
1540
- ws.terminate();
1541
- return;
1542
- }
1543
- // Mark as not alive until we get a pong
1544
- health.isAlive = false;
1545
- health.lastPing = now;
1546
- ws.ping();
1547
- });
1548
- }, PRESENCE_HEARTBEAT_INTERVAL);
1549
- // Clean up interval on server close
1550
- wssPresence.on('close', () => {
1551
- clearInterval(presenceHeartbeat);
1552
- });
1553
- // Track daemon proxy connections for channel message forwarding
1554
- const daemonProxies = new Map(); // clientWs -> workspaceId -> daemonWs
1555
- // Set up daemon proxy for channel messages
1556
- async function setupDaemonChannelProxy(clientWs, workspaceId, username) {
1557
- // Check if already have a proxy for this workspace
1558
- const clientProxies = daemonProxies.get(clientWs) || new Map();
1559
- if (clientProxies.has(workspaceId)) {
1560
- return; // Already connected
1561
- }
1562
- try {
1563
- const workspace = await db.workspaces.findById(workspaceId);
1564
- if (!workspace) {
1565
- console.log(`[cloud] Workspace ${workspaceId} not found`);
1566
- return;
1567
- }
1568
- // Use workspace's public URL where the daemon actually runs
1569
- // IMPORTANT: Must use workspace.publicUrl (not getLocalDashboardUrl) because
1570
- // the daemon and userBridge are on the workspace server, not the cloud server
1571
- const dashboardUrl = workspace.publicUrl || await getLocalDashboardUrl();
1572
- const daemonWsUrl = dashboardUrl.replace(/^http/, 'ws').replace(/\/$/, '') + '/ws/presence';
1573
- console.log(`[cloud] Connecting channel proxy to daemon: ${daemonWsUrl} for ${username}`);
1574
- const daemonWs = new WebSocket(daemonWsUrl, { perMessageDeflate: false });
1575
- daemonWs.on('open', async () => {
1576
- console.log(`[cloud] Channel proxy connected for ${username} in workspace ${workspaceId}`);
1577
- // Send presence join to register with userBridge on dashboard-server
1578
- // This creates a relay client for the user so they can receive channel messages
1579
- daemonWs.send(JSON.stringify({
1580
- type: 'presence',
1581
- action: 'join',
1582
- user: { username },
1583
- }));
1584
- console.log(`[cloud] Sent presence join for ${username} to register with userBridge`);
1585
- // Wait briefly for userBridge registration to complete, then subscribe to channels
1586
- // This ensures the user is registered before we try to join channels via userBridge
1587
- await new Promise(resolve => setTimeout(resolve, 200));
1588
- try {
1589
- // Get all channels the user is a member of
1590
- const memberships = await db.channelMembers.findByMemberId(username);
1591
- const userChannels = ['#general']; // Always include #general
1592
- // Look up channel details to get the channelId string (like '#foobar')
1593
- for (const membership of memberships) {
1594
- const channel = await db.channels.findById(membership.channelId);
1595
- if (channel && channel.workspaceId === workspaceId) {
1596
- // Normalize channel ID with # prefix
1597
- const channelIdStr = channel.channelId.startsWith('#')
1598
- ? channel.channelId
1599
- : `#${channel.channelId}`;
1600
- if (!userChannels.includes(channelIdStr)) {
1601
- userChannels.push(channelIdStr);
1602
- }
1603
- }
1604
- }
1605
- console.log(`[cloud] Subscribing ${username} to ${userChannels.length} channels: ${userChannels.join(', ')}`);
1606
- const subscribeRes = await fetch(`${dashboardUrl}/api/channels/subscribe`, {
1607
- method: 'POST',
1608
- headers: { 'Content-Type': 'application/json' },
1609
- body: JSON.stringify({
1610
- username,
1611
- channels: userChannels,
1612
- workspaceId,
1613
- }),
1614
- });
1615
- if (subscribeRes.ok) {
1616
- const result = (await subscribeRes.json());
1617
- console.log(`[cloud] Subscribed ${username} to channels: ${result.channels?.join(', ')}`);
1618
- }
1619
- else {
1620
- console.warn(`[cloud] Failed to subscribe ${username} to channels: ${subscribeRes.status}`);
1621
- }
1622
- }
1623
- catch (err) {
1624
- console.warn(`[cloud] Error subscribing ${username} to channels:`, err);
1625
- }
1626
- });
1627
- daemonWs.on('message', (data) => {
1628
- try {
1629
- const msg = JSON.parse(data.toString());
1630
- // Forward channel messages to this user
1631
- // userBridge sends messages directly to registered users (no targetUser filter needed)
1632
- // getRelayClient fallback broadcasts with targetUser field
1633
- if (msg.type === 'channel_message') {
1634
- // Either the message is for this user specifically (targetUser match)
1635
- // or it's a direct send from userBridge (no targetUser, meaning it's for us)
1636
- if (!msg.targetUser || msg.targetUser === username) {
1637
- console.log(`[cloud] Forwarding channel message to ${username}: ${msg.from} -> ${msg.channel}`);
1638
- if (clientWs.readyState === WebSocket.OPEN) {
1639
- clientWs.send(data.toString());
1640
- }
1641
- }
1642
- }
1643
- }
1644
- catch {
1645
- // Non-JSON, ignore
1646
- }
1647
- });
1648
- daemonWs.on('close', () => {
1649
- console.log(`[cloud] Channel proxy closed for ${username} in workspace ${workspaceId}`);
1650
- clientProxies.delete(workspaceId);
1651
- });
1652
- daemonWs.on('error', (err) => {
1653
- console.error(`[cloud] Channel proxy error for ${username}:`, err);
1654
- clientProxies.delete(workspaceId);
1655
- });
1656
- clientProxies.set(workspaceId, daemonWs);
1657
- daemonProxies.set(clientWs, clientProxies);
1658
- }
1659
- catch (err) {
1660
- console.error(`[cloud] Failed to setup channel proxy for ${username}:`, err);
1661
- }
1662
- }
1663
- // Clean up daemon proxies for a client
1664
- function cleanupDaemonProxies(clientWs) {
1665
- const clientProxies = daemonProxies.get(clientWs);
1666
- if (clientProxies) {
1667
- for (const [workspaceId, daemonWs] of clientProxies) {
1668
- console.log(`[cloud] Cleaning up channel proxy for workspace ${workspaceId}`);
1669
- if (daemonWs.readyState === WebSocket.OPEN) {
1670
- daemonWs.close();
1671
- }
1672
- }
1673
- daemonProxies.delete(clientWs);
1674
- }
1675
- }
1676
- // Handle presence connections
1677
- wssPresence.on('connection', (ws) => {
1678
- // Initialize health tracking (no log - too noisy)
1679
- connectionHealth.set(ws, { isAlive: true, lastPing: Date.now() });
1680
- // Handle pong responses (heartbeat)
1681
- ws.on('pong', () => {
1682
- const health = connectionHealth.get(ws);
1683
- if (health) {
1684
- health.isAlive = true;
1685
- }
1686
- });
1687
- let clientUsername;
1688
- ws.on('message', (data) => {
1689
- try {
1690
- const msg = JSON.parse(data.toString());
1691
- if (msg.type === 'presence') {
1692
- if (msg.action === 'join' && msg.user?.username) {
1693
- const username = msg.user.username;
1694
- const avatarUrl = msg.user.avatarUrl;
1695
- if (!isValidUsername(username)) {
1696
- console.warn(`[cloud] Invalid username rejected: ${username}`);
1697
- return;
1698
- }
1699
- if (!isValidAvatarUrl(avatarUrl)) {
1700
- console.warn(`[cloud] Invalid avatar URL rejected for user ${username}`);
1701
- return;
1702
- }
1703
- clientUsername = username;
1704
- const now = new Date().toISOString();
1705
- const existing = onlineUsers.get(username);
1706
- if (existing) {
1707
- existing.connections.add(ws);
1708
- existing.info.lastSeen = now;
1709
- // Update last seen in shared presence registry
1710
- updateUserLastSeen(username);
1711
- // Only log at milestones to reduce noise
1712
- const count = existing.connections.size;
1713
- if (count === 2 || count === 5 || count === 10 || count % 50 === 0) {
1714
- console.log(`[cloud] User ${username} has ${count} connections`);
1715
- }
1716
- }
1717
- else {
1718
- onlineUsers.set(username, {
1719
- info: { username, avatarUrl, connectedAt: now, lastSeen: now },
1720
- connections: new Set([ws]),
1721
- workspaceIds: new Set(), // Workspace set when subscribe_channels is called
1722
- });
1723
- // Register with shared presence registry for cross-module access
1724
- registerUserPresence({ username, avatarUrl, connectedAt: now, lastSeen: now });
1725
- console.log(`[cloud] User ${username} came online`);
1726
- // Don't broadcast globally - wait until we know which workspace they're in
1727
- }
1728
- // Send empty presence list initially - real list sent when workspace is known
1729
- ws.send(JSON.stringify({
1730
- type: 'presence_list',
1731
- users: [],
1732
- }));
1733
- }
1734
- else if (msg.action === 'leave') {
1735
- if (!clientUsername || msg.username !== clientUsername)
1736
- return;
1737
- const userState = onlineUsers.get(clientUsername);
1738
- const leavingWorkspace = connectionWorkspace.get(ws);
1739
- connectionWorkspace.delete(ws);
1740
- if (userState) {
1741
- userState.connections.delete(ws);
1742
- // Remove workspace from user's set if no more connections to it
1743
- if (leavingWorkspace) {
1744
- const stillHasWorkspaceConnection = Array.from(userState.connections).some((conn) => connectionWorkspace.get(conn) === leavingWorkspace);
1745
- if (!stillHasWorkspaceConnection) {
1746
- userState.workspaceIds.delete(leavingWorkspace);
1747
- // Broadcast leave to users in that workspace
1748
- wssPresence.clients.forEach((client) => {
1749
- if (client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === leavingWorkspace) {
1750
- client.send(JSON.stringify({ type: 'presence_leave', username: clientUsername }));
1751
- }
1752
- });
1753
- }
1754
- }
1755
- if (userState.connections.size === 0) {
1756
- onlineUsers.delete(clientUsername);
1757
- // Unregister from shared presence registry
1758
- unregisterUserPresence(clientUsername);
1759
- console.log(`[cloud] User ${clientUsername} went offline`);
1760
- }
1761
- }
1762
- }
1763
- }
1764
- else if (msg.type === 'typing') {
1765
- if (!clientUsername || msg.username !== clientUsername)
1766
- return;
1767
- const userState = onlineUsers.get(clientUsername);
1768
- if (userState) {
1769
- userState.info.lastSeen = new Date().toISOString();
1770
- // Update last seen in shared presence registry
1771
- updateUserLastSeen(clientUsername);
1772
- }
1773
- // Only broadcast typing to users in the same workspace
1774
- const typingWorkspace = connectionWorkspace.get(ws);
1775
- if (typingWorkspace) {
1776
- wssPresence.clients.forEach((client) => {
1777
- if (client !== ws && client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === typingWorkspace) {
1778
- client.send(JSON.stringify({
1779
- type: 'typing',
1780
- username: clientUsername,
1781
- avatarUrl: userState?.info.avatarUrl,
1782
- isTyping: msg.isTyping,
1783
- }));
1784
- }
1785
- });
1786
- }
1787
- }
1788
- else if (msg.type === 'subscribe_channels') {
1789
- // Subscribe to channel messages for a specific workspace
1790
- if (!clientUsername) {
1791
- console.warn(`[cloud] subscribe_channels from unauthenticated client`);
1792
- return;
1793
- }
1794
- if (!msg.workspaceId || typeof msg.workspaceId !== 'string') {
1795
- console.warn(`[cloud] subscribe_channels missing workspaceId`);
1796
- return;
1797
- }
1798
- const workspaceId = msg.workspaceId;
1799
- console.log(`[cloud] User ${clientUsername} subscribing to channels in workspace ${workspaceId}`);
1800
- // Track which workspace this connection is in
1801
- connectionWorkspace.set(ws, workspaceId);
1802
- // Add workspace to user's workspace set
1803
- const userState = onlineUsers.get(clientUsername);
1804
- if (userState) {
1805
- const isNewToWorkspace = !userState.workspaceIds.has(workspaceId);
1806
- userState.workspaceIds.add(workspaceId);
1807
- // If user is new to this workspace, broadcast presence_join to others in workspace
1808
- if (isNewToWorkspace) {
1809
- const { info } = userState;
1810
- // Broadcast to all users in this workspace
1811
- wssPresence.clients.forEach((client) => {
1812
- if (client !== ws && client.readyState === WebSocket.OPEN) {
1813
- const clientWsId = connectionWorkspace.get(client);
1814
- if (clientWsId === workspaceId) {
1815
- client.send(JSON.stringify({
1816
- type: 'presence_join',
1817
- user: info,
1818
- }));
1819
- }
1820
- }
1821
- });
1822
- }
1823
- // Send updated presence list filtered by this workspace
1824
- ws.send(JSON.stringify({
1825
- type: 'presence_list',
1826
- users: getOnlineUsersList(workspaceId),
1827
- }));
1828
- }
1829
- setupDaemonChannelProxy(ws, workspaceId, clientUsername).catch((err) => {
1830
- console.error(`[cloud] Failed to setup channel subscription:`, err);
1831
- });
1832
- }
1833
- else if (msg.type === 'channel_message') {
1834
- // Proxy channel message to daemon via HTTP API
1835
- if (!clientUsername) {
1836
- console.warn(`[cloud] channel_message from unauthenticated client`);
1837
- return;
1838
- }
1839
- if (!msg.channel || !msg.body) {
1840
- console.warn(`[cloud] channel_message missing channel or body`);
1841
- return;
1842
- }
1843
- // Note: This should be handled by the HTTP API, but support WebSocket too
1844
- console.log(`[cloud] Channel message via WebSocket from ${clientUsername} to ${msg.channel}`);
1845
- // The HTTP proxy will handle actual sending - just log for now
1846
- }
1847
- }
1848
- catch (err) {
1849
- console.error('[cloud] Invalid presence message:', err);
1850
- }
1851
- });
1852
- ws.on('close', () => {
1853
- // Clean up daemon proxies
1854
- cleanupDaemonProxies(ws);
1855
- const closingWorkspace = connectionWorkspace.get(ws);
1856
- connectionWorkspace.delete(ws);
1857
- if (clientUsername) {
1858
- const userState = onlineUsers.get(clientUsername);
1859
- if (userState) {
1860
- userState.connections.delete(ws);
1861
- // Remove workspace from user's set if no more connections to it
1862
- if (closingWorkspace) {
1863
- const stillHasWorkspaceConnection = Array.from(userState.connections).some((conn) => connectionWorkspace.get(conn) === closingWorkspace);
1864
- if (!stillHasWorkspaceConnection) {
1865
- userState.workspaceIds.delete(closingWorkspace);
1866
- // Broadcast leave to users in that workspace
1867
- wssPresence.clients.forEach((client) => {
1868
- if (client.readyState === WebSocket.OPEN && connectionWorkspace.get(client) === closingWorkspace) {
1869
- client.send(JSON.stringify({ type: 'presence_leave', username: clientUsername }));
1870
- }
1871
- });
1872
- }
1873
- }
1874
- if (userState.connections.size === 0) {
1875
- onlineUsers.delete(clientUsername);
1876
- // Unregister from shared presence registry
1877
- unregisterUserPresence(clientUsername);
1878
- console.log(`[cloud] User ${clientUsername} disconnected`);
1879
- }
1880
- }
1881
- }
1882
- });
1883
- ws.on('error', (err) => {
1884
- console.error('[cloud] Presence WebSocket error:', err);
1885
- });
1886
- });
1887
- wssPresence.on('error', (err) => {
1888
- console.error('[cloud] Presence WebSocket server error:', err);
1889
- });
1890
- // Subscribe to cloud message bus for delivering messages to cloud users
1891
- cloudMessageBus.on('user-message', ({ username, message }) => {
1892
- const userState = onlineUsers.get(username);
1893
- if (!userState) {
1894
- console.warn(`[cloud] Cannot deliver message to ${username}: user not online`);
1895
- return;
1896
- }
1897
- // Deliver to all of the user's WebSocket connections
1898
- const payload = JSON.stringify({
1899
- type: 'direct_message',
1900
- from: message.from.agent,
1901
- body: message.body,
1902
- timestamp: message.timestamp,
1903
- metadata: {
1904
- ...message.metadata,
1905
- daemonId: message.from.daemonId,
1906
- daemonName: message.from.daemonName,
1907
- },
1908
- });
1909
- let delivered = 0;
1910
- userState.connections.forEach((ws) => {
1911
- if (ws.readyState === WebSocket.OPEN) {
1912
- ws.send(payload);
1913
- delivered++;
1914
- }
1915
- });
1916
- console.log(`[cloud] Delivered message to ${username} (${delivered} connections)`);
1917
- });
1918
- return {
1919
- app,
1920
- async start() {
1921
- // Run database migrations before accepting connections
1922
- console.log('[cloud] Running database migrations...');
1923
- await runMigrations();
1924
- // Initialize scaling orchestrator for auto-scaling
1925
- if (process.env.RELAY_CLOUD_ENABLED === 'true') {
1926
- try {
1927
- scalingOrchestrator = getScalingOrchestrator();
1928
- await scalingOrchestrator.initialize(config.redisUrl);
1929
- console.log('[cloud] Scaling orchestrator initialized');
1930
- // Log scaling events
1931
- scalingOrchestrator.on('scaling_started', (op) => {
1932
- console.log(`[scaling] Started: ${op.action} for user ${op.userId}`);
1933
- });
1934
- scalingOrchestrator.on('scaling_completed', (op) => {
1935
- console.log(`[scaling] Completed: ${op.action} for user ${op.userId}`);
1936
- });
1937
- scalingOrchestrator.on('scaling_error', ({ operation, error }) => {
1938
- console.error(`[scaling] Error: ${operation.action} for ${operation.userId}:`, error);
1939
- });
1940
- scalingOrchestrator.on('workspace_provisioned', (data) => {
1941
- console.log(`[scaling] Provisioned workspace ${data.workspaceId} for user ${data.userId}`);
1942
- });
1943
- }
1944
- catch (error) {
1945
- console.warn('[cloud] Failed to initialize scaling orchestrator:', error);
1946
- // Non-fatal - server can run without auto-scaling
1947
- }
1948
- // Start compute enforcement service (checks every 15 min)
1949
- try {
1950
- computeEnforcement = getComputeEnforcementService();
1951
- computeEnforcement.start();
1952
- console.log('[cloud] Compute enforcement service started');
1953
- }
1954
- catch (error) {
1955
- console.warn('[cloud] Failed to start compute enforcement:', error);
1956
- }
1957
- // Start intro expiration service (checks every hour for expired intro periods)
1958
- try {
1959
- introExpiration = getIntroExpirationService();
1960
- introExpiration.start();
1961
- console.log('[cloud] Intro expiration service started');
1962
- }
1963
- catch (error) {
1964
- console.warn('[cloud] Failed to start intro expiration:', error);
1965
- }
1966
- // Start workspace keepalive service (pings workspaces with active agents)
1967
- // This prevents Fly.io from idling machines that have running Claude agents
1968
- try {
1969
- workspaceKeepalive = getWorkspaceKeepaliveService();
1970
- workspaceKeepalive.start();
1971
- console.log('[cloud] Workspace keepalive service started');
1972
- }
1973
- catch (error) {
1974
- console.warn('[cloud] Failed to start workspace keepalive:', error);
1975
- }
1976
- }
1977
- // Start daemon stale check (mark daemons offline if no heartbeat for 2+ minutes)
1978
- // Runs every 60 seconds regardless of RELAY_CLOUD_ENABLED
1979
- daemonStaleCheckInterval = setInterval(async () => {
1980
- try {
1981
- const count = await db.linkedDaemons.markStale();
1982
- if (count > 0) {
1983
- console.log(`[cloud] Marked ${count} daemon(s) as offline (stale)`);
1984
- }
1985
- }
1986
- catch (error) {
1987
- console.error('[cloud] Failed to mark stale daemons:', error);
1988
- }
1989
- }, 60_000); // Every 60 seconds
1990
- console.log('[cloud] Daemon stale check started (60s interval)');
1991
- return new Promise((resolve) => {
1992
- server = httpServer.listen(config.port, () => {
1993
- console.log(`Agent Relay Cloud running on port ${config.port}`);
1994
- console.log(`Public URL: ${config.publicUrl}`);
1995
- console.log(`WebSocket: ws://localhost:${config.port}/ws/presence`);
1996
- resolve();
1997
- });
1998
- });
1999
- },
2000
- async stop() {
2001
- // Shutdown scaling orchestrator
2002
- if (scalingOrchestrator) {
2003
- await scalingOrchestrator.shutdown();
2004
- }
2005
- // Stop compute enforcement service
2006
- if (computeEnforcement) {
2007
- computeEnforcement.stop();
2008
- }
2009
- // Stop intro expiration service
2010
- if (introExpiration) {
2011
- introExpiration.stop();
2012
- }
2013
- // Stop workspace keepalive service
2014
- if (workspaceKeepalive) {
2015
- workspaceKeepalive.stop();
2016
- }
2017
- // Stop daemon stale check
2018
- if (daemonStaleCheckInterval) {
2019
- clearInterval(daemonStaleCheckInterval);
2020
- daemonStaleCheckInterval = null;
2021
- }
2022
- // Close WebSocket server
2023
- wssPresence.close();
2024
- if (server) {
2025
- await new Promise((resolve) => server.close(() => resolve()));
2026
- }
2027
- // Only quit Redis if client is still open
2028
- if (redisClient.isOpen) {
2029
- await redisClient.quit();
2030
- }
2031
- },
2032
- };
2033
- }
2034
- //# sourceMappingURL=server.js.map