@agent-relay/cloud 0.1.0

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