@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.
- package/dist/api/admin.d.ts +8 -0
- package/dist/api/admin.d.ts.map +1 -0
- package/dist/api/admin.js +225 -0
- package/dist/api/admin.js.map +1 -0
- package/dist/api/auth.d.ts +20 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +136 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/billing.d.ts +7 -0
- package/dist/api/billing.d.ts.map +1 -0
- package/dist/api/billing.js +564 -0
- package/dist/api/billing.js.map +1 -0
- package/dist/api/cli-pty-runner.d.ts +53 -0
- package/dist/api/cli-pty-runner.d.ts.map +1 -0
- package/dist/api/cli-pty-runner.js +193 -0
- package/dist/api/cli-pty-runner.js.map +1 -0
- package/dist/api/codex-auth-helper.d.ts +21 -0
- package/dist/api/codex-auth-helper.d.ts.map +1 -0
- package/dist/api/codex-auth-helper.js +327 -0
- package/dist/api/codex-auth-helper.js.map +1 -0
- package/dist/api/consensus.d.ts +13 -0
- package/dist/api/consensus.d.ts.map +1 -0
- package/dist/api/consensus.js +261 -0
- package/dist/api/consensus.js.map +1 -0
- package/dist/api/coordinators.d.ts +8 -0
- package/dist/api/coordinators.d.ts.map +1 -0
- package/dist/api/coordinators.js +750 -0
- package/dist/api/coordinators.js.map +1 -0
- package/dist/api/daemons.d.ts +12 -0
- package/dist/api/daemons.d.ts.map +1 -0
- package/dist/api/daemons.js +535 -0
- package/dist/api/daemons.js.map +1 -0
- package/dist/api/generic-webhooks.d.ts +8 -0
- package/dist/api/generic-webhooks.d.ts.map +1 -0
- package/dist/api/generic-webhooks.js +129 -0
- package/dist/api/generic-webhooks.js.map +1 -0
- package/dist/api/git.d.ts +8 -0
- package/dist/api/git.d.ts.map +1 -0
- package/dist/api/git.js +269 -0
- package/dist/api/git.js.map +1 -0
- package/dist/api/github-app.d.ts +11 -0
- package/dist/api/github-app.d.ts.map +1 -0
- package/dist/api/github-app.js +223 -0
- package/dist/api/github-app.js.map +1 -0
- package/dist/api/middleware/planLimits.d.ts +43 -0
- package/dist/api/middleware/planLimits.d.ts.map +1 -0
- package/dist/api/middleware/planLimits.js +202 -0
- package/dist/api/middleware/planLimits.js.map +1 -0
- package/dist/api/monitoring.d.ts +11 -0
- package/dist/api/monitoring.d.ts.map +1 -0
- package/dist/api/monitoring.js +578 -0
- package/dist/api/monitoring.js.map +1 -0
- package/dist/api/nango-auth.d.ts +9 -0
- package/dist/api/nango-auth.d.ts.map +1 -0
- package/dist/api/nango-auth.js +674 -0
- package/dist/api/nango-auth.js.map +1 -0
- package/dist/api/onboarding.d.ts +15 -0
- package/dist/api/onboarding.d.ts.map +1 -0
- package/dist/api/onboarding.js +679 -0
- package/dist/api/onboarding.js.map +1 -0
- package/dist/api/policy.d.ts +8 -0
- package/dist/api/policy.d.ts.map +1 -0
- package/dist/api/policy.js +229 -0
- package/dist/api/policy.js.map +1 -0
- package/dist/api/provider-env.d.ts +14 -0
- package/dist/api/provider-env.d.ts.map +1 -0
- package/dist/api/provider-env.js +75 -0
- package/dist/api/provider-env.js.map +1 -0
- package/dist/api/providers.d.ts +7 -0
- package/dist/api/providers.d.ts.map +1 -0
- package/dist/api/providers.js +564 -0
- package/dist/api/providers.js.map +1 -0
- package/dist/api/repos.d.ts +8 -0
- package/dist/api/repos.d.ts.map +1 -0
- package/dist/api/repos.js +577 -0
- package/dist/api/repos.js.map +1 -0
- package/dist/api/sessions.d.ts +11 -0
- package/dist/api/sessions.d.ts.map +1 -0
- package/dist/api/sessions.js +302 -0
- package/dist/api/sessions.js.map +1 -0
- package/dist/api/teams.d.ts +7 -0
- package/dist/api/teams.d.ts.map +1 -0
- package/dist/api/teams.js +281 -0
- package/dist/api/teams.js.map +1 -0
- package/dist/api/test-helpers.d.ts +10 -0
- package/dist/api/test-helpers.d.ts.map +1 -0
- package/dist/api/test-helpers.js +745 -0
- package/dist/api/test-helpers.js.map +1 -0
- package/dist/api/usage.d.ts +7 -0
- package/dist/api/usage.d.ts.map +1 -0
- package/dist/api/usage.js +111 -0
- package/dist/api/usage.js.map +1 -0
- package/dist/api/webhooks.d.ts +8 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +645 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/api/workspaces.d.ts +25 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +1799 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/billing/index.d.ts +9 -0
- package/dist/billing/index.d.ts.map +1 -0
- package/dist/billing/index.js +9 -0
- package/dist/billing/index.js.map +1 -0
- package/dist/billing/plans.d.ts +39 -0
- package/dist/billing/plans.d.ts.map +1 -0
- package/dist/billing/plans.js +245 -0
- package/dist/billing/plans.js.map +1 -0
- package/dist/billing/service.d.ts +80 -0
- package/dist/billing/service.d.ts.map +1 -0
- package/dist/billing/service.js +388 -0
- package/dist/billing/service.js.map +1 -0
- package/dist/billing/types.d.ts +141 -0
- package/dist/billing/types.d.ts.map +1 -0
- package/dist/billing/types.js +7 -0
- package/dist/billing/types.js.map +1 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -0
- package/dist/db/bulk-ingest.d.ts +89 -0
- package/dist/db/bulk-ingest.d.ts.map +1 -0
- package/dist/db/bulk-ingest.js +268 -0
- package/dist/db/bulk-ingest.js.map +1 -0
- package/dist/db/drizzle.d.ts +256 -0
- package/dist/db/drizzle.d.ts.map +1 -0
- package/dist/db/drizzle.js +1286 -0
- package/dist/db/drizzle.js.map +1 -0
- package/dist/db/index.d.ts +55 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +68 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +4873 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +620 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/provisioner/index.d.ts +207 -0
- package/dist/provisioner/index.d.ts.map +1 -0
- package/dist/provisioner/index.js +2114 -0
- package/dist/provisioner/index.js.map +1 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1924 -0
- package/dist/server.js.map +1 -0
- package/dist/services/auto-scaler.d.ts +152 -0
- package/dist/services/auto-scaler.d.ts.map +1 -0
- package/dist/services/auto-scaler.js +439 -0
- package/dist/services/auto-scaler.js.map +1 -0
- package/dist/services/capacity-manager.d.ts +148 -0
- package/dist/services/capacity-manager.d.ts.map +1 -0
- package/dist/services/capacity-manager.js +449 -0
- package/dist/services/capacity-manager.js.map +1 -0
- package/dist/services/ci-agent-spawner.d.ts +49 -0
- package/dist/services/ci-agent-spawner.d.ts.map +1 -0
- package/dist/services/ci-agent-spawner.js +373 -0
- package/dist/services/ci-agent-spawner.js.map +1 -0
- package/dist/services/cloud-message-bus.d.ts +28 -0
- package/dist/services/cloud-message-bus.d.ts.map +1 -0
- package/dist/services/cloud-message-bus.js +19 -0
- package/dist/services/cloud-message-bus.js.map +1 -0
- package/dist/services/compute-enforcement.d.ts +57 -0
- package/dist/services/compute-enforcement.d.ts.map +1 -0
- package/dist/services/compute-enforcement.js +175 -0
- package/dist/services/compute-enforcement.js.map +1 -0
- package/dist/services/coordinator.d.ts +62 -0
- package/dist/services/coordinator.d.ts.map +1 -0
- package/dist/services/coordinator.js +389 -0
- package/dist/services/coordinator.js.map +1 -0
- package/dist/services/index.d.ts +17 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +25 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/intro-expiration.d.ts +60 -0
- package/dist/services/intro-expiration.d.ts.map +1 -0
- package/dist/services/intro-expiration.js +252 -0
- package/dist/services/intro-expiration.js.map +1 -0
- package/dist/services/mention-handler.d.ts +65 -0
- package/dist/services/mention-handler.d.ts.map +1 -0
- package/dist/services/mention-handler.js +405 -0
- package/dist/services/mention-handler.js.map +1 -0
- package/dist/services/nango.d.ts +201 -0
- package/dist/services/nango.d.ts.map +1 -0
- package/dist/services/nango.js +392 -0
- package/dist/services/nango.js.map +1 -0
- package/dist/services/persistence.d.ts +131 -0
- package/dist/services/persistence.d.ts.map +1 -0
- package/dist/services/persistence.js +200 -0
- package/dist/services/persistence.js.map +1 -0
- package/dist/services/planLimits.d.ts +147 -0
- package/dist/services/planLimits.d.ts.map +1 -0
- package/dist/services/planLimits.js +335 -0
- package/dist/services/planLimits.js.map +1 -0
- package/dist/services/presence-registry.d.ts +56 -0
- package/dist/services/presence-registry.d.ts.map +1 -0
- package/dist/services/presence-registry.js +91 -0
- package/dist/services/presence-registry.js.map +1 -0
- package/dist/services/scaling-orchestrator.d.ts +159 -0
- package/dist/services/scaling-orchestrator.d.ts.map +1 -0
- package/dist/services/scaling-orchestrator.js +502 -0
- package/dist/services/scaling-orchestrator.js.map +1 -0
- package/dist/services/scaling-policy.d.ts +121 -0
- package/dist/services/scaling-policy.d.ts.map +1 -0
- package/dist/services/scaling-policy.js +415 -0
- package/dist/services/scaling-policy.js.map +1 -0
- package/dist/services/ssh-security.d.ts +31 -0
- package/dist/services/ssh-security.d.ts.map +1 -0
- package/dist/services/ssh-security.js +63 -0
- package/dist/services/ssh-security.js.map +1 -0
- package/dist/services/workspace-keepalive.d.ts +76 -0
- package/dist/services/workspace-keepalive.d.ts.map +1 -0
- package/dist/services/workspace-keepalive.js +234 -0
- package/dist/services/workspace-keepalive.js.map +1 -0
- package/dist/shims/consensus.d.ts +23 -0
- package/dist/shims/consensus.d.ts.map +1 -0
- package/dist/shims/consensus.js +5 -0
- package/dist/shims/consensus.js.map +1 -0
- package/dist/webhooks/index.d.ts +24 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +29 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/parsers/github.d.ts +8 -0
- package/dist/webhooks/parsers/github.d.ts.map +1 -0
- package/dist/webhooks/parsers/github.js +234 -0
- package/dist/webhooks/parsers/github.js.map +1 -0
- package/dist/webhooks/parsers/index.d.ts +23 -0
- package/dist/webhooks/parsers/index.d.ts.map +1 -0
- package/dist/webhooks/parsers/index.js +30 -0
- package/dist/webhooks/parsers/index.js.map +1 -0
- package/dist/webhooks/parsers/linear.d.ts +9 -0
- package/dist/webhooks/parsers/linear.d.ts.map +1 -0
- package/dist/webhooks/parsers/linear.js +258 -0
- package/dist/webhooks/parsers/linear.js.map +1 -0
- package/dist/webhooks/parsers/slack.d.ts +9 -0
- package/dist/webhooks/parsers/slack.d.ts.map +1 -0
- package/dist/webhooks/parsers/slack.js +214 -0
- package/dist/webhooks/parsers/slack.js.map +1 -0
- package/dist/webhooks/responders/github.d.ts +8 -0
- package/dist/webhooks/responders/github.d.ts.map +1 -0
- package/dist/webhooks/responders/github.js +73 -0
- package/dist/webhooks/responders/github.js.map +1 -0
- package/dist/webhooks/responders/index.d.ts +23 -0
- package/dist/webhooks/responders/index.d.ts.map +1 -0
- package/dist/webhooks/responders/index.js +30 -0
- package/dist/webhooks/responders/index.js.map +1 -0
- package/dist/webhooks/responders/linear.d.ts +9 -0
- package/dist/webhooks/responders/linear.d.ts.map +1 -0
- package/dist/webhooks/responders/linear.js +149 -0
- package/dist/webhooks/responders/linear.js.map +1 -0
- package/dist/webhooks/responders/slack.d.ts +20 -0
- package/dist/webhooks/responders/slack.d.ts.map +1 -0
- package/dist/webhooks/responders/slack.js +178 -0
- package/dist/webhooks/responders/slack.js.map +1 -0
- package/dist/webhooks/router.d.ts +25 -0
- package/dist/webhooks/router.d.ts.map +1 -0
- package/dist/webhooks/router.js +504 -0
- package/dist/webhooks/router.js.map +1 -0
- package/dist/webhooks/rules-engine.d.ts +24 -0
- package/dist/webhooks/rules-engine.d.ts.map +1 -0
- package/dist/webhooks/rules-engine.js +287 -0
- package/dist/webhooks/rules-engine.js.map +1 -0
- package/dist/webhooks/types.d.ts +186 -0
- package/dist/webhooks/types.d.ts.map +1 -0
- package/dist/webhooks/types.js +8 -0
- package/dist/webhooks/types.js.map +1 -0
- 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
|