agent-relay 1.2.3 → 1.3.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/.trajectories/agent-relay-322-324.md +17 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.json +49 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.md +31 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.json +125 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.md +62 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.json +49 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.md +31 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.json +77 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.md +42 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.json +77 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.md +42 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.json +77 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.md +42 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.json +66 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.md +36 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.json +40 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.md +22 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.json +121 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.md +29 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.json +53 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.md +32 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.json +101 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.md +52 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.json +49 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.md +31 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.json +65 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.md +37 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.json +49 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.md +31 -0
- package/.trajectories/completed/2026-01/traj_multi_server_arch.md +101 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.json +27 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.md +14 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.json +53 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.md +32 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.json +186 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.md +86 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.json +77 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.md +42 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.json +89 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.md +47 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.json +65 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.md +37 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.json +49 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.md +31 -0
- package/.trajectories/consolidate-settings-panel.md +24 -0
- package/.trajectories/gh-cli-user-token.md +26 -0
- package/.trajectories/index.json +155 -1
- package/deploy/workspace/codex.config.toml +15 -0
- package/deploy/workspace/entrypoint.sh +167 -7
- package/deploy/workspace/git-credential-relay +17 -2
- package/dist/bridge/spawner.d.ts +7 -0
- package/dist/bridge/spawner.js +40 -9
- package/dist/bridge/types.d.ts +2 -0
- package/dist/cli/index.js +210 -168
- package/dist/cloud/api/admin.d.ts +8 -0
- package/dist/cloud/api/admin.js +212 -0
- package/dist/cloud/api/auth.js +8 -0
- package/dist/cloud/api/billing.d.ts +0 -10
- package/dist/cloud/api/billing.js +248 -58
- package/dist/cloud/api/codex-auth-helper.d.ts +10 -4
- package/dist/cloud/api/codex-auth-helper.js +215 -8
- package/dist/cloud/api/coordinators.js +402 -0
- package/dist/cloud/api/daemons.js +15 -11
- package/dist/cloud/api/git.js +104 -17
- package/dist/cloud/api/github-app.js +42 -8
- package/dist/cloud/api/nango-auth.js +297 -16
- package/dist/cloud/api/onboarding.js +97 -33
- package/dist/cloud/api/providers.js +12 -16
- package/dist/cloud/api/repos.js +200 -124
- package/dist/cloud/api/test-helpers.js +40 -0
- package/dist/cloud/api/usage.js +13 -0
- package/dist/cloud/api/webhooks.js +1 -1
- package/dist/cloud/api/workspaces.d.ts +18 -0
- package/dist/cloud/api/workspaces.js +945 -15
- package/dist/cloud/config.d.ts +8 -0
- package/dist/cloud/config.js +15 -0
- package/dist/cloud/db/drizzle.d.ts +5 -2
- package/dist/cloud/db/drizzle.js +27 -20
- package/dist/cloud/db/schema.d.ts +19 -51
- package/dist/cloud/db/schema.js +5 -4
- package/dist/cloud/index.d.ts +0 -1
- package/dist/cloud/index.js +0 -1
- package/dist/cloud/provisioner/index.d.ts +93 -1
- package/dist/cloud/provisioner/index.js +608 -63
- package/dist/cloud/server.js +156 -16
- package/dist/cloud/services/compute-enforcement.d.ts +57 -0
- package/dist/cloud/services/compute-enforcement.js +175 -0
- package/dist/cloud/services/index.d.ts +2 -0
- package/dist/cloud/services/index.js +4 -0
- package/dist/cloud/services/intro-expiration.d.ts +55 -0
- package/dist/cloud/services/intro-expiration.js +211 -0
- package/dist/cloud/services/nango.d.ts +14 -0
- package/dist/cloud/services/nango.js +74 -14
- package/dist/cloud/services/ssh-security.d.ts +31 -0
- package/dist/cloud/services/ssh-security.js +63 -0
- package/dist/continuity/manager.d.ts +5 -0
- package/dist/continuity/manager.js +56 -2
- package/dist/daemon/api.d.ts +2 -0
- package/dist/daemon/api.js +214 -5
- package/dist/daemon/cli-auth.d.ts +13 -1
- package/dist/daemon/cli-auth.js +166 -47
- package/dist/daemon/connection.d.ts +7 -1
- package/dist/daemon/connection.js +15 -0
- package/dist/daemon/orchestrator.d.ts +2 -0
- package/dist/daemon/orchestrator.js +26 -0
- package/dist/daemon/repo-manager.d.ts +116 -0
- package/dist/daemon/repo-manager.js +384 -0
- package/dist/daemon/router.d.ts +60 -1
- package/dist/daemon/router.js +281 -20
- package/dist/daemon/user-directory.d.ts +111 -0
- package/dist/daemon/user-directory.js +233 -0
- package/dist/dashboard/out/404.html +1 -1
- package/dist/dashboard/out/_next/static/T1tgCqVWHFIkV7ClEtzD7/_ssgManifest.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +9 -0
- package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/899-bb19a9b3d9b39ea6.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-8939b0fc700f7eca.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/page-5af1b6b439858aa6.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-f45ecbc3e06134fc.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/history/{page-abb9ab2d329f56e9.js → page-8c8bed33beb2bf1c.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/layout-2433bb48965f4333.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/login/{page-c22d080201cbd9fb.js → page-16f3b49e55b1e0ed.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-ac39dc0cc3c26fa7.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/{page-77e9c65420a06cfb.js → page-4a5938c18a11a654.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/pricing/page-982a7000fee44014.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-ac3a6ac433fd6001.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-09f9caae98a18c09.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/signup/{page-68d34f50baa8ab6b.js → page-547dd0ca55ecd0ba.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/{main-ed4e1fb6f29c34cf.js → main-2ee6beb2ae96d210.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/{main-app-6e8e8d3ef4e0192a.js → main-app-5d692157a8eb1fd9.js} +1 -1
- package/dist/dashboard/out/_next/static/css/85d2af9c7ac74d62.css +1 -0
- package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
- package/dist/dashboard/out/app/onboarding.html +1 -1
- package/dist/dashboard/out/app/onboarding.txt +3 -3
- package/dist/dashboard/out/app.html +1 -1
- package/dist/dashboard/out/app.txt +3 -3
- package/dist/dashboard/out/apple-icon.png +0 -0
- package/dist/dashboard/out/connect-repos.html +1 -1
- package/dist/dashboard/out/connect-repos.txt +3 -3
- package/dist/dashboard/out/history.html +1 -1
- package/dist/dashboard/out/history.txt +3 -3
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +3 -3
- package/dist/dashboard/out/login.html +2 -2
- package/dist/dashboard/out/login.txt +3 -3
- package/dist/dashboard/out/metrics.html +1 -1
- package/dist/dashboard/out/metrics.txt +3 -3
- package/dist/dashboard/out/pricing.html +2 -2
- package/dist/dashboard/out/pricing.txt +3 -3
- package/dist/dashboard/out/providers/setup/claude.html +1 -0
- package/dist/dashboard/out/providers/setup/claude.txt +8 -0
- package/dist/dashboard/out/providers/setup/codex.html +1 -0
- package/dist/dashboard/out/providers/setup/codex.txt +8 -0
- package/dist/dashboard/out/providers.html +1 -1
- package/dist/dashboard/out/providers.txt +3 -3
- package/dist/dashboard/out/signup.html +2 -2
- package/dist/dashboard/out/signup.txt +3 -3
- package/dist/dashboard-server/server.js +316 -12
- package/dist/dashboard-server/user-bridge.d.ts +103 -0
- package/dist/dashboard-server/user-bridge.js +189 -0
- package/dist/protocol/channels.d.ts +205 -0
- package/dist/protocol/channels.js +154 -0
- package/dist/protocol/types.d.ts +13 -1
- package/dist/resiliency/provider-context.js +2 -0
- package/dist/shared/cli-auth-config.d.ts +19 -0
- package/dist/shared/cli-auth-config.js +58 -2
- package/dist/utils/agent-config.js +1 -1
- package/dist/wrapper/auth-detection.d.ts +49 -0
- package/dist/wrapper/auth-detection.js +192 -0
- package/dist/wrapper/base-wrapper.d.ts +153 -0
- package/dist/wrapper/base-wrapper.js +393 -0
- package/dist/wrapper/client.d.ts +7 -1
- package/dist/wrapper/client.js +3 -0
- package/dist/wrapper/index.d.ts +1 -0
- package/dist/wrapper/index.js +4 -3
- package/dist/wrapper/pty-wrapper.d.ts +62 -84
- package/dist/wrapper/pty-wrapper.js +154 -180
- package/dist/wrapper/tmux-wrapper.d.ts +41 -66
- package/dist/wrapper/tmux-wrapper.js +90 -134
- package/package.json +4 -2
- package/scripts/postinstall.js +11 -155
- package/scripts/test-interactive-terminal.sh +248 -0
- package/dist/cloud/vault/index.d.ts +0 -76
- package/dist/cloud/vault/index.js +0 -219
- package/dist/dashboard/out/_next/static/chunks/699-3b1cd6618a45d259.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/724-2dae7627550ab88f.js +0 -9
- package/dist/dashboard/out/_next/static/chunks/766-1f2dd8cb7f766b0b.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-3fdfa60e53f2810d.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/page-e6381e5a6e1fbcfd.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/connect-repos/page-3538dfe0ffe984b8.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/layout-c0d118c0f92d969c.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-67a3e98d9a43a6ed.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/pricing/page-b08ed1c34d14434a.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-e88bc117ef7671c3.js +0 -1
- package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
- package/dist/dashboard/out/_next/static/css/7c3ae9e8617d42a5.css +0 -1
- package/dist/dashboard/out/_next/static/wPgKJtcOmTFLpUncDg16A/_ssgManifest.js +0 -1
- /package/dist/dashboard/out/_next/static/{wPgKJtcOmTFLpUncDg16A → T1tgCqVWHFIkV7ClEtzD7}/_buildManifest.js +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Workspaces API Routes
|
|
3
3
|
*
|
|
4
4
|
* One-click workspace provisioning and management.
|
|
5
|
+
* Includes auto-access based on GitHub repo permissions.
|
|
5
6
|
*/
|
|
6
7
|
import { Router } from 'express';
|
|
7
8
|
import { requireAuth } from './auth.js';
|
|
@@ -9,19 +10,359 @@ import { db } from '../db/index.js';
|
|
|
9
10
|
import { getProvisioner, getProvisioningStage } from '../provisioner/index.js';
|
|
10
11
|
import { checkWorkspaceLimit } from './middleware/planLimits.js';
|
|
11
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
|
+
}
|
|
12
342
|
export const workspacesRouter = Router();
|
|
13
343
|
// All routes require authentication
|
|
14
344
|
workspacesRouter.use(requireAuth);
|
|
15
345
|
/**
|
|
16
346
|
* GET /api/workspaces
|
|
17
|
-
* List user's workspaces
|
|
347
|
+
* List user's workspaces (owned + member workspaces)
|
|
18
348
|
*/
|
|
19
349
|
workspacesRouter.get('/', async (req, res) => {
|
|
20
350
|
const userId = req.session.userId;
|
|
21
351
|
try {
|
|
22
|
-
|
|
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());
|
|
23
364
|
res.json({
|
|
24
|
-
workspaces:
|
|
365
|
+
workspaces: allWorkspaces.map((w) => ({
|
|
25
366
|
id: w.id,
|
|
26
367
|
name: w.name,
|
|
27
368
|
status: w.status,
|
|
@@ -29,6 +370,7 @@ workspacesRouter.get('/', async (req, res) => {
|
|
|
29
370
|
providers: w.config.providers,
|
|
30
371
|
repositories: w.config.repositories,
|
|
31
372
|
createdAt: w.createdAt,
|
|
373
|
+
isOwner: w.userId === userId, // Flag to indicate ownership
|
|
32
374
|
})),
|
|
33
375
|
});
|
|
34
376
|
}
|
|
@@ -54,6 +396,33 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
|
|
|
54
396
|
if (!repositories || !Array.isArray(repositories)) {
|
|
55
397
|
return res.status(400).json({ error: 'Repositories array is required' });
|
|
56
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
|
+
}
|
|
57
426
|
// Verify user has credentials for all providers
|
|
58
427
|
const credentials = await db.credentials.findByUserId(userId);
|
|
59
428
|
const connectedProviders = new Set(credentials.map((c) => c.provider));
|
|
@@ -93,13 +462,23 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
|
|
|
93
462
|
});
|
|
94
463
|
/**
|
|
95
464
|
* GET /api/workspaces/summary
|
|
96
|
-
* Get summary of all user workspaces for dashboard status indicator
|
|
465
|
+
* Get summary of all user workspaces for dashboard status indicator (owned + member workspaces)
|
|
97
466
|
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
98
467
|
*/
|
|
99
468
|
workspacesRouter.get('/summary', async (req, res) => {
|
|
100
469
|
const userId = req.session.userId;
|
|
101
470
|
try {
|
|
102
|
-
|
|
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];
|
|
103
482
|
const provisioner = getProvisioner();
|
|
104
483
|
// Get live status for each workspace
|
|
105
484
|
const workspaceSummaries = await Promise.all(workspaces.map(async (w) => {
|
|
@@ -152,14 +531,24 @@ workspacesRouter.get('/summary', async (req, res) => {
|
|
|
152
531
|
});
|
|
153
532
|
/**
|
|
154
533
|
* GET /api/workspaces/primary
|
|
155
|
-
* Get the user's primary workspace (first/default) with live status
|
|
534
|
+
* Get the user's primary workspace (first/default) with live status (owned + member workspaces)
|
|
156
535
|
* Used by dashboard to show quick status indicator
|
|
157
536
|
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
158
537
|
*/
|
|
159
538
|
workspacesRouter.get('/primary', async (req, res) => {
|
|
160
539
|
const userId = req.session.userId;
|
|
161
540
|
try {
|
|
162
|
-
|
|
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];
|
|
163
552
|
if (workspaces.length === 0) {
|
|
164
553
|
return res.json({
|
|
165
554
|
exists: false,
|
|
@@ -211,21 +600,147 @@ workspacesRouter.get('/primary', async (req, res) => {
|
|
|
211
600
|
res.status(500).json({ error: 'Failed to get primary workspace' });
|
|
212
601
|
}
|
|
213
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
|
+
});
|
|
214
731
|
/**
|
|
215
732
|
* GET /api/workspaces/:id
|
|
216
733
|
* Get workspace details
|
|
734
|
+
* Uses requireWorkspaceAccess middleware for auto-access via GitHub repos
|
|
217
735
|
*/
|
|
218
|
-
workspacesRouter.get('/:id', async (req, res) => {
|
|
219
|
-
const userId = req.session.userId;
|
|
736
|
+
workspacesRouter.get('/:id', requireWorkspaceAccess, async (req, res) => {
|
|
220
737
|
const { id } = req.params;
|
|
738
|
+
const _workspaceAccess = req.workspaceAccess;
|
|
221
739
|
try {
|
|
222
740
|
const workspace = await db.workspaces.findById(id);
|
|
223
741
|
if (!workspace) {
|
|
224
742
|
return res.status(404).json({ error: 'Workspace not found' });
|
|
225
743
|
}
|
|
226
|
-
if (workspace.userId !== userId) {
|
|
227
|
-
return res.status(403).json({ error: 'Unauthorized' });
|
|
228
|
-
}
|
|
229
744
|
// Get repositories assigned to this workspace
|
|
230
745
|
const repositories = await db.repositories.findByWorkspaceId(id);
|
|
231
746
|
res.json({
|
|
@@ -387,6 +902,236 @@ workspacesRouter.post('/:id/repos', async (req, res) => {
|
|
|
387
902
|
res.status(500).json({ error: 'Failed to add repositories' });
|
|
388
903
|
}
|
|
389
904
|
});
|
|
905
|
+
/**
|
|
906
|
+
* GET /api/workspaces/:id/repos
|
|
907
|
+
* List repositories linked to a workspace
|
|
908
|
+
*/
|
|
909
|
+
workspacesRouter.get('/:id/repos', async (req, res) => {
|
|
910
|
+
const userId = req.session.userId;
|
|
911
|
+
const { id } = req.params;
|
|
912
|
+
try {
|
|
913
|
+
const workspace = await db.workspaces.findById(id);
|
|
914
|
+
if (!workspace) {
|
|
915
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
916
|
+
}
|
|
917
|
+
// Check access (owner, member, or contributor)
|
|
918
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
919
|
+
if (!accessResult.hasAccess) {
|
|
920
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
921
|
+
}
|
|
922
|
+
// Get repos linked to this workspace
|
|
923
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
924
|
+
res.json({
|
|
925
|
+
repositories: repos.map(r => ({
|
|
926
|
+
id: r.id,
|
|
927
|
+
githubFullName: r.githubFullName,
|
|
928
|
+
defaultBranch: r.defaultBranch,
|
|
929
|
+
isPrivate: r.isPrivate,
|
|
930
|
+
syncStatus: r.syncStatus,
|
|
931
|
+
lastSyncedAt: r.lastSyncedAt,
|
|
932
|
+
})),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
console.error('Error listing workspace repos:', error);
|
|
937
|
+
res.status(500).json({ error: 'Failed to list repositories' });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
/**
|
|
941
|
+
* GET /api/workspaces/:id/repo-collaborators
|
|
942
|
+
* Get all collaborators from repos linked to this workspace
|
|
943
|
+
* These are users who have access via GitHub repo permissions (grandfathered in)
|
|
944
|
+
*/
|
|
945
|
+
workspacesRouter.get('/:id/repo-collaborators', async (req, res) => {
|
|
946
|
+
const userId = req.session.userId;
|
|
947
|
+
const { id } = req.params;
|
|
948
|
+
try {
|
|
949
|
+
const workspace = await db.workspaces.findById(id);
|
|
950
|
+
if (!workspace) {
|
|
951
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
952
|
+
}
|
|
953
|
+
// Check access (owner, member, or contributor)
|
|
954
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
955
|
+
if (!accessResult.hasAccess) {
|
|
956
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
957
|
+
}
|
|
958
|
+
// Get repos linked to this workspace
|
|
959
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
960
|
+
if (repos.length === 0) {
|
|
961
|
+
return res.json({ collaborators: [] });
|
|
962
|
+
}
|
|
963
|
+
// Find a repo with a Nango connection (GitHub App)
|
|
964
|
+
const repoWithConnection = repos.find(r => r.nangoConnectionId);
|
|
965
|
+
if (!repoWithConnection?.nangoConnectionId) {
|
|
966
|
+
return res.json({
|
|
967
|
+
collaborators: [],
|
|
968
|
+
message: 'GitHub App not connected for this workspace',
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
// Get the workspace owner for filtering
|
|
972
|
+
const owner = await db.users.findById(workspace.userId);
|
|
973
|
+
// Fetch collaborators for each repo and deduplicate
|
|
974
|
+
const collaboratorsMap = new Map();
|
|
975
|
+
// Get existing workspace members to exclude them
|
|
976
|
+
const existingMembers = await db.workspaceMembers.findByWorkspaceId(id);
|
|
977
|
+
// Also get the workspace owner's GitHub ID to exclude
|
|
978
|
+
const ownerGithubId = owner?.githubId ? Number(owner.githubId) : null;
|
|
979
|
+
for (const repo of repos) {
|
|
980
|
+
// Use this repo's connection if it has one, otherwise use the shared connection
|
|
981
|
+
const connectionId = repo.nangoConnectionId || repoWithConnection.nangoConnectionId;
|
|
982
|
+
if (!connectionId)
|
|
983
|
+
continue;
|
|
984
|
+
try {
|
|
985
|
+
const [repoOwner, repoName] = repo.githubFullName.split('/');
|
|
986
|
+
const collabs = await nangoService.listRepoCollaborators(connectionId, repoOwner, repoName);
|
|
987
|
+
for (const collab of collabs) {
|
|
988
|
+
// Skip the workspace owner
|
|
989
|
+
if (ownerGithubId && collab.id === ownerGithubId) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const existing = collaboratorsMap.get(collab.id);
|
|
993
|
+
if (existing) {
|
|
994
|
+
// Add this repo to their list
|
|
995
|
+
if (!existing.repos.includes(repo.githubFullName)) {
|
|
996
|
+
existing.repos.push(repo.githubFullName);
|
|
997
|
+
}
|
|
998
|
+
// Upgrade permission if this repo gives higher access
|
|
999
|
+
if (collab.permission === 'admin' && existing.permission !== 'admin') {
|
|
1000
|
+
existing.permission = 'admin';
|
|
1001
|
+
}
|
|
1002
|
+
else if (collab.permission === 'write' && existing.permission === 'read') {
|
|
1003
|
+
existing.permission = 'write';
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
collaboratorsMap.set(collab.id, {
|
|
1008
|
+
id: collab.id,
|
|
1009
|
+
login: collab.login,
|
|
1010
|
+
avatarUrl: collab.avatarUrl,
|
|
1011
|
+
permission: collab.permission,
|
|
1012
|
+
repos: [repo.githubFullName],
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
console.warn(`[workspace-collaborators] Failed to fetch collaborators for ${repo.githubFullName}:`, err);
|
|
1019
|
+
// Continue with other repos
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Filter out users who are already workspace members
|
|
1023
|
+
// We need to check by GitHub username since we don't have their user IDs
|
|
1024
|
+
const workspaceMemberUsernames = new Set();
|
|
1025
|
+
for (const member of existingMembers) {
|
|
1026
|
+
const memberUser = await db.users.findById(member.userId);
|
|
1027
|
+
if (memberUser?.githubUsername) {
|
|
1028
|
+
workspaceMemberUsernames.add(memberUser.githubUsername.toLowerCase());
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// Also add workspace owner
|
|
1032
|
+
if (owner?.githubUsername) {
|
|
1033
|
+
workspaceMemberUsernames.add(owner.githubUsername.toLowerCase());
|
|
1034
|
+
}
|
|
1035
|
+
const collaborators = Array.from(collaboratorsMap.values())
|
|
1036
|
+
.filter(c => !workspaceMemberUsernames.has(c.login.toLowerCase()))
|
|
1037
|
+
.sort((a, b) => {
|
|
1038
|
+
// Sort by permission level (admin > write > read), then by username
|
|
1039
|
+
const permOrder = { admin: 0, write: 1, read: 2, none: 3 };
|
|
1040
|
+
if (permOrder[a.permission] !== permOrder[b.permission]) {
|
|
1041
|
+
return permOrder[a.permission] - permOrder[b.permission];
|
|
1042
|
+
}
|
|
1043
|
+
return a.login.localeCompare(b.login);
|
|
1044
|
+
});
|
|
1045
|
+
res.json({
|
|
1046
|
+
collaborators,
|
|
1047
|
+
totalRepos: repos.length,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
console.error('Error fetching repo collaborators:', error);
|
|
1052
|
+
res.status(500).json({ error: 'Failed to fetch collaborators' });
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
/**
|
|
1056
|
+
* DELETE /api/workspaces/:id/repos/:repoId
|
|
1057
|
+
* Remove a repository from a workspace
|
|
1058
|
+
*/
|
|
1059
|
+
workspacesRouter.delete('/:id/repos/:repoId', async (req, res) => {
|
|
1060
|
+
const userId = req.session.userId;
|
|
1061
|
+
const { id, repoId } = req.params;
|
|
1062
|
+
try {
|
|
1063
|
+
const workspace = await db.workspaces.findById(id);
|
|
1064
|
+
if (!workspace) {
|
|
1065
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1066
|
+
}
|
|
1067
|
+
// Only owner can remove repos
|
|
1068
|
+
if (workspace.userId !== userId) {
|
|
1069
|
+
return res.status(403).json({ error: 'Only workspace owner can remove repositories' });
|
|
1070
|
+
}
|
|
1071
|
+
// Unlink repo from workspace (set workspaceId to null)
|
|
1072
|
+
await db.repositories.assignToWorkspace(repoId, null);
|
|
1073
|
+
// Also update workspace config to remove from repositories array
|
|
1074
|
+
const currentRepos = workspace.config.repositories || [];
|
|
1075
|
+
const repo = await db.repositories.findById(repoId);
|
|
1076
|
+
if (repo) {
|
|
1077
|
+
const updatedRepos = currentRepos.filter(r => r.toLowerCase() !== repo.githubFullName.toLowerCase());
|
|
1078
|
+
await db.workspaces.update(id, {
|
|
1079
|
+
config: { ...workspace.config, repositories: updatedRepos },
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
res.json({ success: true, message: 'Repository removed from workspace' });
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
console.error('Error removing repo from workspace:', error);
|
|
1086
|
+
res.status(500).json({ error: 'Failed to remove repository' });
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
/**
|
|
1090
|
+
* PATCH /api/workspaces/:id
|
|
1091
|
+
* Update workspace settings (name, etc.)
|
|
1092
|
+
*/
|
|
1093
|
+
workspacesRouter.patch('/:id', async (req, res) => {
|
|
1094
|
+
const userId = req.session.userId;
|
|
1095
|
+
const { id } = req.params;
|
|
1096
|
+
const { name } = req.body;
|
|
1097
|
+
try {
|
|
1098
|
+
const workspace = await db.workspaces.findById(id);
|
|
1099
|
+
if (!workspace) {
|
|
1100
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1101
|
+
}
|
|
1102
|
+
// Only owner can rename
|
|
1103
|
+
if (workspace.userId !== userId) {
|
|
1104
|
+
return res.status(403).json({ error: 'Only workspace owner can update settings' });
|
|
1105
|
+
}
|
|
1106
|
+
// Validate name if provided
|
|
1107
|
+
if (name !== undefined) {
|
|
1108
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
1109
|
+
return res.status(400).json({ error: 'Name must be a non-empty string' });
|
|
1110
|
+
}
|
|
1111
|
+
if (name.length > 100) {
|
|
1112
|
+
return res.status(400).json({ error: 'Name must be 100 characters or less' });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// Update workspace
|
|
1116
|
+
await db.workspaces.update(id, {
|
|
1117
|
+
...(name && { name: name.trim() }),
|
|
1118
|
+
});
|
|
1119
|
+
const updated = await db.workspaces.findById(id);
|
|
1120
|
+
res.json({
|
|
1121
|
+
success: true,
|
|
1122
|
+
workspace: {
|
|
1123
|
+
id: updated.id,
|
|
1124
|
+
name: updated.name,
|
|
1125
|
+
status: updated.status,
|
|
1126
|
+
publicUrl: updated.publicUrl,
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
console.error('Error updating workspace:', error);
|
|
1132
|
+
res.status(500).json({ error: 'Failed to update workspace' });
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
390
1135
|
/**
|
|
391
1136
|
* POST /api/workspaces/:id/autoscale
|
|
392
1137
|
* Trigger auto-scaling based on current agent count
|
|
@@ -675,8 +1420,19 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
675
1420
|
if (!workspace) {
|
|
676
1421
|
return res.status(404).json({ error: 'Workspace not found' });
|
|
677
1422
|
}
|
|
1423
|
+
// Check if user is owner or has workspace membership
|
|
678
1424
|
if (workspace.userId !== userId) {
|
|
679
|
-
|
|
1425
|
+
// Check workspace membership
|
|
1426
|
+
const membership = await db.workspaceMembers.findMembership(id, userId);
|
|
1427
|
+
if (!membership || !membership.acceptedAt) {
|
|
1428
|
+
return res.status(403).json({ error: 'Unauthorized - not a workspace member' });
|
|
1429
|
+
}
|
|
1430
|
+
// Viewers can only proxy read-only requests
|
|
1431
|
+
if (membership.role === 'viewer' && req.method !== 'GET') {
|
|
1432
|
+
return res.status(403).json({ error: 'Viewers can only make read-only requests' });
|
|
1433
|
+
}
|
|
1434
|
+
// Members and admins can read and write
|
|
1435
|
+
// For now, allow all proxy requests for members and admins
|
|
680
1436
|
}
|
|
681
1437
|
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
682
1438
|
return res.status(400).json({ error: 'Workspace is not running' });
|
|
@@ -705,8 +1461,9 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
705
1461
|
// Store targetUrl for error handling
|
|
706
1462
|
req._proxyTargetUrl = targetUrl;
|
|
707
1463
|
// Add timeout to prevent hanging requests
|
|
1464
|
+
// 45s timeout to accommodate Fly.io machine cold starts (can take 20-30s)
|
|
708
1465
|
const controller = new AbortController();
|
|
709
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
1466
|
+
const timeout = setTimeout(() => controller.abort(), 45000);
|
|
710
1467
|
const fetchOptions = {
|
|
711
1468
|
method: req.method,
|
|
712
1469
|
headers: {
|
|
@@ -744,7 +1501,7 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
744
1501
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
745
1502
|
res.status(504).json({
|
|
746
1503
|
error: 'Workspace request timed out',
|
|
747
|
-
details: 'The workspace did not respond within
|
|
1504
|
+
details: 'The workspace did not respond within 45 seconds',
|
|
748
1505
|
targetUrl: targetUrl,
|
|
749
1506
|
});
|
|
750
1507
|
return;
|
|
@@ -766,6 +1523,156 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
766
1523
|
});
|
|
767
1524
|
}
|
|
768
1525
|
});
|
|
1526
|
+
// ============================================================================
|
|
1527
|
+
// Agent Management (proxied to workspace daemon)
|
|
1528
|
+
// ============================================================================
|
|
1529
|
+
/**
|
|
1530
|
+
* POST /api/workspaces/:id/agents
|
|
1531
|
+
* Spawn an agent in the workspace
|
|
1532
|
+
* Proxies to workspace daemon's /workspaces/:id/agents endpoint
|
|
1533
|
+
*/
|
|
1534
|
+
workspacesRouter.post('/:id/agents', async (req, res) => {
|
|
1535
|
+
const userId = req.session.userId;
|
|
1536
|
+
const { id } = req.params;
|
|
1537
|
+
const { name, provider, task, temporary, interactive } = req.body;
|
|
1538
|
+
if (!userId) {
|
|
1539
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1540
|
+
}
|
|
1541
|
+
if (!name) {
|
|
1542
|
+
return res.status(400).json({ error: 'Agent name is required' });
|
|
1543
|
+
}
|
|
1544
|
+
try {
|
|
1545
|
+
// Find workspace and verify access
|
|
1546
|
+
const workspace = await db.workspaces.findById(id);
|
|
1547
|
+
if (!workspace) {
|
|
1548
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1549
|
+
}
|
|
1550
|
+
// Check access
|
|
1551
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1552
|
+
if (!accessResult.hasAccess) {
|
|
1553
|
+
return res.status(403).json({ error: 'Access denied to this workspace' });
|
|
1554
|
+
}
|
|
1555
|
+
// Ensure workspace is running
|
|
1556
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1557
|
+
return res.status(400).json({
|
|
1558
|
+
error: 'Workspace is not running',
|
|
1559
|
+
status: workspace.status,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
// Proxy to workspace dashboard server's /api/spawn endpoint
|
|
1563
|
+
// The dashboard server expects 'cli' field (not 'provider')
|
|
1564
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawn`;
|
|
1565
|
+
console.log(`[workspaces] Proxying agent spawn to: ${targetUrl}`);
|
|
1566
|
+
const response = await fetch(targetUrl, {
|
|
1567
|
+
method: 'POST',
|
|
1568
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1569
|
+
body: JSON.stringify({
|
|
1570
|
+
name,
|
|
1571
|
+
cli: provider || 'claude', // Map provider to cli
|
|
1572
|
+
task: task || '', // Empty task = interactive mode, user responds to prompts
|
|
1573
|
+
interactive: interactive ?? true, // Default to interactive for setup flows
|
|
1574
|
+
}),
|
|
1575
|
+
signal: AbortSignal.timeout(30000),
|
|
1576
|
+
});
|
|
1577
|
+
const data = await response.json();
|
|
1578
|
+
if (!response.ok) {
|
|
1579
|
+
return res.status(response.status).json(data);
|
|
1580
|
+
}
|
|
1581
|
+
res.status(201).json(data);
|
|
1582
|
+
}
|
|
1583
|
+
catch (error) {
|
|
1584
|
+
console.error('[workspaces] Agent spawn error:', error);
|
|
1585
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1586
|
+
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
|
|
1587
|
+
return res.status(503).json({
|
|
1588
|
+
error: 'Workspace is not reachable',
|
|
1589
|
+
details: 'The workspace container may not be running',
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
res.status(500).json({
|
|
1593
|
+
error: 'Failed to spawn agent',
|
|
1594
|
+
details: errorMessage,
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
/**
|
|
1599
|
+
* GET /api/workspaces/:id/agents
|
|
1600
|
+
* List agents in the workspace
|
|
1601
|
+
*/
|
|
1602
|
+
workspacesRouter.get('/:id/agents', async (req, res) => {
|
|
1603
|
+
const userId = req.session.userId;
|
|
1604
|
+
const { id } = req.params;
|
|
1605
|
+
if (!userId) {
|
|
1606
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
const workspace = await db.workspaces.findById(id);
|
|
1610
|
+
if (!workspace) {
|
|
1611
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1612
|
+
}
|
|
1613
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1614
|
+
if (!accessResult.hasAccess) {
|
|
1615
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1616
|
+
}
|
|
1617
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1618
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1619
|
+
}
|
|
1620
|
+
// Use dashboard server's /api/spawned endpoint
|
|
1621
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned`;
|
|
1622
|
+
const response = await fetch(targetUrl, {
|
|
1623
|
+
signal: AbortSignal.timeout(10000),
|
|
1624
|
+
});
|
|
1625
|
+
if (!response.ok) {
|
|
1626
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1627
|
+
}
|
|
1628
|
+
const data = await response.json();
|
|
1629
|
+
// Transform to expected format
|
|
1630
|
+
res.json({ agents: data.agents || [], workspaceId: id });
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
console.error('[workspaces] List agents error:', error);
|
|
1634
|
+
res.status(200).json({ agents: [], workspaceId: id });
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
/**
|
|
1638
|
+
* DELETE /api/workspaces/:id/agents/:agentName
|
|
1639
|
+
* Stop an agent in the workspace
|
|
1640
|
+
*/
|
|
1641
|
+
workspacesRouter.delete('/:id/agents/:agentName', async (req, res) => {
|
|
1642
|
+
const userId = req.session.userId;
|
|
1643
|
+
const { id, agentName } = req.params;
|
|
1644
|
+
if (!userId) {
|
|
1645
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
const workspace = await db.workspaces.findById(id);
|
|
1649
|
+
if (!workspace) {
|
|
1650
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1651
|
+
}
|
|
1652
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1653
|
+
if (!accessResult.hasAccess) {
|
|
1654
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1655
|
+
}
|
|
1656
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1657
|
+
return res.status(400).json({ error: 'Workspace is not running' });
|
|
1658
|
+
}
|
|
1659
|
+
// Use dashboard server's /api/spawned/:name endpoint
|
|
1660
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned/${encodeURIComponent(agentName)}`;
|
|
1661
|
+
const response = await fetch(targetUrl, {
|
|
1662
|
+
method: 'DELETE',
|
|
1663
|
+
signal: AbortSignal.timeout(10000),
|
|
1664
|
+
});
|
|
1665
|
+
if (response.status === 204) {
|
|
1666
|
+
return res.status(204).send();
|
|
1667
|
+
}
|
|
1668
|
+
const data = await response.json();
|
|
1669
|
+
res.status(response.status).json(data);
|
|
1670
|
+
}
|
|
1671
|
+
catch (error) {
|
|
1672
|
+
console.error('[workspaces] Stop agent error:', error);
|
|
1673
|
+
res.status(500).json({ error: 'Failed to stop agent' });
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
769
1676
|
/**
|
|
770
1677
|
* POST /api/workspaces/quick
|
|
771
1678
|
* Quick provision: one-click with defaults
|
|
@@ -778,6 +1685,29 @@ workspacesRouter.post('/quick', checkWorkspaceLimit, async (req, res) => {
|
|
|
778
1685
|
return res.status(400).json({ error: 'Repository name is required' });
|
|
779
1686
|
}
|
|
780
1687
|
try {
|
|
1688
|
+
// Check if a workspace already exists for this repo
|
|
1689
|
+
// If so, check if user has access and return it instead of creating a duplicate
|
|
1690
|
+
const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName);
|
|
1691
|
+
for (const existingRepo of existingRepos) {
|
|
1692
|
+
if (existingRepo.workspaceId) {
|
|
1693
|
+
// Check if user has access to this workspace
|
|
1694
|
+
const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
|
|
1695
|
+
if (accessResult.hasAccess) {
|
|
1696
|
+
const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
|
|
1697
|
+
if (existingWorkspace) {
|
|
1698
|
+
console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`);
|
|
1699
|
+
return res.status(200).json({
|
|
1700
|
+
workspaceId: existingWorkspace.id,
|
|
1701
|
+
status: existingWorkspace.status,
|
|
1702
|
+
publicUrl: existingWorkspace.publicUrl,
|
|
1703
|
+
existingWorkspace: true,
|
|
1704
|
+
accessType: accessResult.accessType,
|
|
1705
|
+
message: `You already have ${accessResult.accessType} access to a workspace for this repository.`,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
781
1711
|
// Get user's connected providers (optional now)
|
|
782
1712
|
const credentials = await db.credentials.findByUserId(userId);
|
|
783
1713
|
const providers = credentials
|