@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
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces API Routes
|
|
3
|
+
*
|
|
4
|
+
* One-click workspace provisioning and management.
|
|
5
|
+
* Includes auto-access based on GitHub repo permissions.
|
|
6
|
+
*/
|
|
7
|
+
import { Router } from 'express';
|
|
8
|
+
import { requireAuth } from './auth.js';
|
|
9
|
+
import { db } from '../db/index.js';
|
|
10
|
+
import { getProvisioner, getProvisioningStage } from '../provisioner/index.js';
|
|
11
|
+
import { checkWorkspaceLimit } from './middleware/planLimits.js';
|
|
12
|
+
import { getConfig } from '../config.js';
|
|
13
|
+
import { nangoService } from '../services/nango.js';
|
|
14
|
+
// Simple in-memory cache for workspace access checks
|
|
15
|
+
// Key: `${userId}:${workspaceId}`
|
|
16
|
+
const workspaceAccessCache = new Map();
|
|
17
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
function getCachedAccess(userId, workspaceId) {
|
|
19
|
+
const key = `${userId}:${workspaceId}`;
|
|
20
|
+
const cached = workspaceAccessCache.get(key);
|
|
21
|
+
if (!cached)
|
|
22
|
+
return null;
|
|
23
|
+
// Check if expired
|
|
24
|
+
if (Date.now() - cached.cachedAt > CACHE_TTL_MS) {
|
|
25
|
+
workspaceAccessCache.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
function setCachedAccess(userId, workspaceId, access) {
|
|
31
|
+
const key = `${userId}:${workspaceId}`;
|
|
32
|
+
workspaceAccessCache.set(key, { ...access, cachedAt: Date.now() });
|
|
33
|
+
}
|
|
34
|
+
function _invalidateCachedAccess(userId, workspaceId) {
|
|
35
|
+
if (workspaceId) {
|
|
36
|
+
workspaceAccessCache.delete(`${userId}:${workspaceId}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Invalidate all cache entries for this user
|
|
40
|
+
for (const key of workspaceAccessCache.keys()) {
|
|
41
|
+
if (key.startsWith(`${userId}:`)) {
|
|
42
|
+
workspaceAccessCache.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Cache keyed by nangoConnectionId
|
|
48
|
+
const userReposCache = new Map();
|
|
49
|
+
const USER_REPOS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - hard expiry
|
|
50
|
+
const STALE_WHILE_REVALIDATE_MS = 5 * 60 * 1000; // Trigger background refresh after 5 minutes
|
|
51
|
+
const MAX_CACHE_ENTRIES = 500; // Prevent unbounded growth
|
|
52
|
+
/**
|
|
53
|
+
* Evict oldest cache entries if we exceed the limit
|
|
54
|
+
*/
|
|
55
|
+
function evictOldestCacheEntries() {
|
|
56
|
+
if (userReposCache.size <= MAX_CACHE_ENTRIES)
|
|
57
|
+
return;
|
|
58
|
+
// Convert to array, sort by cachedAt (oldest first), delete oldest entries
|
|
59
|
+
const entries = Array.from(userReposCache.entries())
|
|
60
|
+
.sort((a, b) => a[1].cachedAt - b[1].cachedAt);
|
|
61
|
+
const toEvict = entries.slice(0, entries.length - MAX_CACHE_ENTRIES);
|
|
62
|
+
for (const [key] of toEvict) {
|
|
63
|
+
console.log(`[repos-cache] Evicting oldest cache entry: ${key.substring(0, 8)}`);
|
|
64
|
+
userReposCache.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Background refresh function that paginates through ALL user repos
|
|
69
|
+
*/
|
|
70
|
+
async function refreshUserReposInBackground(nangoConnectionId) {
|
|
71
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
72
|
+
// Don't start if refresh already in progress
|
|
73
|
+
if (cached?.refreshInProgress) {
|
|
74
|
+
console.log(`[repos-cache] Background refresh already in progress for ${nangoConnectionId.substring(0, 8)}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Mark as refreshing
|
|
78
|
+
if (cached) {
|
|
79
|
+
cached.refreshInProgress = true;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Create placeholder entry
|
|
83
|
+
userReposCache.set(nangoConnectionId, {
|
|
84
|
+
repositories: [],
|
|
85
|
+
cachedAt: Date.now(),
|
|
86
|
+
isComplete: false,
|
|
87
|
+
refreshInProgress: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
console.log(`[repos-cache] Starting background refresh for ${nangoConnectionId.substring(0, 8)}`);
|
|
91
|
+
try {
|
|
92
|
+
const allRepos = [];
|
|
93
|
+
let page = 1;
|
|
94
|
+
let hasMore = true;
|
|
95
|
+
const MAX_PAGES = 20; // Safety limit: 20 pages * 100 repos = 2000 repos max
|
|
96
|
+
while (hasMore && page <= MAX_PAGES) {
|
|
97
|
+
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
98
|
+
perPage: 100,
|
|
99
|
+
page,
|
|
100
|
+
type: 'all',
|
|
101
|
+
});
|
|
102
|
+
allRepos.push(...result.repositories.map(r => ({
|
|
103
|
+
fullName: r.fullName,
|
|
104
|
+
permissions: r.permissions,
|
|
105
|
+
})));
|
|
106
|
+
hasMore = result.hasMore;
|
|
107
|
+
page++;
|
|
108
|
+
// Small delay between pages to avoid rate limiting
|
|
109
|
+
if (hasMore) {
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.log(`[repos-cache] Background refresh complete for ${nangoConnectionId.substring(0, 8)}: ${allRepos.length} repos across ${page - 1} pages`);
|
|
114
|
+
userReposCache.set(nangoConnectionId, {
|
|
115
|
+
repositories: allRepos,
|
|
116
|
+
cachedAt: Date.now(),
|
|
117
|
+
isComplete: true,
|
|
118
|
+
refreshInProgress: false,
|
|
119
|
+
});
|
|
120
|
+
evictOldestCacheEntries();
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`[repos-cache] Background refresh failed for ${nangoConnectionId.substring(0, 8)}:`, err);
|
|
124
|
+
// Mark refresh as done even on error, keep existing data if any
|
|
125
|
+
const existing = userReposCache.get(nangoConnectionId);
|
|
126
|
+
if (existing) {
|
|
127
|
+
existing.refreshInProgress = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get cached user repos, triggering background refresh if stale
|
|
133
|
+
* Returns null if no cache exists (caller should fetch first page synchronously)
|
|
134
|
+
*/
|
|
135
|
+
function getCachedUserRepos(nangoConnectionId) {
|
|
136
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
137
|
+
if (!cached)
|
|
138
|
+
return null;
|
|
139
|
+
const age = Date.now() - cached.cachedAt;
|
|
140
|
+
// If expired, delete and return null
|
|
141
|
+
if (age > USER_REPOS_CACHE_TTL_MS) {
|
|
142
|
+
console.log(`[repos-cache] Cache expired for ${nangoConnectionId.substring(0, 8)}`);
|
|
143
|
+
userReposCache.delete(nangoConnectionId);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
// If stale but valid, trigger background refresh
|
|
147
|
+
if (age > STALE_WHILE_REVALIDATE_MS && !cached.refreshInProgress) {
|
|
148
|
+
console.log(`[repos-cache] Cache stale for ${nangoConnectionId.substring(0, 8)}, triggering background refresh`);
|
|
149
|
+
// Fire and forget - don't await
|
|
150
|
+
refreshUserReposInBackground(nangoConnectionId).catch(() => { });
|
|
151
|
+
}
|
|
152
|
+
return cached;
|
|
153
|
+
}
|
|
154
|
+
// Track in-flight initializations to prevent duplicate API calls
|
|
155
|
+
const initializingConnections = new Set();
|
|
156
|
+
/**
|
|
157
|
+
* Initialize cache with first page and trigger background refresh for rest
|
|
158
|
+
* Returns the first page of repos immediately
|
|
159
|
+
*/
|
|
160
|
+
async function initializeUserReposCache(nangoConnectionId) {
|
|
161
|
+
// Check if another request is already initializing this connection
|
|
162
|
+
if (initializingConnections.has(nangoConnectionId)) {
|
|
163
|
+
console.log(`[repos-cache] Another request is initializing ${nangoConnectionId.substring(0, 8)}, waiting...`);
|
|
164
|
+
// Wait a bit and check cache again
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
166
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
167
|
+
if (cached) {
|
|
168
|
+
return cached.repositories;
|
|
169
|
+
}
|
|
170
|
+
// Still no cache, fall through to initialize (previous request may have failed)
|
|
171
|
+
}
|
|
172
|
+
initializingConnections.add(nangoConnectionId);
|
|
173
|
+
try {
|
|
174
|
+
// Fetch first page synchronously
|
|
175
|
+
const firstPage = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
176
|
+
perPage: 100,
|
|
177
|
+
page: 1,
|
|
178
|
+
type: 'all',
|
|
179
|
+
});
|
|
180
|
+
const repos = firstPage.repositories.map(r => ({
|
|
181
|
+
fullName: r.fullName,
|
|
182
|
+
permissions: r.permissions,
|
|
183
|
+
}));
|
|
184
|
+
// Store first page immediately
|
|
185
|
+
userReposCache.set(nangoConnectionId, {
|
|
186
|
+
repositories: repos,
|
|
187
|
+
cachedAt: Date.now(),
|
|
188
|
+
isComplete: !firstPage.hasMore,
|
|
189
|
+
refreshInProgress: firstPage.hasMore, // Will be refreshing if there's more
|
|
190
|
+
});
|
|
191
|
+
evictOldestCacheEntries();
|
|
192
|
+
// If there are more pages, trigger background refresh to get the rest
|
|
193
|
+
if (firstPage.hasMore) {
|
|
194
|
+
console.log(`[repos-cache] First page has ${repos.length} repos, more available - triggering background pagination`);
|
|
195
|
+
// Fire and forget - reuse the shared background refresh function
|
|
196
|
+
// But start from page 2 with the existing repos
|
|
197
|
+
(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const allRepos = [...repos];
|
|
200
|
+
let page = 2;
|
|
201
|
+
let hasMore = true;
|
|
202
|
+
const MAX_PAGES = 20;
|
|
203
|
+
while (hasMore && page <= MAX_PAGES) {
|
|
204
|
+
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
205
|
+
perPage: 100,
|
|
206
|
+
page,
|
|
207
|
+
type: 'all',
|
|
208
|
+
});
|
|
209
|
+
allRepos.push(...result.repositories.map(r => ({
|
|
210
|
+
fullName: r.fullName,
|
|
211
|
+
permissions: r.permissions,
|
|
212
|
+
})));
|
|
213
|
+
hasMore = result.hasMore;
|
|
214
|
+
page++;
|
|
215
|
+
if (hasMore) {
|
|
216
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
console.log(`[repos-cache] Background pagination complete: ${allRepos.length} total repos`);
|
|
220
|
+
userReposCache.set(nangoConnectionId, {
|
|
221
|
+
repositories: allRepos,
|
|
222
|
+
cachedAt: Date.now(),
|
|
223
|
+
isComplete: true,
|
|
224
|
+
refreshInProgress: false,
|
|
225
|
+
});
|
|
226
|
+
evictOldestCacheEntries();
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error('[repos-cache] Background pagination failed:', err);
|
|
230
|
+
const existing = userReposCache.get(nangoConnectionId);
|
|
231
|
+
if (existing) {
|
|
232
|
+
existing.refreshInProgress = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
}
|
|
237
|
+
return repos;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
initializingConnections.delete(nangoConnectionId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// Workspace Access Middleware
|
|
245
|
+
// ============================================================================
|
|
246
|
+
/**
|
|
247
|
+
* Check if user has access to a workspace via:
|
|
248
|
+
* 1. Workspace ownership (userId matches)
|
|
249
|
+
* 2. Explicit workspace_members record
|
|
250
|
+
* 3. GitHub repo access (just-in-time check via Nango)
|
|
251
|
+
*/
|
|
252
|
+
export async function checkWorkspaceAccess(userId, workspaceId) {
|
|
253
|
+
// Check cache first
|
|
254
|
+
const cached = getCachedAccess(userId, workspaceId);
|
|
255
|
+
if (cached) {
|
|
256
|
+
return { hasAccess: cached.hasAccess, accessType: cached.accessType, permission: cached.permission };
|
|
257
|
+
}
|
|
258
|
+
// 1. Check if user is workspace owner
|
|
259
|
+
const workspace = await db.workspaces.findById(workspaceId);
|
|
260
|
+
if (!workspace) {
|
|
261
|
+
return { hasAccess: false, accessType: 'none' };
|
|
262
|
+
}
|
|
263
|
+
if (workspace.userId === userId) {
|
|
264
|
+
setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'owner', permission: 'admin' });
|
|
265
|
+
return { hasAccess: true, accessType: 'owner', permission: 'admin' };
|
|
266
|
+
}
|
|
267
|
+
// 2. Check explicit workspace_members
|
|
268
|
+
const member = await db.workspaceMembers.findMembership(workspaceId, userId);
|
|
269
|
+
if (member && member.acceptedAt) {
|
|
270
|
+
const permission = member.role === 'admin' ? 'admin' : member.role === 'member' ? 'write' : 'read';
|
|
271
|
+
setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'member', permission });
|
|
272
|
+
return { hasAccess: true, accessType: 'member', permission };
|
|
273
|
+
}
|
|
274
|
+
// 3. Check GitHub repo access (just-in-time)
|
|
275
|
+
const user = await db.users.findById(userId);
|
|
276
|
+
if (!user?.nangoConnectionId) {
|
|
277
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
278
|
+
return { hasAccess: false, accessType: 'none' };
|
|
279
|
+
}
|
|
280
|
+
const repos = await db.repositories.findByWorkspaceId(workspaceId);
|
|
281
|
+
if (repos.length === 0) {
|
|
282
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
283
|
+
return { hasAccess: false, accessType: 'none' };
|
|
284
|
+
}
|
|
285
|
+
// Check if user has access to ANY repo in this workspace
|
|
286
|
+
for (const repo of repos) {
|
|
287
|
+
try {
|
|
288
|
+
const [owner, repoName] = repo.githubFullName.split('/');
|
|
289
|
+
const accessResult = await nangoService.checkUserRepoAccess(user.nangoConnectionId, owner, repoName);
|
|
290
|
+
if (accessResult.hasAccess && accessResult.permission && accessResult.permission !== 'none') {
|
|
291
|
+
setCachedAccess(userId, workspaceId, {
|
|
292
|
+
hasAccess: true,
|
|
293
|
+
accessType: 'contributor',
|
|
294
|
+
permission: accessResult.permission
|
|
295
|
+
});
|
|
296
|
+
return { hasAccess: true, accessType: 'contributor', permission: accessResult.permission };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// Continue to next repo on error
|
|
301
|
+
console.warn(`[workspace-access] Failed to check repo access for ${repo.githubFullName}:`, err);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// No access found
|
|
305
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
306
|
+
return { hasAccess: false, accessType: 'none' };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Middleware to require workspace access.
|
|
310
|
+
* Checks ownership, membership, or GitHub repo access.
|
|
311
|
+
*/
|
|
312
|
+
export function requireWorkspaceAccess(req, res, next) {
|
|
313
|
+
const userId = req.session.userId;
|
|
314
|
+
const workspaceId = (req.params.id || req.params.workspaceId);
|
|
315
|
+
if (!userId) {
|
|
316
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!workspaceId) {
|
|
320
|
+
res.status(400).json({ error: 'Workspace ID required' });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
checkWorkspaceAccess(userId, workspaceId)
|
|
324
|
+
.then((result) => {
|
|
325
|
+
if (result.hasAccess) {
|
|
326
|
+
// Attach access info to request for downstream use
|
|
327
|
+
req.workspaceAccess = {
|
|
328
|
+
accessType: result.accessType,
|
|
329
|
+
permission: result.permission,
|
|
330
|
+
};
|
|
331
|
+
next();
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
res.status(403).json({ error: 'No access to this workspace' });
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.catch((err) => {
|
|
338
|
+
console.error('[workspace-access] Error checking access:', err);
|
|
339
|
+
res.status(500).json({ error: 'Failed to check workspace access' });
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
export const workspacesRouter = Router();
|
|
343
|
+
// All routes require authentication
|
|
344
|
+
workspacesRouter.use(requireAuth);
|
|
345
|
+
/**
|
|
346
|
+
* GET /api/workspaces
|
|
347
|
+
* List user's workspaces (owned + member workspaces)
|
|
348
|
+
*/
|
|
349
|
+
workspacesRouter.get('/', async (req, res) => {
|
|
350
|
+
const userId = req.session.userId;
|
|
351
|
+
try {
|
|
352
|
+
// Get owned workspaces
|
|
353
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
354
|
+
// Get workspaces where user is a member
|
|
355
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
356
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
357
|
+
const memberWorkspaceIds = memberships
|
|
358
|
+
.map((m) => m.workspaceId)
|
|
359
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned to prevent duplicates
|
|
360
|
+
// Fetch member workspaces (optimize with Promise.all instead of loop)
|
|
361
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
362
|
+
// Combine and sort by creation date
|
|
363
|
+
const allWorkspaces = [...ownedWorkspaces, ...memberWorkspaces].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
364
|
+
res.json({
|
|
365
|
+
workspaces: allWorkspaces.map((w) => ({
|
|
366
|
+
id: w.id,
|
|
367
|
+
name: w.name,
|
|
368
|
+
status: w.status,
|
|
369
|
+
publicUrl: w.publicUrl,
|
|
370
|
+
providers: w.config.providers,
|
|
371
|
+
repositories: w.config.repositories,
|
|
372
|
+
createdAt: w.createdAt,
|
|
373
|
+
isOwner: w.userId === userId, // Flag to indicate ownership
|
|
374
|
+
})),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
console.error('Error listing workspaces:', error);
|
|
379
|
+
res.status(500).json({ error: 'Failed to list workspaces' });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
/**
|
|
383
|
+
* POST /api/workspaces
|
|
384
|
+
* Create (provision) a new workspace
|
|
385
|
+
*/
|
|
386
|
+
workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
|
|
387
|
+
const userId = req.session.userId;
|
|
388
|
+
const { name, providers, repositories, supervisorEnabled, maxAgents } = req.body;
|
|
389
|
+
// Validation
|
|
390
|
+
if (!name || typeof name !== 'string') {
|
|
391
|
+
return res.status(400).json({ error: 'Name is required' });
|
|
392
|
+
}
|
|
393
|
+
if (!providers || !Array.isArray(providers) || providers.length === 0) {
|
|
394
|
+
return res.status(400).json({ error: 'At least one provider is required' });
|
|
395
|
+
}
|
|
396
|
+
if (!repositories || !Array.isArray(repositories)) {
|
|
397
|
+
return res.status(400).json({ error: 'Repositories array is required' });
|
|
398
|
+
}
|
|
399
|
+
// Check if any of the repos already have a workspace the user can access
|
|
400
|
+
// This prevents creating duplicate workspaces for the same repo
|
|
401
|
+
for (const repoFullName of repositories) {
|
|
402
|
+
const existingRepos = await db.repositories.findByGithubFullName(repoFullName);
|
|
403
|
+
for (const existingRepo of existingRepos) {
|
|
404
|
+
if (existingRepo.workspaceId) {
|
|
405
|
+
const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
|
|
406
|
+
if (accessResult.hasAccess) {
|
|
407
|
+
const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
|
|
408
|
+
if (existingWorkspace) {
|
|
409
|
+
console.log(`[workspaces/create] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repoFullName}`);
|
|
410
|
+
return res.status(409).json({
|
|
411
|
+
error: 'A workspace already exists for one of these repositories',
|
|
412
|
+
existingWorkspace: {
|
|
413
|
+
id: existingWorkspace.id,
|
|
414
|
+
name: existingWorkspace.name,
|
|
415
|
+
publicUrl: existingWorkspace.publicUrl,
|
|
416
|
+
accessType: accessResult.accessType,
|
|
417
|
+
},
|
|
418
|
+
conflictingRepo: repoFullName,
|
|
419
|
+
message: `You already have ${accessResult.accessType} access to workspace "${existingWorkspace.name}" which includes ${repoFullName}.`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Verify user has credentials for all providers
|
|
427
|
+
const credentials = await db.credentials.findByUserId(userId);
|
|
428
|
+
const connectedProviders = new Set(credentials.map((c) => c.provider));
|
|
429
|
+
for (const provider of providers) {
|
|
430
|
+
if (!connectedProviders.has(provider)) {
|
|
431
|
+
return res.status(400).json({
|
|
432
|
+
error: `Provider ${provider} not connected. Please connect it first.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const provisioner = getProvisioner();
|
|
438
|
+
const result = await provisioner.provision({
|
|
439
|
+
userId,
|
|
440
|
+
name,
|
|
441
|
+
providers,
|
|
442
|
+
repositories,
|
|
443
|
+
supervisorEnabled,
|
|
444
|
+
maxAgents,
|
|
445
|
+
});
|
|
446
|
+
if (result.status === 'error') {
|
|
447
|
+
return res.status(500).json({
|
|
448
|
+
error: 'Failed to provision workspace',
|
|
449
|
+
details: result.error,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
res.status(201).json({
|
|
453
|
+
workspaceId: result.workspaceId,
|
|
454
|
+
status: result.status,
|
|
455
|
+
publicUrl: result.publicUrl,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
console.error('Error creating workspace:', error);
|
|
460
|
+
res.status(500).json({ error: 'Failed to create workspace' });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
/**
|
|
464
|
+
* GET /api/workspaces/summary
|
|
465
|
+
* Get summary of all user workspaces for dashboard status indicator (owned + member workspaces)
|
|
466
|
+
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
467
|
+
*/
|
|
468
|
+
workspacesRouter.get('/summary', async (req, res) => {
|
|
469
|
+
const userId = req.session.userId;
|
|
470
|
+
try {
|
|
471
|
+
// Get owned workspaces
|
|
472
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
473
|
+
// Get workspaces where user is a member
|
|
474
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
475
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
476
|
+
const memberWorkspaceIds = memberships
|
|
477
|
+
.map((m) => m.workspaceId)
|
|
478
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId));
|
|
479
|
+
// Fetch member workspaces (optimize with Promise.all)
|
|
480
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
481
|
+
const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
|
|
482
|
+
const provisioner = getProvisioner();
|
|
483
|
+
// Get live status for each workspace
|
|
484
|
+
const workspaceSummaries = await Promise.all(workspaces.map(async (w) => {
|
|
485
|
+
let liveStatus = w.status;
|
|
486
|
+
try {
|
|
487
|
+
liveStatus = await provisioner.getStatus(w.id);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// Fall back to DB status
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
id: w.id,
|
|
494
|
+
name: w.name,
|
|
495
|
+
status: liveStatus,
|
|
496
|
+
publicUrl: w.publicUrl,
|
|
497
|
+
isStopped: liveStatus === 'stopped',
|
|
498
|
+
isRunning: liveStatus === 'running',
|
|
499
|
+
isProvisioning: liveStatus === 'provisioning',
|
|
500
|
+
hasError: liveStatus === 'error',
|
|
501
|
+
};
|
|
502
|
+
}));
|
|
503
|
+
// Overall status for quick dashboard indicator
|
|
504
|
+
const hasRunningWorkspace = workspaceSummaries.some(w => w.isRunning);
|
|
505
|
+
const hasStoppedWorkspace = workspaceSummaries.some(w => w.isStopped);
|
|
506
|
+
const hasProvisioningWorkspace = workspaceSummaries.some(w => w.isProvisioning);
|
|
507
|
+
res.json({
|
|
508
|
+
workspaces: workspaceSummaries,
|
|
509
|
+
summary: {
|
|
510
|
+
total: workspaceSummaries.length,
|
|
511
|
+
running: workspaceSummaries.filter(w => w.isRunning).length,
|
|
512
|
+
stopped: workspaceSummaries.filter(w => w.isStopped).length,
|
|
513
|
+
provisioning: workspaceSummaries.filter(w => w.isProvisioning).length,
|
|
514
|
+
error: workspaceSummaries.filter(w => w.hasError).length,
|
|
515
|
+
},
|
|
516
|
+
overallStatus: hasRunningWorkspace
|
|
517
|
+
? 'ready'
|
|
518
|
+
: hasProvisioningWorkspace
|
|
519
|
+
? 'provisioning'
|
|
520
|
+
: hasStoppedWorkspace
|
|
521
|
+
? 'stopped'
|
|
522
|
+
: workspaceSummaries.length === 0
|
|
523
|
+
? 'none'
|
|
524
|
+
: 'error',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
console.error('Error getting workspace summary:', error);
|
|
529
|
+
res.status(500).json({ error: 'Failed to get workspace summary' });
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
/**
|
|
533
|
+
* GET /api/workspaces/primary
|
|
534
|
+
* Get the user's primary workspace (first/default) with live status (owned + member workspaces)
|
|
535
|
+
* Used by dashboard to show quick status indicator
|
|
536
|
+
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
537
|
+
*/
|
|
538
|
+
workspacesRouter.get('/primary', async (req, res) => {
|
|
539
|
+
const userId = req.session.userId;
|
|
540
|
+
try {
|
|
541
|
+
// Get owned workspaces
|
|
542
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
543
|
+
// Get workspaces where user is a member
|
|
544
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
545
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
546
|
+
const memberWorkspaceIds = memberships
|
|
547
|
+
.map((m) => m.workspaceId)
|
|
548
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId));
|
|
549
|
+
// Fetch member workspaces (optimize with Promise.all)
|
|
550
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
551
|
+
const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
|
|
552
|
+
if (workspaces.length === 0) {
|
|
553
|
+
return res.json({
|
|
554
|
+
exists: false,
|
|
555
|
+
message: 'No workspace found. Connect a repository to auto-provision one.',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const primary = workspaces[0];
|
|
559
|
+
const provisioner = getProvisioner();
|
|
560
|
+
let liveStatus = primary.status;
|
|
561
|
+
try {
|
|
562
|
+
liveStatus = await provisioner.getStatus(primary.id);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// Fall back to DB status
|
|
566
|
+
}
|
|
567
|
+
res.json({
|
|
568
|
+
exists: true,
|
|
569
|
+
workspace: {
|
|
570
|
+
id: primary.id,
|
|
571
|
+
name: primary.name,
|
|
572
|
+
status: liveStatus,
|
|
573
|
+
publicUrl: primary.publicUrl,
|
|
574
|
+
isStopped: liveStatus === 'stopped',
|
|
575
|
+
isRunning: liveStatus === 'running',
|
|
576
|
+
isProvisioning: liveStatus === 'provisioning',
|
|
577
|
+
hasError: liveStatus === 'error',
|
|
578
|
+
config: {
|
|
579
|
+
providers: primary.config.providers || [],
|
|
580
|
+
repositories: primary.config.repositories || [],
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
// Quick messages for UI
|
|
584
|
+
statusMessage: liveStatus === 'running'
|
|
585
|
+
? 'Workspace is running'
|
|
586
|
+
: liveStatus === 'stopped'
|
|
587
|
+
? 'Workspace is idle (will start automatically when needed)'
|
|
588
|
+
: liveStatus === 'provisioning'
|
|
589
|
+
? 'Workspace is being provisioned...'
|
|
590
|
+
: 'Workspace has an error',
|
|
591
|
+
actionNeeded: liveStatus === 'stopped'
|
|
592
|
+
? 'wakeup'
|
|
593
|
+
: liveStatus === 'error'
|
|
594
|
+
? 'check_error'
|
|
595
|
+
: null,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
console.error('Error getting primary workspace:', error);
|
|
600
|
+
res.status(500).json({ error: 'Failed to get primary workspace' });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
/**
|
|
604
|
+
* GET /api/workspaces/accessible
|
|
605
|
+
* List all workspaces the user can access:
|
|
606
|
+
* - Owned workspaces
|
|
607
|
+
* - Workspaces where user is a member
|
|
608
|
+
* - Workspaces with repos the user has GitHub access to
|
|
609
|
+
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
610
|
+
*/
|
|
611
|
+
workspacesRouter.get('/accessible', async (req, res) => {
|
|
612
|
+
const userId = req.session.userId;
|
|
613
|
+
try {
|
|
614
|
+
const user = await db.users.findById(userId);
|
|
615
|
+
if (!user) {
|
|
616
|
+
return res.status(404).json({ error: 'User not found' });
|
|
617
|
+
}
|
|
618
|
+
// 1. Get owned workspaces
|
|
619
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
620
|
+
// 2. Get workspaces where user is a member (excluding owned ones to prevent duplicates)
|
|
621
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
622
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
623
|
+
const memberWorkspaceIds = memberships
|
|
624
|
+
.map((m) => m.workspaceId)
|
|
625
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned workspaces
|
|
626
|
+
// Fetch member workspaces
|
|
627
|
+
const memberWorkspaces = [];
|
|
628
|
+
for (const wsId of memberWorkspaceIds) {
|
|
629
|
+
const ws = await db.workspaces.findById(wsId);
|
|
630
|
+
if (ws)
|
|
631
|
+
memberWorkspaces.push(ws);
|
|
632
|
+
}
|
|
633
|
+
// 3. Get workspaces via GitHub repo access (if user has Nango connection)
|
|
634
|
+
// Uses background caching to handle users with many repos (>100)
|
|
635
|
+
const contributorWorkspaces = [];
|
|
636
|
+
let cacheStatus = 'miss';
|
|
637
|
+
if (user.nangoConnectionId) {
|
|
638
|
+
try {
|
|
639
|
+
console.log(`[workspaces/accessible] Checking GitHub repo access for user ${userId.substring(0, 8)} with nangoConnectionId ${user.nangoConnectionId.substring(0, 8)}...`);
|
|
640
|
+
// Try to get cached repos first
|
|
641
|
+
let userRepos;
|
|
642
|
+
const cached = getCachedUserRepos(user.nangoConnectionId);
|
|
643
|
+
if (cached) {
|
|
644
|
+
userRepos = cached.repositories;
|
|
645
|
+
cacheStatus = 'hit';
|
|
646
|
+
console.log(`[workspaces/accessible] Cache ${cached.isComplete ? 'hit (complete)' : 'hit (partial)'}: ${userRepos.length} repos`);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
// No cache - initialize with first page and trigger background refresh
|
|
650
|
+
userRepos = await initializeUserReposCache(user.nangoConnectionId);
|
|
651
|
+
cacheStatus = 'initializing';
|
|
652
|
+
console.log(`[workspaces/accessible] Cache miss - initialized with ${userRepos.length} repos (background refresh may add more)`);
|
|
653
|
+
}
|
|
654
|
+
// Get workspaces that aren't owned or membered
|
|
655
|
+
// Reuse ownedWorkspaceIds and add member workspace IDs
|
|
656
|
+
const knownWorkspaceIds = new Set([
|
|
657
|
+
...ownedWorkspaceIds,
|
|
658
|
+
...memberWorkspaceIds,
|
|
659
|
+
]);
|
|
660
|
+
// Get all repo full names from user's accessible repos
|
|
661
|
+
for (const repo of userRepos) {
|
|
662
|
+
// Find repos in our DB that match this full name (case-insensitive)
|
|
663
|
+
const dbRepos = await db.repositories.findByGithubFullName(repo.fullName);
|
|
664
|
+
if (dbRepos.length > 0) {
|
|
665
|
+
console.log(`[workspaces/accessible] Found ${dbRepos.length} DB records for repo ${repo.fullName}`);
|
|
666
|
+
}
|
|
667
|
+
for (const dbRepo of dbRepos) {
|
|
668
|
+
if (dbRepo.workspaceId && !knownWorkspaceIds.has(dbRepo.workspaceId)) {
|
|
669
|
+
const ws = await db.workspaces.findById(dbRepo.workspaceId);
|
|
670
|
+
if (ws) {
|
|
671
|
+
console.log(`[workspaces/accessible] Granting contributor access to workspace ${ws.id.substring(0, 8)} via repo ${repo.fullName}`);
|
|
672
|
+
// Determine permission level
|
|
673
|
+
const permission = repo.permissions.admin
|
|
674
|
+
? 'admin'
|
|
675
|
+
: repo.permissions.push
|
|
676
|
+
? 'write'
|
|
677
|
+
: 'read';
|
|
678
|
+
contributorWorkspaces.push({ ...ws, accessPermission: permission });
|
|
679
|
+
knownWorkspaceIds.add(ws.id);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else if (!dbRepo.workspaceId) {
|
|
683
|
+
console.log(`[workspaces/accessible] Repo ${repo.fullName} found in DB but has no workspaceId`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
console.log(`[workspaces/accessible] Found ${contributorWorkspaces.length} contributor workspaces (cache: ${cacheStatus})`);
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
console.warn('[workspaces/accessible] Failed to check GitHub repo access:', err);
|
|
691
|
+
// Continue without contributor workspaces
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
console.log(`[workspaces/accessible] User ${userId.substring(0, 8)} has no nangoConnectionId - skipping GitHub repo access check`);
|
|
696
|
+
}
|
|
697
|
+
// Format response - include all fields the dashboard expects
|
|
698
|
+
const formatWorkspace = (ws, accessType, permission) => ({
|
|
699
|
+
id: ws.id,
|
|
700
|
+
name: ws.name,
|
|
701
|
+
status: ws.status,
|
|
702
|
+
publicUrl: ws.publicUrl,
|
|
703
|
+
providers: ws.config?.providers,
|
|
704
|
+
repositories: ws.config?.repositories,
|
|
705
|
+
accessType,
|
|
706
|
+
permission: permission || (accessType === 'owner' ? 'admin' : 'read'),
|
|
707
|
+
createdAt: ws.createdAt,
|
|
708
|
+
});
|
|
709
|
+
res.json({
|
|
710
|
+
workspaces: [
|
|
711
|
+
...ownedWorkspaces.map((w) => formatWorkspace(w, 'owner', 'admin')),
|
|
712
|
+
...memberWorkspaces.map((w) => {
|
|
713
|
+
const membership = memberships.find((m) => m.workspaceId === w.id);
|
|
714
|
+
return formatWorkspace(w, 'member', membership?.role);
|
|
715
|
+
}),
|
|
716
|
+
...contributorWorkspaces.map((w) => formatWorkspace(w, 'contributor', w.accessPermission)),
|
|
717
|
+
],
|
|
718
|
+
summary: {
|
|
719
|
+
owned: ownedWorkspaces.length,
|
|
720
|
+
member: memberWorkspaces.length,
|
|
721
|
+
contributor: contributorWorkspaces.length,
|
|
722
|
+
total: ownedWorkspaces.length + memberWorkspaces.length + contributorWorkspaces.length,
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
console.error('Error getting accessible workspaces:', error);
|
|
728
|
+
res.status(500).json({ error: 'Failed to get accessible workspaces' });
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
/**
|
|
732
|
+
* GET /api/workspaces/:id
|
|
733
|
+
* Get workspace details
|
|
734
|
+
* Uses requireWorkspaceAccess middleware for auto-access via GitHub repos
|
|
735
|
+
*/
|
|
736
|
+
workspacesRouter.get('/:id', requireWorkspaceAccess, async (req, res) => {
|
|
737
|
+
const id = req.params.id;
|
|
738
|
+
const _workspaceAccess = req.workspaceAccess;
|
|
739
|
+
try {
|
|
740
|
+
const workspace = await db.workspaces.findById(id);
|
|
741
|
+
if (!workspace) {
|
|
742
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
743
|
+
}
|
|
744
|
+
// Get repositories assigned to this workspace
|
|
745
|
+
const repositories = await db.repositories.findByWorkspaceId(id);
|
|
746
|
+
res.json({
|
|
747
|
+
id: workspace.id,
|
|
748
|
+
name: workspace.name,
|
|
749
|
+
status: workspace.status,
|
|
750
|
+
publicUrl: workspace.publicUrl,
|
|
751
|
+
computeProvider: workspace.computeProvider,
|
|
752
|
+
config: workspace.config,
|
|
753
|
+
errorMessage: workspace.errorMessage,
|
|
754
|
+
repositories: repositories.map((r) => ({
|
|
755
|
+
id: r.id,
|
|
756
|
+
fullName: r.githubFullName,
|
|
757
|
+
syncStatus: r.syncStatus,
|
|
758
|
+
lastSyncedAt: r.lastSyncedAt,
|
|
759
|
+
})),
|
|
760
|
+
createdAt: workspace.createdAt,
|
|
761
|
+
updatedAt: workspace.updatedAt,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
console.error('Error getting workspace:', error);
|
|
766
|
+
res.status(500).json({ error: 'Failed to get workspace' });
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
/**
|
|
770
|
+
* GET /api/workspaces/:id/status
|
|
771
|
+
* Get current workspace status (polls compute provider)
|
|
772
|
+
*/
|
|
773
|
+
workspacesRouter.get('/:id/status', async (req, res) => {
|
|
774
|
+
const userId = req.session.userId;
|
|
775
|
+
const id = req.params.id;
|
|
776
|
+
try {
|
|
777
|
+
const workspace = await db.workspaces.findById(id);
|
|
778
|
+
if (!workspace) {
|
|
779
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
780
|
+
}
|
|
781
|
+
if (workspace.userId !== userId) {
|
|
782
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
783
|
+
}
|
|
784
|
+
const provisioner = getProvisioner();
|
|
785
|
+
const status = await provisioner.getStatus(id);
|
|
786
|
+
// Include provisioning progress info if it exists (even after status changes to 'running')
|
|
787
|
+
// This allows the frontend to see all stages including 'complete'
|
|
788
|
+
const provisioningProgress = getProvisioningStage(id);
|
|
789
|
+
res.json({
|
|
790
|
+
status,
|
|
791
|
+
provisioning: provisioningProgress ? {
|
|
792
|
+
stage: provisioningProgress.stage,
|
|
793
|
+
startedAt: provisioningProgress.startedAt,
|
|
794
|
+
elapsedMs: Date.now() - provisioningProgress.startedAt,
|
|
795
|
+
} : null,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
console.error('Error getting workspace status:', error);
|
|
800
|
+
res.status(500).json({ error: 'Failed to get status' });
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
/**
|
|
804
|
+
* POST /api/workspaces/:id/restart
|
|
805
|
+
* Restart a workspace
|
|
806
|
+
*/
|
|
807
|
+
workspacesRouter.post('/:id/restart', async (req, res) => {
|
|
808
|
+
const userId = req.session.userId;
|
|
809
|
+
const id = req.params.id;
|
|
810
|
+
try {
|
|
811
|
+
const workspace = await db.workspaces.findById(id);
|
|
812
|
+
if (!workspace) {
|
|
813
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
814
|
+
}
|
|
815
|
+
if (workspace.userId !== userId) {
|
|
816
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
817
|
+
}
|
|
818
|
+
const provisioner = getProvisioner();
|
|
819
|
+
await provisioner.restart(id);
|
|
820
|
+
res.json({ success: true, message: 'Workspace restarting' });
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
console.error('Error restarting workspace:', error);
|
|
824
|
+
res.status(500).json({ error: 'Failed to restart workspace' });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
/**
|
|
828
|
+
* POST /api/workspaces/:id/stop
|
|
829
|
+
* Stop a workspace
|
|
830
|
+
*/
|
|
831
|
+
workspacesRouter.post('/:id/stop', async (req, res) => {
|
|
832
|
+
const userId = req.session.userId;
|
|
833
|
+
const id = req.params.id;
|
|
834
|
+
try {
|
|
835
|
+
const workspace = await db.workspaces.findById(id);
|
|
836
|
+
if (!workspace) {
|
|
837
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
838
|
+
}
|
|
839
|
+
if (workspace.userId !== userId) {
|
|
840
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
841
|
+
}
|
|
842
|
+
const provisioner = getProvisioner();
|
|
843
|
+
await provisioner.stop(id);
|
|
844
|
+
res.json({ success: true, message: 'Workspace stopped' });
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
console.error('Error stopping workspace:', error);
|
|
848
|
+
res.status(500).json({ error: 'Failed to stop workspace' });
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
/**
|
|
852
|
+
* DELETE /api/workspaces/:id
|
|
853
|
+
* Delete (deprovision) a workspace
|
|
854
|
+
*/
|
|
855
|
+
workspacesRouter.delete('/:id', async (req, res) => {
|
|
856
|
+
const userId = req.session.userId;
|
|
857
|
+
const id = req.params.id;
|
|
858
|
+
try {
|
|
859
|
+
const workspace = await db.workspaces.findById(id);
|
|
860
|
+
if (!workspace) {
|
|
861
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
862
|
+
}
|
|
863
|
+
if (workspace.userId !== userId) {
|
|
864
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
865
|
+
}
|
|
866
|
+
const provisioner = getProvisioner();
|
|
867
|
+
await provisioner.deprovision(id);
|
|
868
|
+
res.json({ success: true, message: 'Workspace deleted' });
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
console.error('Error deleting workspace:', error);
|
|
872
|
+
res.status(500).json({ error: 'Failed to delete workspace' });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
/**
|
|
876
|
+
* POST /api/workspaces/:id/repos
|
|
877
|
+
* Add repositories to a workspace
|
|
878
|
+
*/
|
|
879
|
+
workspacesRouter.post('/:id/repos', async (req, res) => {
|
|
880
|
+
const userId = req.session.userId;
|
|
881
|
+
const id = req.params.id;
|
|
882
|
+
const { repositoryIds } = req.body;
|
|
883
|
+
if (!repositoryIds || !Array.isArray(repositoryIds)) {
|
|
884
|
+
return res.status(400).json({ error: 'repositoryIds array is required' });
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
const workspace = await db.workspaces.findById(id);
|
|
888
|
+
if (!workspace) {
|
|
889
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
890
|
+
}
|
|
891
|
+
if (workspace.userId !== userId) {
|
|
892
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
893
|
+
}
|
|
894
|
+
const reposToAssign = [];
|
|
895
|
+
const repoFullNames = [];
|
|
896
|
+
for (const repoId of repositoryIds) {
|
|
897
|
+
const repo = await db.repositories.findById(repoId);
|
|
898
|
+
if (!repo || repo.userId !== userId) {
|
|
899
|
+
return res.status(404).json({ error: 'Repository not found' });
|
|
900
|
+
}
|
|
901
|
+
if (repo.workspaceId && repo.workspaceId !== id) {
|
|
902
|
+
return res.status(409).json({
|
|
903
|
+
error: 'Repository already linked to another workspace',
|
|
904
|
+
workspaceId: repo.workspaceId,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
if (!repo.installationId) {
|
|
908
|
+
return res.status(400).json({
|
|
909
|
+
error: 'Repository not authorized via GitHub App',
|
|
910
|
+
message: 'Install the GitHub App for this repository before adding it to a workspace.',
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
reposToAssign.push(repo.id);
|
|
914
|
+
repoFullNames.push(repo.githubFullName);
|
|
915
|
+
}
|
|
916
|
+
// Assign repositories to workspace
|
|
917
|
+
for (const repoId of reposToAssign) {
|
|
918
|
+
await db.repositories.assignToWorkspace(repoId, id);
|
|
919
|
+
}
|
|
920
|
+
// Update workspace config repositories list
|
|
921
|
+
const existingRepos = workspace.config.repositories ?? [];
|
|
922
|
+
const updatedRepos = Array.from(new Set([...existingRepos, ...repoFullNames]));
|
|
923
|
+
if (updatedRepos.length !== existingRepos.length) {
|
|
924
|
+
await db.workspaces.updateConfig(id, {
|
|
925
|
+
...workspace.config,
|
|
926
|
+
repositories: updatedRepos,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
res.json({ success: true, message: 'Repositories added' });
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
console.error('Error adding repos to workspace:', error);
|
|
933
|
+
res.status(500).json({ error: 'Failed to add repositories' });
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
/**
|
|
937
|
+
* GET /api/workspaces/:id/repos
|
|
938
|
+
* List repositories linked to a workspace
|
|
939
|
+
*/
|
|
940
|
+
workspacesRouter.get('/:id/repos', async (req, res) => {
|
|
941
|
+
const userId = req.session.userId;
|
|
942
|
+
const id = req.params.id;
|
|
943
|
+
try {
|
|
944
|
+
const workspace = await db.workspaces.findById(id);
|
|
945
|
+
if (!workspace) {
|
|
946
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
947
|
+
}
|
|
948
|
+
// Check access (owner, member, or contributor)
|
|
949
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
950
|
+
if (!accessResult.hasAccess) {
|
|
951
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
952
|
+
}
|
|
953
|
+
// Get repos linked to this workspace
|
|
954
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
955
|
+
res.json({
|
|
956
|
+
repositories: repos.map(r => ({
|
|
957
|
+
id: r.id,
|
|
958
|
+
githubFullName: r.githubFullName,
|
|
959
|
+
defaultBranch: r.defaultBranch,
|
|
960
|
+
isPrivate: r.isPrivate,
|
|
961
|
+
syncStatus: r.syncStatus,
|
|
962
|
+
lastSyncedAt: r.lastSyncedAt,
|
|
963
|
+
})),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
console.error('Error listing workspace repos:', error);
|
|
968
|
+
res.status(500).json({ error: 'Failed to list repositories' });
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
/**
|
|
972
|
+
* GET /api/workspaces/:id/repo-collaborators
|
|
973
|
+
* Get all collaborators from repos linked to this workspace
|
|
974
|
+
* These are users who have access via GitHub repo permissions (grandfathered in)
|
|
975
|
+
*/
|
|
976
|
+
workspacesRouter.get('/:id/repo-collaborators', async (req, res) => {
|
|
977
|
+
const userId = req.session.userId;
|
|
978
|
+
const id = req.params.id;
|
|
979
|
+
try {
|
|
980
|
+
const workspace = await db.workspaces.findById(id);
|
|
981
|
+
if (!workspace) {
|
|
982
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
983
|
+
}
|
|
984
|
+
// Check access (owner, member, or contributor)
|
|
985
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
986
|
+
if (!accessResult.hasAccess) {
|
|
987
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
988
|
+
}
|
|
989
|
+
// Get repos linked to this workspace
|
|
990
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
991
|
+
if (repos.length === 0) {
|
|
992
|
+
return res.json({ collaborators: [] });
|
|
993
|
+
}
|
|
994
|
+
// Find a repo with a Nango connection (GitHub App)
|
|
995
|
+
const repoWithConnection = repos.find(r => r.nangoConnectionId);
|
|
996
|
+
if (!repoWithConnection?.nangoConnectionId) {
|
|
997
|
+
return res.json({
|
|
998
|
+
collaborators: [],
|
|
999
|
+
message: 'GitHub App not connected for this workspace',
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
// Get the workspace owner for filtering
|
|
1003
|
+
const owner = await db.users.findById(workspace.userId);
|
|
1004
|
+
// Fetch collaborators for each repo and deduplicate
|
|
1005
|
+
const collaboratorsMap = new Map();
|
|
1006
|
+
// Get existing workspace members to exclude them
|
|
1007
|
+
const existingMembers = await db.workspaceMembers.findByWorkspaceId(id);
|
|
1008
|
+
// Also get the workspace owner's GitHub ID to exclude
|
|
1009
|
+
const ownerGithubId = owner?.githubId ? Number(owner.githubId) : null;
|
|
1010
|
+
for (const repo of repos) {
|
|
1011
|
+
// Use this repo's connection if it has one, otherwise use the shared connection
|
|
1012
|
+
const connectionId = repo.nangoConnectionId || repoWithConnection.nangoConnectionId;
|
|
1013
|
+
if (!connectionId)
|
|
1014
|
+
continue;
|
|
1015
|
+
try {
|
|
1016
|
+
const [repoOwner, repoName] = repo.githubFullName.split('/');
|
|
1017
|
+
const collabs = await nangoService.listRepoCollaborators(connectionId, repoOwner, repoName);
|
|
1018
|
+
for (const collab of collabs) {
|
|
1019
|
+
// Skip the workspace owner
|
|
1020
|
+
if (ownerGithubId && collab.id === ownerGithubId) {
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
const existing = collaboratorsMap.get(collab.id);
|
|
1024
|
+
if (existing) {
|
|
1025
|
+
// Add this repo to their list
|
|
1026
|
+
if (!existing.repos.includes(repo.githubFullName)) {
|
|
1027
|
+
existing.repos.push(repo.githubFullName);
|
|
1028
|
+
}
|
|
1029
|
+
// Upgrade permission if this repo gives higher access
|
|
1030
|
+
if (collab.permission === 'admin' && existing.permission !== 'admin') {
|
|
1031
|
+
existing.permission = 'admin';
|
|
1032
|
+
}
|
|
1033
|
+
else if (collab.permission === 'write' && existing.permission === 'read') {
|
|
1034
|
+
existing.permission = 'write';
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
collaboratorsMap.set(collab.id, {
|
|
1039
|
+
id: collab.id,
|
|
1040
|
+
login: collab.login,
|
|
1041
|
+
avatarUrl: collab.avatarUrl,
|
|
1042
|
+
permission: collab.permission,
|
|
1043
|
+
repos: [repo.githubFullName],
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
console.warn(`[workspace-collaborators] Failed to fetch collaborators for ${repo.githubFullName}:`, err);
|
|
1050
|
+
// Continue with other repos
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// Filter out users who are already workspace members
|
|
1054
|
+
// We need to check by GitHub username since we don't have their user IDs
|
|
1055
|
+
const workspaceMemberUsernames = new Set();
|
|
1056
|
+
for (const member of existingMembers) {
|
|
1057
|
+
const memberUser = await db.users.findById(member.userId);
|
|
1058
|
+
if (memberUser?.githubUsername) {
|
|
1059
|
+
workspaceMemberUsernames.add(memberUser.githubUsername.toLowerCase());
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
// Also add workspace owner
|
|
1063
|
+
if (owner?.githubUsername) {
|
|
1064
|
+
workspaceMemberUsernames.add(owner.githubUsername.toLowerCase());
|
|
1065
|
+
}
|
|
1066
|
+
const collaborators = Array.from(collaboratorsMap.values())
|
|
1067
|
+
.filter(c => !workspaceMemberUsernames.has(c.login.toLowerCase()))
|
|
1068
|
+
.sort((a, b) => {
|
|
1069
|
+
// Sort by permission level (admin > write > read), then by username
|
|
1070
|
+
const permOrder = { admin: 0, write: 1, read: 2, none: 3 };
|
|
1071
|
+
if (permOrder[a.permission] !== permOrder[b.permission]) {
|
|
1072
|
+
return permOrder[a.permission] - permOrder[b.permission];
|
|
1073
|
+
}
|
|
1074
|
+
return a.login.localeCompare(b.login);
|
|
1075
|
+
});
|
|
1076
|
+
res.json({
|
|
1077
|
+
collaborators,
|
|
1078
|
+
totalRepos: repos.length,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
console.error('Error fetching repo collaborators:', error);
|
|
1083
|
+
res.status(500).json({ error: 'Failed to fetch collaborators' });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
/**
|
|
1087
|
+
* DELETE /api/workspaces/:id/repos/:repoId
|
|
1088
|
+
* Remove a repository from a workspace
|
|
1089
|
+
*/
|
|
1090
|
+
workspacesRouter.delete('/:id/repos/:repoId', async (req, res) => {
|
|
1091
|
+
const userId = req.session.userId;
|
|
1092
|
+
const id = req.params.id;
|
|
1093
|
+
const repoId = req.params.repoId;
|
|
1094
|
+
try {
|
|
1095
|
+
const workspace = await db.workspaces.findById(id);
|
|
1096
|
+
if (!workspace) {
|
|
1097
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1098
|
+
}
|
|
1099
|
+
// Only owner can remove repos
|
|
1100
|
+
if (workspace.userId !== userId) {
|
|
1101
|
+
return res.status(403).json({ error: 'Only workspace owner can remove repositories' });
|
|
1102
|
+
}
|
|
1103
|
+
// Unlink repo from workspace (set workspaceId to null)
|
|
1104
|
+
await db.repositories.assignToWorkspace(repoId, null);
|
|
1105
|
+
// Also update workspace config to remove from repositories array
|
|
1106
|
+
const currentRepos = workspace.config.repositories || [];
|
|
1107
|
+
const repo = await db.repositories.findById(repoId);
|
|
1108
|
+
if (repo) {
|
|
1109
|
+
const updatedRepos = currentRepos.filter(r => r.toLowerCase() !== repo.githubFullName.toLowerCase());
|
|
1110
|
+
await db.workspaces.update(id, {
|
|
1111
|
+
config: { ...workspace.config, repositories: updatedRepos },
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
res.json({ success: true, message: 'Repository removed from workspace' });
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
console.error('Error removing repo from workspace:', error);
|
|
1118
|
+
res.status(500).json({ error: 'Failed to remove repository' });
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
/**
|
|
1122
|
+
* PATCH /api/workspaces/:id
|
|
1123
|
+
* Update workspace settings (name, etc.)
|
|
1124
|
+
*/
|
|
1125
|
+
workspacesRouter.patch('/:id', async (req, res) => {
|
|
1126
|
+
const userId = req.session.userId;
|
|
1127
|
+
const id = req.params.id;
|
|
1128
|
+
const { name } = req.body;
|
|
1129
|
+
try {
|
|
1130
|
+
const workspace = await db.workspaces.findById(id);
|
|
1131
|
+
if (!workspace) {
|
|
1132
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1133
|
+
}
|
|
1134
|
+
// Only owner can rename
|
|
1135
|
+
if (workspace.userId !== userId) {
|
|
1136
|
+
return res.status(403).json({ error: 'Only workspace owner can update settings' });
|
|
1137
|
+
}
|
|
1138
|
+
// Validate name if provided
|
|
1139
|
+
if (name !== undefined) {
|
|
1140
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
1141
|
+
return res.status(400).json({ error: 'Name must be a non-empty string' });
|
|
1142
|
+
}
|
|
1143
|
+
if (name.length > 100) {
|
|
1144
|
+
return res.status(400).json({ error: 'Name must be 100 characters or less' });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// Update workspace
|
|
1148
|
+
await db.workspaces.update(id, {
|
|
1149
|
+
...(name && { name: name.trim() }),
|
|
1150
|
+
});
|
|
1151
|
+
const updated = await db.workspaces.findById(id);
|
|
1152
|
+
res.json({
|
|
1153
|
+
success: true,
|
|
1154
|
+
workspace: {
|
|
1155
|
+
id: updated.id,
|
|
1156
|
+
name: updated.name,
|
|
1157
|
+
status: updated.status,
|
|
1158
|
+
publicUrl: updated.publicUrl,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
console.error('Error updating workspace:', error);
|
|
1164
|
+
res.status(500).json({ error: 'Failed to update workspace' });
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
/**
|
|
1168
|
+
* POST /api/workspaces/:id/autoscale
|
|
1169
|
+
* Trigger auto-scaling based on current agent count
|
|
1170
|
+
* Supports both user session auth and workspace token auth
|
|
1171
|
+
* Called by workspace container when spawning new agents
|
|
1172
|
+
*/
|
|
1173
|
+
workspacesRouter.post('/:id/autoscale', async (req, res) => {
|
|
1174
|
+
const id = req.params.id;
|
|
1175
|
+
const { agentCount } = req.body;
|
|
1176
|
+
if (typeof agentCount !== 'number' || agentCount < 0) {
|
|
1177
|
+
return res.status(400).json({ error: 'agentCount must be a non-negative number' });
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
const workspace = await db.workspaces.findById(id);
|
|
1181
|
+
if (!workspace) {
|
|
1182
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1183
|
+
}
|
|
1184
|
+
// Verify auth: either user session or workspace token
|
|
1185
|
+
const userId = req.session?.userId;
|
|
1186
|
+
const authHeader = req.get('authorization');
|
|
1187
|
+
if (userId) {
|
|
1188
|
+
// User session auth
|
|
1189
|
+
if (workspace.userId !== userId) {
|
|
1190
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
else if (authHeader?.startsWith('Bearer ')) {
|
|
1194
|
+
// Workspace token auth (for calls from within the workspace)
|
|
1195
|
+
const crypto = await import('crypto');
|
|
1196
|
+
const config = getConfig();
|
|
1197
|
+
const providedToken = authHeader.slice(7);
|
|
1198
|
+
const expectedToken = crypto.default
|
|
1199
|
+
.createHmac('sha256', config.sessionSecret)
|
|
1200
|
+
.update(`workspace:${id}`)
|
|
1201
|
+
.digest('hex');
|
|
1202
|
+
const isValid = crypto.default.timingSafeEqual(Buffer.from(providedToken), Buffer.from(expectedToken));
|
|
1203
|
+
if (!isValid) {
|
|
1204
|
+
return res.status(401).json({ error: 'Invalid workspace token' });
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
1209
|
+
}
|
|
1210
|
+
const provisioner = getProvisioner();
|
|
1211
|
+
const currentTier = await provisioner.getCurrentTier(id);
|
|
1212
|
+
const recommendedTier = provisioner.getRecommendedTier(agentCount);
|
|
1213
|
+
// Check if scaling is needed
|
|
1214
|
+
if (recommendedTier.memoryMb <= currentTier.memoryMb) {
|
|
1215
|
+
return res.json({
|
|
1216
|
+
scaled: false,
|
|
1217
|
+
currentTier: currentTier.name,
|
|
1218
|
+
message: 'Current tier is sufficient',
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
// Perform the scale-up (respects plan limits)
|
|
1222
|
+
const result = await provisioner.autoScale(id, agentCount);
|
|
1223
|
+
res.json({
|
|
1224
|
+
scaled: result.scaled,
|
|
1225
|
+
previousTier: result.currentTier || currentTier.name,
|
|
1226
|
+
newTier: result.targetTier || currentTier.name,
|
|
1227
|
+
reason: result.reason,
|
|
1228
|
+
message: result.scaled
|
|
1229
|
+
? `Scaled up to ${result.targetTier} tier`
|
|
1230
|
+
: result.reason || 'Scaling not required',
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
catch (error) {
|
|
1234
|
+
console.error('Error auto-scaling workspace:', error);
|
|
1235
|
+
res.status(500).json({ error: 'Failed to auto-scale workspace' });
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
/**
|
|
1239
|
+
* POST /api/workspaces/:id/domain
|
|
1240
|
+
* Add or update custom domain (Premium feature - Team/Enterprise only)
|
|
1241
|
+
*/
|
|
1242
|
+
workspacesRouter.post('/:id/domain', async (req, res) => {
|
|
1243
|
+
const userId = req.session.userId;
|
|
1244
|
+
const id = req.params.id;
|
|
1245
|
+
const { domain } = req.body;
|
|
1246
|
+
if (!domain || typeof domain !== 'string') {
|
|
1247
|
+
return res.status(400).json({ error: 'Domain is required' });
|
|
1248
|
+
}
|
|
1249
|
+
// Basic domain validation
|
|
1250
|
+
const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
|
1251
|
+
if (!domainRegex.test(domain)) {
|
|
1252
|
+
return res.status(400).json({ error: 'Invalid domain format' });
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
const workspace = await db.workspaces.findById(id);
|
|
1256
|
+
if (!workspace) {
|
|
1257
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1258
|
+
}
|
|
1259
|
+
if (workspace.userId !== userId) {
|
|
1260
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
1261
|
+
}
|
|
1262
|
+
// Check if user has premium plan (Team/Enterprise)
|
|
1263
|
+
const user = await db.users.findById(userId);
|
|
1264
|
+
const hasPremium = user?.plan === 'team' || user?.plan === 'enterprise';
|
|
1265
|
+
if (!hasPremium) {
|
|
1266
|
+
return res.status(402).json({
|
|
1267
|
+
error: 'Custom domains require Team or Enterprise plan',
|
|
1268
|
+
upgrade: '/settings/billing',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
// Check if domain is already in use
|
|
1272
|
+
const existing = await db.workspaces.findByCustomDomain(domain);
|
|
1273
|
+
if (existing && existing.id !== id) {
|
|
1274
|
+
return res.status(409).json({ error: 'Domain already in use' });
|
|
1275
|
+
}
|
|
1276
|
+
// Set the custom domain (pending verification)
|
|
1277
|
+
await db.workspaces.setCustomDomain(id, domain, 'pending');
|
|
1278
|
+
// Return DNS instructions
|
|
1279
|
+
res.json({
|
|
1280
|
+
success: true,
|
|
1281
|
+
domain,
|
|
1282
|
+
status: 'pending',
|
|
1283
|
+
instructions: {
|
|
1284
|
+
type: 'CNAME',
|
|
1285
|
+
name: domain,
|
|
1286
|
+
value: workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`,
|
|
1287
|
+
ttl: 300,
|
|
1288
|
+
},
|
|
1289
|
+
verifyEndpoint: `/api/workspaces/${id}/domain/verify`,
|
|
1290
|
+
message: 'Add the CNAME record to your DNS, then call the verify endpoint',
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
console.error('Error setting custom domain:', error);
|
|
1295
|
+
res.status(500).json({ error: 'Failed to set custom domain' });
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
/**
|
|
1299
|
+
* POST /api/workspaces/:id/domain/verify
|
|
1300
|
+
* Verify custom domain DNS is configured correctly
|
|
1301
|
+
*/
|
|
1302
|
+
workspacesRouter.post('/:id/domain/verify', async (req, res) => {
|
|
1303
|
+
const userId = req.session.userId;
|
|
1304
|
+
const id = req.params.id;
|
|
1305
|
+
try {
|
|
1306
|
+
const workspace = await db.workspaces.findById(id);
|
|
1307
|
+
if (!workspace) {
|
|
1308
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1309
|
+
}
|
|
1310
|
+
if (workspace.userId !== userId) {
|
|
1311
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
1312
|
+
}
|
|
1313
|
+
if (!workspace.customDomain) {
|
|
1314
|
+
return res.status(400).json({ error: 'No custom domain configured' });
|
|
1315
|
+
}
|
|
1316
|
+
// Verify DNS resolution
|
|
1317
|
+
const dns = await import('dns').then(m => m.promises);
|
|
1318
|
+
try {
|
|
1319
|
+
const records = await dns.resolveCname(workspace.customDomain);
|
|
1320
|
+
const expectedTarget = workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`;
|
|
1321
|
+
if (records.some(r => r.includes(expectedTarget) || r.includes('agentrelay'))) {
|
|
1322
|
+
// DNS is configured, now provision SSL cert
|
|
1323
|
+
await db.workspaces.updateCustomDomainStatus(id, 'verifying');
|
|
1324
|
+
// Trigger SSL cert provisioning on compute provider
|
|
1325
|
+
// For Railway/Fly, this is automatic once domain is added
|
|
1326
|
+
await provisionDomainSSL(workspace);
|
|
1327
|
+
await db.workspaces.updateCustomDomainStatus(id, 'active');
|
|
1328
|
+
res.json({
|
|
1329
|
+
success: true,
|
|
1330
|
+
status: 'active',
|
|
1331
|
+
domain: workspace.customDomain,
|
|
1332
|
+
message: 'Custom domain verified and SSL certificate provisioned',
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
res.status(400).json({
|
|
1337
|
+
success: false,
|
|
1338
|
+
status: 'pending',
|
|
1339
|
+
error: 'DNS not configured correctly',
|
|
1340
|
+
expected: expectedTarget,
|
|
1341
|
+
found: records,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
catch (_dnsError) {
|
|
1346
|
+
res.status(400).json({
|
|
1347
|
+
success: false,
|
|
1348
|
+
status: 'pending',
|
|
1349
|
+
error: 'Could not resolve domain. DNS may not be configured yet.',
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
console.error('Error verifying domain:', error);
|
|
1355
|
+
res.status(500).json({ error: 'Failed to verify domain' });
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
/**
|
|
1359
|
+
* DELETE /api/workspaces/:id/domain
|
|
1360
|
+
* Remove custom domain
|
|
1361
|
+
*/
|
|
1362
|
+
workspacesRouter.delete('/:id/domain', async (req, res) => {
|
|
1363
|
+
const userId = req.session.userId;
|
|
1364
|
+
const id = req.params.id;
|
|
1365
|
+
try {
|
|
1366
|
+
const workspace = await db.workspaces.findById(id);
|
|
1367
|
+
if (!workspace) {
|
|
1368
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1369
|
+
}
|
|
1370
|
+
if (workspace.userId !== userId) {
|
|
1371
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
1372
|
+
}
|
|
1373
|
+
// Remove from compute provider
|
|
1374
|
+
if (workspace.customDomain) {
|
|
1375
|
+
await removeDomainFromCompute(workspace);
|
|
1376
|
+
}
|
|
1377
|
+
await db.workspaces.removeCustomDomain(id);
|
|
1378
|
+
res.json({ success: true, message: 'Custom domain removed' });
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
console.error('Error removing domain:', error);
|
|
1382
|
+
res.status(500).json({ error: 'Failed to remove domain' });
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
/**
|
|
1386
|
+
* Helper: Provision SSL for custom domain on compute provider
|
|
1387
|
+
*/
|
|
1388
|
+
async function provisionDomainSSL(workspace) {
|
|
1389
|
+
const config = (await import('../config.js')).getConfig();
|
|
1390
|
+
if (workspace.computeProvider === 'fly' && config.compute.fly) {
|
|
1391
|
+
// Fly.io: Add certificate
|
|
1392
|
+
await fetch(`https://api.machines.dev/v1/apps/ar-${workspace.id.substring(0, 8)}/certificates`, {
|
|
1393
|
+
method: 'POST',
|
|
1394
|
+
headers: {
|
|
1395
|
+
Authorization: `Bearer ${config.compute.fly.apiToken}`,
|
|
1396
|
+
'Content-Type': 'application/json',
|
|
1397
|
+
},
|
|
1398
|
+
body: JSON.stringify({ hostname: workspace.customDomain }),
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
else if (workspace.computeProvider === 'railway' && config.compute.railway) {
|
|
1402
|
+
// Railway: Add custom domain via GraphQL
|
|
1403
|
+
await fetch('https://backboard.railway.app/graphql/v2', {
|
|
1404
|
+
method: 'POST',
|
|
1405
|
+
headers: {
|
|
1406
|
+
Authorization: `Bearer ${config.compute.railway.apiToken}`,
|
|
1407
|
+
'Content-Type': 'application/json',
|
|
1408
|
+
},
|
|
1409
|
+
body: JSON.stringify({
|
|
1410
|
+
query: `
|
|
1411
|
+
mutation AddCustomDomain($input: CustomDomainCreateInput!) {
|
|
1412
|
+
customDomainCreate(input: $input) { id }
|
|
1413
|
+
}
|
|
1414
|
+
`,
|
|
1415
|
+
variables: {
|
|
1416
|
+
input: {
|
|
1417
|
+
projectId: workspace.computeId,
|
|
1418
|
+
domain: workspace.customDomain,
|
|
1419
|
+
},
|
|
1420
|
+
},
|
|
1421
|
+
}),
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
// Docker: Would need reverse proxy config (Caddy/nginx)
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Helper: Remove custom domain from compute provider
|
|
1428
|
+
*/
|
|
1429
|
+
async function removeDomainFromCompute(workspace) {
|
|
1430
|
+
const config = (await import('../config.js')).getConfig();
|
|
1431
|
+
if (workspace.computeProvider === 'fly' && config.compute.fly) {
|
|
1432
|
+
await fetch(`https://api.machines.dev/v1/apps/ar-${workspace.id.substring(0, 8)}/certificates/${workspace.customDomain}`, {
|
|
1433
|
+
method: 'DELETE',
|
|
1434
|
+
headers: { Authorization: `Bearer ${config.compute.fly.apiToken}` },
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
// Railway and Docker: similar cleanup
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* POST /api/workspaces/:id/proxy/*
|
|
1441
|
+
* Proxy API requests to the workspace container
|
|
1442
|
+
* This allows the dashboard to make REST calls through the cloud server
|
|
1443
|
+
*/
|
|
1444
|
+
workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
1445
|
+
const userId = req.session.userId;
|
|
1446
|
+
const id = req.params.id;
|
|
1447
|
+
// Express 5 wildcard params return an array of path segments, not a slash-separated string
|
|
1448
|
+
const proxyPathParam = req.params.proxyPath;
|
|
1449
|
+
const proxyPath = Array.isArray(proxyPathParam) ? proxyPathParam.join('/') : proxyPathParam;
|
|
1450
|
+
try {
|
|
1451
|
+
const workspace = await db.workspaces.findById(id);
|
|
1452
|
+
if (!workspace) {
|
|
1453
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1454
|
+
}
|
|
1455
|
+
// Check if user is owner or has workspace membership
|
|
1456
|
+
if (workspace.userId !== userId) {
|
|
1457
|
+
// Check workspace membership
|
|
1458
|
+
const membership = await db.workspaceMembers.findMembership(id, userId);
|
|
1459
|
+
if (!membership || !membership.acceptedAt) {
|
|
1460
|
+
return res.status(403).json({ error: 'Unauthorized - not a workspace member' });
|
|
1461
|
+
}
|
|
1462
|
+
// Viewers can only proxy read-only requests
|
|
1463
|
+
if (membership.role === 'viewer' && req.method !== 'GET') {
|
|
1464
|
+
return res.status(403).json({ error: 'Viewers can only make read-only requests' });
|
|
1465
|
+
}
|
|
1466
|
+
// Members and admins can read and write
|
|
1467
|
+
// For now, allow all proxy requests for members and admins
|
|
1468
|
+
}
|
|
1469
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1470
|
+
return res.status(400).json({ error: 'Workspace is not running' });
|
|
1471
|
+
}
|
|
1472
|
+
// Determine the internal URL for proxying
|
|
1473
|
+
// When running inside Docker or Fly.io, use internal networking
|
|
1474
|
+
let targetBaseUrl = workspace.publicUrl;
|
|
1475
|
+
const runningInDocker = process.env.RUNNING_IN_DOCKER === 'true';
|
|
1476
|
+
const runningOnFly = !!process.env.FLY_APP_NAME;
|
|
1477
|
+
if (runningOnFly && targetBaseUrl.includes('.fly.dev')) {
|
|
1478
|
+
// Use Fly.io internal networking (.internal uses IPv6, works by default)
|
|
1479
|
+
// ar-583f273b.fly.dev -> http://ar-583f273b.internal:3888
|
|
1480
|
+
const appName = targetBaseUrl.match(/https?:\/\/([^.]+)\.fly\.dev/)?.[1];
|
|
1481
|
+
if (appName) {
|
|
1482
|
+
targetBaseUrl = `http://${appName}.internal:3888`;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
else if (runningInDocker && workspace.computeId && targetBaseUrl.includes('localhost')) {
|
|
1486
|
+
// Replace localhost URL with container name for Docker networking
|
|
1487
|
+
// workspace.computeId is the container name (e.g., "ar-abc12345")
|
|
1488
|
+
// The workspace port is 3888 inside the container
|
|
1489
|
+
targetBaseUrl = `http://${workspace.computeId}:3888`;
|
|
1490
|
+
}
|
|
1491
|
+
// Preserve query string when proxying - this is critical for API calls like
|
|
1492
|
+
// /trajectory/steps?trajectoryId=xxx which need the query params forwarded
|
|
1493
|
+
const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
|
|
1494
|
+
const targetUrl = `${targetBaseUrl}/api/${proxyPath}${queryString}`;
|
|
1495
|
+
console.log(`[workspace-proxy] ${req.method} ${targetUrl}`);
|
|
1496
|
+
// Store targetUrl for error handling
|
|
1497
|
+
req._proxyTargetUrl = targetUrl;
|
|
1498
|
+
// Add timeout to prevent hanging requests
|
|
1499
|
+
// 45s timeout to accommodate Fly.io machine cold starts (can take 20-30s)
|
|
1500
|
+
const controller = new AbortController();
|
|
1501
|
+
const timeout = setTimeout(() => controller.abort(), 45000);
|
|
1502
|
+
const fetchOptions = {
|
|
1503
|
+
method: req.method,
|
|
1504
|
+
headers: {
|
|
1505
|
+
'Content-Type': 'application/json',
|
|
1506
|
+
},
|
|
1507
|
+
signal: controller.signal,
|
|
1508
|
+
};
|
|
1509
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1510
|
+
fetchOptions.body = JSON.stringify(req.body);
|
|
1511
|
+
}
|
|
1512
|
+
let proxyRes;
|
|
1513
|
+
try {
|
|
1514
|
+
proxyRes = await fetch(targetUrl, fetchOptions);
|
|
1515
|
+
}
|
|
1516
|
+
finally {
|
|
1517
|
+
clearTimeout(timeout);
|
|
1518
|
+
}
|
|
1519
|
+
console.log(`[workspace-proxy] Response: ${proxyRes.status} ${proxyRes.statusText}`);
|
|
1520
|
+
// Handle non-JSON responses gracefully
|
|
1521
|
+
const contentType = proxyRes.headers.get('content-type');
|
|
1522
|
+
if (contentType?.includes('application/json')) {
|
|
1523
|
+
const data = await proxyRes.json();
|
|
1524
|
+
res.status(proxyRes.status).json(data);
|
|
1525
|
+
}
|
|
1526
|
+
else {
|
|
1527
|
+
const text = await proxyRes.text();
|
|
1528
|
+
res.status(proxyRes.status).send(text);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
catch (error) {
|
|
1532
|
+
const targetUrl = req._proxyTargetUrl || 'unknown';
|
|
1533
|
+
console.error('[workspace-proxy] Error proxying to:', targetUrl);
|
|
1534
|
+
console.error('[workspace-proxy] Error details:', error);
|
|
1535
|
+
// Check for timeout/abort errors
|
|
1536
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
1537
|
+
res.status(504).json({
|
|
1538
|
+
error: 'Workspace request timed out',
|
|
1539
|
+
details: 'The workspace did not respond within 45 seconds',
|
|
1540
|
+
targetUrl: targetUrl,
|
|
1541
|
+
});
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
// Check for connection refused (workspace not running)
|
|
1545
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1546
|
+
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
|
|
1547
|
+
res.status(503).json({
|
|
1548
|
+
error: 'Workspace is not reachable',
|
|
1549
|
+
details: 'The workspace container may not be running or accepting connections',
|
|
1550
|
+
targetUrl: targetUrl,
|
|
1551
|
+
});
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
res.status(500).json({
|
|
1555
|
+
error: 'Failed to proxy request to workspace',
|
|
1556
|
+
details: errorMessage,
|
|
1557
|
+
targetUrl: targetUrl, // Include target URL for debugging
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
// ============================================================================
|
|
1562
|
+
// Agent Management (proxied to workspace daemon)
|
|
1563
|
+
// ============================================================================
|
|
1564
|
+
/**
|
|
1565
|
+
* POST /api/workspaces/:id/agents
|
|
1566
|
+
* Spawn an agent in the workspace
|
|
1567
|
+
* Proxies to workspace daemon's /workspaces/:id/agents endpoint
|
|
1568
|
+
*/
|
|
1569
|
+
workspacesRouter.post('/:id/agents', async (req, res) => {
|
|
1570
|
+
const userId = req.session.userId;
|
|
1571
|
+
const id = req.params.id;
|
|
1572
|
+
const { name, provider, task, temporary: _temporary, interactive } = req.body;
|
|
1573
|
+
if (!userId) {
|
|
1574
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1575
|
+
}
|
|
1576
|
+
if (!name) {
|
|
1577
|
+
return res.status(400).json({ error: 'Agent name is required' });
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
// Find workspace and verify access
|
|
1581
|
+
const workspace = await db.workspaces.findById(id);
|
|
1582
|
+
if (!workspace) {
|
|
1583
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1584
|
+
}
|
|
1585
|
+
// Check access
|
|
1586
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1587
|
+
if (!accessResult.hasAccess) {
|
|
1588
|
+
return res.status(403).json({ error: 'Access denied to this workspace' });
|
|
1589
|
+
}
|
|
1590
|
+
// Ensure workspace is running
|
|
1591
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1592
|
+
return res.status(400).json({
|
|
1593
|
+
error: 'Workspace is not running',
|
|
1594
|
+
status: workspace.status,
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
// Proxy to workspace dashboard server's /api/spawn endpoint
|
|
1598
|
+
// The dashboard server expects 'cli' field (not 'provider')
|
|
1599
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawn`;
|
|
1600
|
+
console.log(`[workspaces] Proxying agent spawn to: ${targetUrl}`);
|
|
1601
|
+
// Map provider ID to CLI command
|
|
1602
|
+
// Some providers have different CLI names than their provider ID
|
|
1603
|
+
const PROVIDER_CLI_MAP = {
|
|
1604
|
+
anthropic: 'claude',
|
|
1605
|
+
claude: 'claude',
|
|
1606
|
+
codex: 'codex',
|
|
1607
|
+
openai: 'codex',
|
|
1608
|
+
cursor: 'agent', // Cursor CLI installs as 'agent'
|
|
1609
|
+
droid: 'droid',
|
|
1610
|
+
opencode: 'opencode',
|
|
1611
|
+
gemini: 'gemini',
|
|
1612
|
+
google: 'gemini',
|
|
1613
|
+
};
|
|
1614
|
+
const cli = PROVIDER_CLI_MAP[provider || ''] || provider || 'claude';
|
|
1615
|
+
const response = await fetch(targetUrl, {
|
|
1616
|
+
method: 'POST',
|
|
1617
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1618
|
+
body: JSON.stringify({
|
|
1619
|
+
name,
|
|
1620
|
+
cli, // Use mapped CLI command
|
|
1621
|
+
task: task || '', // Empty task = interactive mode, user responds to prompts
|
|
1622
|
+
interactive: interactive ?? true, // Default to interactive for setup flows
|
|
1623
|
+
userId,
|
|
1624
|
+
}),
|
|
1625
|
+
signal: AbortSignal.timeout(30000),
|
|
1626
|
+
});
|
|
1627
|
+
const data = await response.json();
|
|
1628
|
+
if (!response.ok) {
|
|
1629
|
+
return res.status(response.status).json(data);
|
|
1630
|
+
}
|
|
1631
|
+
res.status(201).json(data);
|
|
1632
|
+
}
|
|
1633
|
+
catch (error) {
|
|
1634
|
+
console.error('[workspaces] Agent spawn error:', error);
|
|
1635
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1636
|
+
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
|
|
1637
|
+
return res.status(503).json({
|
|
1638
|
+
error: 'Workspace is not reachable',
|
|
1639
|
+
details: 'The workspace container may not be running',
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
res.status(500).json({
|
|
1643
|
+
error: 'Failed to spawn agent',
|
|
1644
|
+
details: errorMessage,
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
/**
|
|
1649
|
+
* GET /api/workspaces/:id/agents
|
|
1650
|
+
* List agents in the workspace
|
|
1651
|
+
*/
|
|
1652
|
+
workspacesRouter.get('/:id/agents', async (req, res) => {
|
|
1653
|
+
const userId = req.session.userId;
|
|
1654
|
+
const id = req.params.id;
|
|
1655
|
+
if (!userId) {
|
|
1656
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1657
|
+
}
|
|
1658
|
+
try {
|
|
1659
|
+
const workspace = await db.workspaces.findById(id);
|
|
1660
|
+
if (!workspace) {
|
|
1661
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1662
|
+
}
|
|
1663
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1664
|
+
if (!accessResult.hasAccess) {
|
|
1665
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1666
|
+
}
|
|
1667
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1668
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1669
|
+
}
|
|
1670
|
+
// Use dashboard server's /api/spawned endpoint
|
|
1671
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned`;
|
|
1672
|
+
const response = await fetch(targetUrl, {
|
|
1673
|
+
signal: AbortSignal.timeout(10000),
|
|
1674
|
+
});
|
|
1675
|
+
if (!response.ok) {
|
|
1676
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1677
|
+
}
|
|
1678
|
+
const data = await response.json();
|
|
1679
|
+
// Transform to expected format
|
|
1680
|
+
res.json({ agents: data.agents || [], workspaceId: id });
|
|
1681
|
+
}
|
|
1682
|
+
catch (error) {
|
|
1683
|
+
console.error('[workspaces] List agents error:', error);
|
|
1684
|
+
res.status(200).json({ agents: [], workspaceId: id });
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
/**
|
|
1688
|
+
* DELETE /api/workspaces/:id/agents/:agentName
|
|
1689
|
+
* Stop an agent in the workspace
|
|
1690
|
+
*/
|
|
1691
|
+
workspacesRouter.delete('/:id/agents/:agentName', async (req, res) => {
|
|
1692
|
+
const userId = req.session.userId;
|
|
1693
|
+
const id = req.params.id;
|
|
1694
|
+
const agentName = req.params.agentName;
|
|
1695
|
+
if (!userId) {
|
|
1696
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1697
|
+
}
|
|
1698
|
+
try {
|
|
1699
|
+
const workspace = await db.workspaces.findById(id);
|
|
1700
|
+
if (!workspace) {
|
|
1701
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1702
|
+
}
|
|
1703
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1704
|
+
if (!accessResult.hasAccess) {
|
|
1705
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1706
|
+
}
|
|
1707
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1708
|
+
return res.status(400).json({ error: 'Workspace is not running' });
|
|
1709
|
+
}
|
|
1710
|
+
// Use dashboard server's /api/spawned/:name endpoint
|
|
1711
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned/${encodeURIComponent(agentName)}`;
|
|
1712
|
+
const response = await fetch(targetUrl, {
|
|
1713
|
+
method: 'DELETE',
|
|
1714
|
+
signal: AbortSignal.timeout(10000),
|
|
1715
|
+
});
|
|
1716
|
+
if (response.status === 204) {
|
|
1717
|
+
return res.status(204).send();
|
|
1718
|
+
}
|
|
1719
|
+
const data = await response.json();
|
|
1720
|
+
res.status(response.status).json(data);
|
|
1721
|
+
}
|
|
1722
|
+
catch (error) {
|
|
1723
|
+
console.error('[workspaces] Stop agent error:', error);
|
|
1724
|
+
res.status(500).json({ error: 'Failed to stop agent' });
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
/**
|
|
1728
|
+
* POST /api/workspaces/quick
|
|
1729
|
+
* Quick provision: one-click with defaults
|
|
1730
|
+
* Providers are optional - can be connected after workspace creation via CLI login
|
|
1731
|
+
*/
|
|
1732
|
+
workspacesRouter.post('/quick', checkWorkspaceLimit, async (req, res) => {
|
|
1733
|
+
const userId = req.session.userId;
|
|
1734
|
+
const { name, repositoryFullName } = req.body;
|
|
1735
|
+
if (!repositoryFullName) {
|
|
1736
|
+
return res.status(400).json({ error: 'Repository name is required' });
|
|
1737
|
+
}
|
|
1738
|
+
try {
|
|
1739
|
+
// Check if a workspace already exists for this repo
|
|
1740
|
+
// If so, check if user has access and return it instead of creating a duplicate
|
|
1741
|
+
const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName);
|
|
1742
|
+
for (const existingRepo of existingRepos) {
|
|
1743
|
+
if (existingRepo.workspaceId) {
|
|
1744
|
+
// Check if user has access to this workspace
|
|
1745
|
+
const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
|
|
1746
|
+
if (accessResult.hasAccess) {
|
|
1747
|
+
const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
|
|
1748
|
+
if (existingWorkspace) {
|
|
1749
|
+
console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`);
|
|
1750
|
+
return res.status(200).json({
|
|
1751
|
+
workspaceId: existingWorkspace.id,
|
|
1752
|
+
status: existingWorkspace.status,
|
|
1753
|
+
publicUrl: existingWorkspace.publicUrl,
|
|
1754
|
+
existingWorkspace: true,
|
|
1755
|
+
accessType: accessResult.accessType,
|
|
1756
|
+
message: `You already have ${accessResult.accessType} access to a workspace for this repository.`,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
// Get user's connected providers (optional now)
|
|
1763
|
+
const credentials = await db.credentials.findByUserId(userId);
|
|
1764
|
+
const providers = credentials
|
|
1765
|
+
.filter((c) => c.provider !== 'github')
|
|
1766
|
+
.map((c) => c.provider);
|
|
1767
|
+
// Create workspace with defaults
|
|
1768
|
+
const provisioner = getProvisioner();
|
|
1769
|
+
const workspaceName = name || `Workspace for ${repositoryFullName}`;
|
|
1770
|
+
const result = await provisioner.provision({
|
|
1771
|
+
userId,
|
|
1772
|
+
name: workspaceName,
|
|
1773
|
+
providers: providers.length > 0 ? providers : [], // Empty is OK now
|
|
1774
|
+
repositories: [repositoryFullName],
|
|
1775
|
+
supervisorEnabled: true,
|
|
1776
|
+
maxAgents: 10,
|
|
1777
|
+
});
|
|
1778
|
+
if (result.status === 'error') {
|
|
1779
|
+
return res.status(500).json({
|
|
1780
|
+
error: 'Failed to provision workspace',
|
|
1781
|
+
details: result.error,
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
res.status(201).json({
|
|
1785
|
+
workspaceId: result.workspaceId,
|
|
1786
|
+
status: result.status,
|
|
1787
|
+
publicUrl: result.publicUrl,
|
|
1788
|
+
providersConnected: providers.length > 0,
|
|
1789
|
+
message: providers.length > 0
|
|
1790
|
+
? 'Workspace provisioned successfully!'
|
|
1791
|
+
: 'Workspace provisioned! Connect an AI provider to start using agents.',
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
catch (error) {
|
|
1795
|
+
console.error('Error quick provisioning:', error);
|
|
1796
|
+
res.status(500).json({ error: 'Failed to provision workspace' });
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
//# sourceMappingURL=workspaces.js.map
|