@vibe80/vibe80 0.1.1
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/LICENSE +201 -0
- package/README.md +52 -0
- package/bin/vibe80.js +176 -0
- package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
- package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
- package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
- package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
- package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
- package/client/dist/assets/browser-e3WgtMs-.js +8 -0
- package/client/dist/assets/index-CgqGyssr.css +32 -0
- package/client/dist/assets/index-DnwKjoj7.js +706 -0
- package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
- package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/favicon.png +0 -0
- package/client/dist/favicon.svg +35 -0
- package/client/dist/index.html +14 -0
- package/client/index.html +16 -0
- package/client/package.json +34 -0
- package/client/public/favicon.ico +0 -0
- package/client/public/favicon.png +0 -0
- package/client/public/favicon.svg +35 -0
- package/client/public/pwa-192x192.png +0 -0
- package/client/public/pwa-512x512.png +0 -0
- package/client/src/App.jsx +3131 -0
- package/client/src/assets/logo_small.png +0 -0
- package/client/src/assets/vibe80_dark.svg +51 -0
- package/client/src/assets/vibe80_light.svg +50 -0
- package/client/src/components/Chat/ChatComposer.jsx +228 -0
- package/client/src/components/Chat/ChatMessages.jsx +811 -0
- package/client/src/components/Chat/ChatToolbar.jsx +109 -0
- package/client/src/components/Chat/useChatComposer.js +462 -0
- package/client/src/components/Diff/DiffPanel.jsx +129 -0
- package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
- package/client/src/components/Logs/LogsPanel.jsx +80 -0
- package/client/src/components/SessionGate/SessionGate.jsx +874 -0
- package/client/src/components/Settings/SettingsPanel.jsx +212 -0
- package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
- package/client/src/components/Topbar/Topbar.jsx +101 -0
- package/client/src/components/WorktreeTabs.css +419 -0
- package/client/src/components/WorktreeTabs.jsx +604 -0
- package/client/src/hooks/useAttachments.jsx +125 -0
- package/client/src/hooks/useBacklog.js +254 -0
- package/client/src/hooks/useChatClear.js +90 -0
- package/client/src/hooks/useChatCollapse.js +42 -0
- package/client/src/hooks/useChatCommands.js +294 -0
- package/client/src/hooks/useChatExport.js +144 -0
- package/client/src/hooks/useChatMessagesState.js +69 -0
- package/client/src/hooks/useChatSend.js +158 -0
- package/client/src/hooks/useChatSocket.js +1239 -0
- package/client/src/hooks/useDiffNavigation.js +19 -0
- package/client/src/hooks/useExplorerActions.js +1184 -0
- package/client/src/hooks/useGitIdentity.js +114 -0
- package/client/src/hooks/useLayoutMode.js +31 -0
- package/client/src/hooks/useLocalPreferences.js +131 -0
- package/client/src/hooks/useMessageSync.js +30 -0
- package/client/src/hooks/useNotifications.js +132 -0
- package/client/src/hooks/usePaneNavigation.js +67 -0
- package/client/src/hooks/usePanelState.js +13 -0
- package/client/src/hooks/useProviderSelection.js +70 -0
- package/client/src/hooks/useRepoBranchesModels.js +218 -0
- package/client/src/hooks/useRepoStatus.js +350 -0
- package/client/src/hooks/useRpcLogActions.js +19 -0
- package/client/src/hooks/useRpcLogView.js +58 -0
- package/client/src/hooks/useSessionHandoff.js +97 -0
- package/client/src/hooks/useSessionLifecycle.js +287 -0
- package/client/src/hooks/useSessionReset.js +63 -0
- package/client/src/hooks/useSessionResync.js +77 -0
- package/client/src/hooks/useTerminalSession.js +328 -0
- package/client/src/hooks/useToolbarExport.js +27 -0
- package/client/src/hooks/useTurnInterrupt.js +43 -0
- package/client/src/hooks/useVibe80Forms.js +128 -0
- package/client/src/hooks/useWorkspaceAuth.js +932 -0
- package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
- package/client/src/hooks/useWorktrees.js +396 -0
- package/client/src/i18n.jsx +87 -0
- package/client/src/index.css +5147 -0
- package/client/src/locales/en.json +37 -0
- package/client/src/locales/fr.json +321 -0
- package/client/src/main.jsx +16 -0
- package/client/vite.config.js +62 -0
- package/docs/api/asyncapi.json +1511 -0
- package/docs/api/openapi.json +3242 -0
- package/git_hooks/prepare-commit-msg +35 -0
- package/package.json +36 -0
- package/server/package.json +29 -0
- package/server/scripts/rotate-workspace-secret.js +101 -0
- package/server/src/claudeClient.js +454 -0
- package/server/src/clientEvents.js +594 -0
- package/server/src/clientFactory.js +164 -0
- package/server/src/codexClient.js +468 -0
- package/server/src/config.js +27 -0
- package/server/src/helpers.js +138 -0
- package/server/src/index.js +1641 -0
- package/server/src/middleware/auth.js +93 -0
- package/server/src/middleware/debug.js +89 -0
- package/server/src/middleware/errorTypes.js +60 -0
- package/server/src/providerLogger.js +60 -0
- package/server/src/routes/files.js +114 -0
- package/server/src/routes/git.js +183 -0
- package/server/src/routes/health.js +13 -0
- package/server/src/routes/sessions.js +407 -0
- package/server/src/routes/workspaces.js +296 -0
- package/server/src/routes/worktrees.js +993 -0
- package/server/src/runAs.js +458 -0
- package/server/src/runtimeStore.js +32 -0
- package/server/src/services/auth.js +157 -0
- package/server/src/services/claudeThreadDirectory.js +33 -0
- package/server/src/services/session.js +918 -0
- package/server/src/services/workspace.js +858 -0
- package/server/src/storage/index.js +17 -0
- package/server/src/storage/redis.js +412 -0
- package/server/src/storage/sqlite.js +649 -0
- package/server/src/worktreeManager.js +717 -0
- package/server/tests/README.md +13 -0
- package/server/tests/factories/workspaceFactory.js +13 -0
- package/server/tests/fixtures/workspaceCredentials.json +4 -0
- package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
- package/server/tests/setup/env.js +9 -0
- package/server/tests/unit/helpers.test.js +95 -0
- package/server/tests/unit/services/auth.test.js +181 -0
- package/server/tests/unit/services/workspace.test.js +115 -0
- package/server/vitest.config.js +23 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import storage from "../storage/index.js";
|
|
4
|
+
import {
|
|
5
|
+
createMessageId,
|
|
6
|
+
classifySessionCreationError,
|
|
7
|
+
toIsoDateTime,
|
|
8
|
+
} from "../helpers.js";
|
|
9
|
+
import { debugApiWsLog } from "../middleware/debug.js";
|
|
10
|
+
import {
|
|
11
|
+
handoffTokens,
|
|
12
|
+
createHandoffToken,
|
|
13
|
+
issueWorkspaceTokens,
|
|
14
|
+
} from "../services/auth.js";
|
|
15
|
+
import {
|
|
16
|
+
ensureWorkspaceUserExists,
|
|
17
|
+
readWorkspaceConfig,
|
|
18
|
+
listEnabledProviders,
|
|
19
|
+
} from "../services/workspace.js";
|
|
20
|
+
import {
|
|
21
|
+
getSession,
|
|
22
|
+
touchSession,
|
|
23
|
+
createSession,
|
|
24
|
+
cleanupSession,
|
|
25
|
+
getRepoDiff,
|
|
26
|
+
getCurrentBranch,
|
|
27
|
+
getLastCommit,
|
|
28
|
+
resolveDefaultDenyGitCredentialsAccess,
|
|
29
|
+
broadcastToSession,
|
|
30
|
+
} from "../services/session.js";
|
|
31
|
+
import {
|
|
32
|
+
getWorktree,
|
|
33
|
+
clearWorktreeMessages,
|
|
34
|
+
} from "../worktreeManager.js";
|
|
35
|
+
|
|
36
|
+
const terminalEnabled = /^(1|true|yes|on)$/i.test(process.env.TERMINAL_ENABLED || "1");
|
|
37
|
+
const instanceHostname = process.env.HOSTNAME || os.hostname();
|
|
38
|
+
|
|
39
|
+
export default function sessionRoutes(deps) {
|
|
40
|
+
const {
|
|
41
|
+
getOrCreateClient,
|
|
42
|
+
attachClientEvents,
|
|
43
|
+
attachClaudeEvents,
|
|
44
|
+
getActiveClient,
|
|
45
|
+
deploymentMode,
|
|
46
|
+
} = deps;
|
|
47
|
+
|
|
48
|
+
const router = Router();
|
|
49
|
+
const resolveEnabledProviders = async (workspaceId) => {
|
|
50
|
+
try {
|
|
51
|
+
const workspaceConfig = await readWorkspaceConfig(workspaceId);
|
|
52
|
+
return listEnabledProviders(workspaceConfig?.providers || {});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
router.get("/sessions", (req, res) => {
|
|
59
|
+
const workspaceId = req.workspaceId;
|
|
60
|
+
storage
|
|
61
|
+
.listSessions(workspaceId)
|
|
62
|
+
.then(async (sessions) => {
|
|
63
|
+
const enabledProviders = await resolveEnabledProviders(workspaceId);
|
|
64
|
+
const payload = sessions.map((session) => ({
|
|
65
|
+
sessionId: session.sessionId,
|
|
66
|
+
repoUrl: session.repoUrl || "",
|
|
67
|
+
name: session.name || "",
|
|
68
|
+
createdAt: toIsoDateTime(session.createdAt),
|
|
69
|
+
lastActivityAt: toIsoDateTime(session.lastActivityAt),
|
|
70
|
+
activeProvider: session.activeProvider || null,
|
|
71
|
+
providers: enabledProviders,
|
|
72
|
+
}));
|
|
73
|
+
payload.sort((a, b) => {
|
|
74
|
+
const aTime = Date.parse(a.lastActivityAt || a.createdAt || "") || 0;
|
|
75
|
+
const bTime = Date.parse(b.lastActivityAt || b.createdAt || "") || 0;
|
|
76
|
+
return bTime - aTime;
|
|
77
|
+
});
|
|
78
|
+
res.json({ sessions: payload });
|
|
79
|
+
})
|
|
80
|
+
.catch((error) => {
|
|
81
|
+
res.status(500).json({ error: error?.message || "Failed to list sessions." });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
router.post("/sessions/handoff", async (req, res) => {
|
|
86
|
+
const sessionId = req.body?.sessionId;
|
|
87
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
88
|
+
res.status(400).json({ error: "Session ID required.", error_type: "SESSION_ID_REQUIRED" });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
92
|
+
if (!session) {
|
|
93
|
+
res.status(404).json({ error: "Session not found.", error_type: "SESSION_NOT_FOUND" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const record = createHandoffToken(session);
|
|
97
|
+
res.json({
|
|
98
|
+
handoffToken: record.token,
|
|
99
|
+
expiresAt: toIsoDateTime(record.expiresAt),
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
router.post("/sessions/handoff/consume", async (req, res) => {
|
|
104
|
+
const handoffToken = req.body?.handoffToken;
|
|
105
|
+
if (!handoffToken || typeof handoffToken !== "string") {
|
|
106
|
+
res.status(400).json({ error: "Handoff token required.", error_type: "HANDOFF_TOKEN_REQUIRED" });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const record = handoffTokens.get(handoffToken);
|
|
110
|
+
if (!record) {
|
|
111
|
+
res.status(404).json({ error: "Handoff token not found.", error_type: "HANDOFF_TOKEN_INVALID" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (record.usedAt) {
|
|
115
|
+
res.status(409).json({ error: "Handoff token already used.", error_type: "HANDOFF_TOKEN_USED" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (record.expiresAt && record.expiresAt <= Date.now()) {
|
|
119
|
+
handoffTokens.delete(handoffToken);
|
|
120
|
+
res.status(410).json({ error: "Handoff token expired.", error_type: "HANDOFF_TOKEN_EXPIRED" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const session = await getSession(record.sessionId, record.workspaceId);
|
|
124
|
+
if (!session) {
|
|
125
|
+
res.status(404).json({ error: "Session not found.", error_type: "SESSION_NOT_FOUND" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
record.usedAt = Date.now();
|
|
129
|
+
const tokens = await issueWorkspaceTokens(record.workspaceId);
|
|
130
|
+
res.json({
|
|
131
|
+
workspaceId: record.workspaceId,
|
|
132
|
+
workspaceToken: tokens.workspaceToken,
|
|
133
|
+
refreshToken: tokens.refreshToken,
|
|
134
|
+
expiresIn: tokens.expiresIn,
|
|
135
|
+
refreshExpiresIn: tokens.refreshExpiresIn,
|
|
136
|
+
sessionId: record.sessionId,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
router.get("/sessions/:sessionId", async (req, res) => {
|
|
141
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
142
|
+
if (!session) {
|
|
143
|
+
res.status(404).json({ error: "Session not found." });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await touchSession(session);
|
|
147
|
+
const repoDiff = await getRepoDiff(session);
|
|
148
|
+
const activeProvider = session.activeProvider || "codex";
|
|
149
|
+
const enabledProviders = await resolveEnabledProviders(session.workspaceId);
|
|
150
|
+
res.json({
|
|
151
|
+
sessionId: req.params.sessionId,
|
|
152
|
+
workspaceId: session.workspaceId,
|
|
153
|
+
path: session.dir,
|
|
154
|
+
repoUrl: session.repoUrl,
|
|
155
|
+
name: session.name || "",
|
|
156
|
+
defaultProvider: activeProvider,
|
|
157
|
+
providers: enabledProviders,
|
|
158
|
+
defaultInternetAccess:
|
|
159
|
+
typeof session.defaultInternetAccess === "boolean"
|
|
160
|
+
? session.defaultInternetAccess
|
|
161
|
+
: true,
|
|
162
|
+
defaultDenyGitCredentialsAccess: resolveDefaultDenyGitCredentialsAccess(session),
|
|
163
|
+
repoDiff,
|
|
164
|
+
rpcLogsEnabled: debugApiWsLog,
|
|
165
|
+
rpcLogs: debugApiWsLog ? session.rpcLogs || [] : [],
|
|
166
|
+
terminalEnabled,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
router.delete("/sessions/:sessionId", async (req, res) => {
|
|
171
|
+
const sessionId = req.params.sessionId;
|
|
172
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
173
|
+
if (!session) {
|
|
174
|
+
res.status(404).json({ error: "Session not found." });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await cleanupSession(sessionId, "user_request");
|
|
179
|
+
res.json({ ok: true, sessionId });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
res.status(500).json({ error: "Failed to delete session." });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
router.get("/sessions/:sessionId/health", async (req, res) => {
|
|
186
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
187
|
+
if (!session) {
|
|
188
|
+
res.status(404).json({ error: "Session not found." });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await touchSession(session);
|
|
192
|
+
const activeClient = getActiveClient(session);
|
|
193
|
+
res.json({
|
|
194
|
+
ok: true,
|
|
195
|
+
ready: activeClient?.ready || false,
|
|
196
|
+
threadId: activeClient?.threadId || null,
|
|
197
|
+
provider: session.activeProvider || "codex",
|
|
198
|
+
deploymentMode,
|
|
199
|
+
instance: instanceHostname,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
router.get("/sessions/:sessionId/last-commit", async (req, res) => {
|
|
204
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
205
|
+
if (!session) {
|
|
206
|
+
res.status(404).json({ error: "Session not found." });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
await touchSession(session);
|
|
210
|
+
try {
|
|
211
|
+
const [branch, commit] = await Promise.all([
|
|
212
|
+
getCurrentBranch(session),
|
|
213
|
+
getLastCommit(session, session.repoDir),
|
|
214
|
+
]);
|
|
215
|
+
res.json({ branch, commit });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
res.status(500).json({ error: "Failed to load last commit." });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
router.get("/sessions/:sessionId/rpc-logs", async (req, res) => {
|
|
222
|
+
if (!debugApiWsLog) {
|
|
223
|
+
res.status(403).json({ error: "Forbidden." });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
227
|
+
if (!session) {
|
|
228
|
+
res.status(404).json({ error: "Session not found." });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await touchSession(session);
|
|
232
|
+
res.json({ rpcLogs: session.rpcLogs || [] });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
router.post("/sessions/:sessionId/clear", async (req, res) => {
|
|
236
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
237
|
+
if (!session) {
|
|
238
|
+
res.status(404).json({ error: "Session not found." });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await touchSession(session);
|
|
242
|
+
const worktreeId = req.body?.worktreeId;
|
|
243
|
+
if (worktreeId) {
|
|
244
|
+
const worktree = await getWorktree(session, worktreeId);
|
|
245
|
+
if (!worktree) {
|
|
246
|
+
res.status(404).json({ error: "Worktree not found." });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await clearWorktreeMessages(session, worktreeId);
|
|
250
|
+
res.json({ ok: true, worktreeId });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
await clearWorktreeMessages(session, "main");
|
|
254
|
+
res.json({ ok: true, worktreeId: "main" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
router.post("/sessions/:sessionId/backlog-items", async (req, res) => {
|
|
258
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
259
|
+
if (!session) {
|
|
260
|
+
res.status(404).json({ error: "Session not found." });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
264
|
+
if (!text) {
|
|
265
|
+
res.status(400).json({ error: "text is required." });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await touchSession(session);
|
|
269
|
+
const item = {
|
|
270
|
+
id: createMessageId(),
|
|
271
|
+
text,
|
|
272
|
+
createdAt: Date.now(),
|
|
273
|
+
done: false,
|
|
274
|
+
};
|
|
275
|
+
const backlog = Array.isArray(session.backlog) ? session.backlog : [];
|
|
276
|
+
const updated = {
|
|
277
|
+
...session,
|
|
278
|
+
backlog: [item, ...backlog],
|
|
279
|
+
lastActivityAt: Date.now(),
|
|
280
|
+
};
|
|
281
|
+
await storage.saveSession(session.sessionId, updated);
|
|
282
|
+
res.json({
|
|
283
|
+
ok: true,
|
|
284
|
+
item: {
|
|
285
|
+
...item,
|
|
286
|
+
createdAt: toIsoDateTime(item.createdAt),
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
router.get("/sessions/:sessionId/backlog-items", async (req, res) => {
|
|
292
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
293
|
+
if (!session) {
|
|
294
|
+
res.status(404).json({ error: "Session not found." });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await touchSession(session);
|
|
298
|
+
const backlog = Array.isArray(session.backlog) ? session.backlog : [];
|
|
299
|
+
const serializedItems = backlog.map((item) => ({
|
|
300
|
+
...item,
|
|
301
|
+
createdAt: toIsoDateTime(item?.createdAt),
|
|
302
|
+
doneAt: toIsoDateTime(item?.doneAt),
|
|
303
|
+
}));
|
|
304
|
+
res.json({ items: serializedItems });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
router.patch("/sessions/:sessionId/backlog-items/:itemId", async (req, res) => {
|
|
308
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
309
|
+
if (!session) {
|
|
310
|
+
res.status(404).json({ error: "Session not found." });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const itemId =
|
|
314
|
+
typeof req.params.itemId === "string" ? req.params.itemId.trim() : "";
|
|
315
|
+
if (!itemId) {
|
|
316
|
+
res.status(400).json({ error: "itemId is required." });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (typeof req.body?.done !== "boolean") {
|
|
320
|
+
res.status(400).json({ error: "done is required." });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await touchSession(session);
|
|
324
|
+
const backlog = Array.isArray(session.backlog) ? session.backlog : [];
|
|
325
|
+
const index = backlog.findIndex((item) => item?.id === itemId);
|
|
326
|
+
if (index === -1) {
|
|
327
|
+
res.status(404).json({ error: "Backlog item not found." });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const updatedItem = {
|
|
331
|
+
...backlog[index],
|
|
332
|
+
done: req.body.done,
|
|
333
|
+
doneAt: req.body.done ? Date.now() : null,
|
|
334
|
+
};
|
|
335
|
+
const updatedBacklog = [...backlog];
|
|
336
|
+
updatedBacklog[index] = updatedItem;
|
|
337
|
+
const updatedSession = {
|
|
338
|
+
...session,
|
|
339
|
+
backlog: updatedBacklog,
|
|
340
|
+
lastActivityAt: Date.now(),
|
|
341
|
+
};
|
|
342
|
+
await storage.saveSession(session.sessionId, updatedSession);
|
|
343
|
+
res.json({
|
|
344
|
+
ok: true,
|
|
345
|
+
item: {
|
|
346
|
+
...updatedItem,
|
|
347
|
+
createdAt: toIsoDateTime(updatedItem.createdAt),
|
|
348
|
+
doneAt: toIsoDateTime(updatedItem.doneAt),
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
router.post("/sessions", async (req, res) => {
|
|
354
|
+
const repoUrl = req.body?.repoUrl;
|
|
355
|
+
if (!repoUrl) {
|
|
356
|
+
res.status(400).json({ error: "repoUrl is required." });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await ensureWorkspaceUserExists(req.workspaceId);
|
|
361
|
+
const auth = req.body?.auth || null;
|
|
362
|
+
const defaultInternetAccess = req.body?.defaultInternetAccess;
|
|
363
|
+
const defaultDenyGitCredentialsAccess =
|
|
364
|
+
typeof req.body?.defaultDenyGitCredentialsAccess === "boolean"
|
|
365
|
+
? req.body.defaultDenyGitCredentialsAccess
|
|
366
|
+
: undefined;
|
|
367
|
+
const name = req.body?.name;
|
|
368
|
+
const session = await createSession(
|
|
369
|
+
req.workspaceId,
|
|
370
|
+
repoUrl,
|
|
371
|
+
auth,
|
|
372
|
+
defaultInternetAccess,
|
|
373
|
+
defaultDenyGitCredentialsAccess,
|
|
374
|
+
name,
|
|
375
|
+
{ getOrCreateClient, attachClientEvents, attachClaudeEvents, broadcastToSession }
|
|
376
|
+
);
|
|
377
|
+
const enabledProviders = await resolveEnabledProviders(req.workspaceId);
|
|
378
|
+
const defaultProvider = session.activeProvider
|
|
379
|
+
|| (enabledProviders.includes("codex") ? "codex" : enabledProviders[0] || "codex");
|
|
380
|
+
res.status(201).location(`/api/sessions/${session.sessionId}`).json({
|
|
381
|
+
sessionId: session.sessionId,
|
|
382
|
+
workspaceId: session.workspaceId || req.workspaceId,
|
|
383
|
+
path: session.dir,
|
|
384
|
+
repoUrl,
|
|
385
|
+
name: session.name || "",
|
|
386
|
+
defaultProvider,
|
|
387
|
+
providers: enabledProviders,
|
|
388
|
+
defaultInternetAccess:
|
|
389
|
+
typeof session.defaultInternetAccess === "boolean"
|
|
390
|
+
? session.defaultInternetAccess
|
|
391
|
+
: true,
|
|
392
|
+
defaultDenyGitCredentialsAccess: resolveDefaultDenyGitCredentialsAccess(session),
|
|
393
|
+
rpcLogsEnabled: debugApiWsLog,
|
|
394
|
+
terminalEnabled,
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error("Failed to create session for repo:", {
|
|
398
|
+
repoUrl,
|
|
399
|
+
error: error?.message || error,
|
|
400
|
+
});
|
|
401
|
+
const classified = classifySessionCreationError(error);
|
|
402
|
+
res.status(classified.status).json({ error: classified.error });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return router;
|
|
407
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import storage from "../storage/index.js";
|
|
3
|
+
import {
|
|
4
|
+
consumeMonoAuthToken,
|
|
5
|
+
issueWorkspaceTokens,
|
|
6
|
+
rotateWorkspaceRefreshToken,
|
|
7
|
+
} from "../services/auth.js";
|
|
8
|
+
import {
|
|
9
|
+
workspaceIdPattern,
|
|
10
|
+
createWorkspace,
|
|
11
|
+
updateWorkspace,
|
|
12
|
+
readWorkspaceConfig,
|
|
13
|
+
verifyWorkspaceSecret,
|
|
14
|
+
getWorkspaceUserIds,
|
|
15
|
+
sanitizeProvidersForResponse,
|
|
16
|
+
mergeProvidersForUpdate,
|
|
17
|
+
appendAuditLog,
|
|
18
|
+
} from "../services/workspace.js";
|
|
19
|
+
import { getExistingSessionRuntime } from "../runtimeStore.js";
|
|
20
|
+
|
|
21
|
+
const isObject = (value) =>
|
|
22
|
+
value != null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
|
|
24
|
+
const stableStringify = (value) => {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
27
|
+
}
|
|
28
|
+
if (!isObject(value)) {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
const keys = Object.keys(value).sort();
|
|
32
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
33
|
+
return `{${entries.join(",")}}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const providersConfigChanged = (beforeConfig, afterConfig) =>
|
|
37
|
+
stableStringify(beforeConfig ?? null) !== stableStringify(afterConfig ?? null);
|
|
38
|
+
|
|
39
|
+
const sessionUsesProvider = (session, provider) => {
|
|
40
|
+
if (!session || !provider) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(session.providers) && session.providers.length) {
|
|
44
|
+
return session.providers.includes(provider);
|
|
45
|
+
}
|
|
46
|
+
if (typeof session.activeProvider === "string") {
|
|
47
|
+
return session.activeProvider === provider;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const providerHasActiveSessions = async (workspaceId, provider) => {
|
|
53
|
+
const sessions = await storage.listSessions(workspaceId);
|
|
54
|
+
return sessions.some((session) => sessionUsesProvider(session, provider));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const restartCodexClientsForWorkspace = async (workspaceId) => {
|
|
58
|
+
const sessions = await storage.listSessions(workspaceId);
|
|
59
|
+
for (const session of sessions) {
|
|
60
|
+
if (!session?.sessionId) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const runtime = getExistingSessionRuntime(session.sessionId);
|
|
64
|
+
if (!runtime) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const codexClient = runtime?.clients?.codex;
|
|
68
|
+
if (codexClient) {
|
|
69
|
+
if (codexClient.getStatus?.() === "idle") {
|
|
70
|
+
await codexClient.restart?.();
|
|
71
|
+
} else {
|
|
72
|
+
codexClient.requestRestart?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (runtime?.worktreeClients instanceof Map) {
|
|
76
|
+
for (const client of runtime.worktreeClients.values()) {
|
|
77
|
+
if (!client || client?.constructor?.name !== "CodexAppServerClient") {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (client.getStatus?.() === "idle") {
|
|
81
|
+
await client.restart?.();
|
|
82
|
+
} else {
|
|
83
|
+
client.requestRestart?.();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default function workspaceRoutes() {
|
|
91
|
+
const router = Router();
|
|
92
|
+
const deploymentMode = process.env.DEPLOYMENT_MODE;
|
|
93
|
+
|
|
94
|
+
router.post("/workspaces", async (req, res) => {
|
|
95
|
+
if (deploymentMode === "mono_user") {
|
|
96
|
+
res.status(403).json({
|
|
97
|
+
error: "Workspace creation is forbidden in mono_user mode.",
|
|
98
|
+
error_type: "WORKSPACE_CREATE_FORBIDDEN",
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const providers = req.body?.providers;
|
|
104
|
+
const result = await createWorkspace(providers);
|
|
105
|
+
res.status(201).location(`/api/workspaces/${result.workspaceId}`).json(result);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
res.status(400).json({ error: error.message || "Failed to create workspace." });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
router.post("/workspaces/login", async (req, res) => {
|
|
112
|
+
const grantType = typeof req.body?.grantType === "string"
|
|
113
|
+
? req.body.grantType.trim()
|
|
114
|
+
: "";
|
|
115
|
+
if (grantType === "mono_auth_token") {
|
|
116
|
+
if (deploymentMode !== "mono_user") {
|
|
117
|
+
res.status(403).json({
|
|
118
|
+
error: "Mono auth token grant is only available in mono_user mode.",
|
|
119
|
+
error_type: "MONO_AUTH_FORBIDDEN",
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const monoAuthToken = typeof req.body?.monoAuthToken === "string"
|
|
124
|
+
? req.body.monoAuthToken.trim()
|
|
125
|
+
: "";
|
|
126
|
+
if (!monoAuthToken) {
|
|
127
|
+
res.status(400).json({
|
|
128
|
+
error: "monoAuthToken is required.",
|
|
129
|
+
error_type: "MONO_AUTH_TOKEN_REQUIRED",
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const consumed = consumeMonoAuthToken(monoAuthToken);
|
|
134
|
+
if (!consumed.ok || !consumed.workspaceId) {
|
|
135
|
+
res.status(401).json({
|
|
136
|
+
error: "Invalid mono auth token.",
|
|
137
|
+
error_type: consumed.code || "MONO_AUTH_TOKEN_INVALID",
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await getWorkspaceUserIds(consumed.workspaceId);
|
|
143
|
+
const tokens = await issueWorkspaceTokens(consumed.workspaceId);
|
|
144
|
+
await appendAuditLog(consumed.workspaceId, "workspace_login_success", {
|
|
145
|
+
grantType: "mono_auth_token",
|
|
146
|
+
});
|
|
147
|
+
res.json(tokens);
|
|
148
|
+
} catch {
|
|
149
|
+
await appendAuditLog(consumed.workspaceId, "workspace_login_failed", {
|
|
150
|
+
grantType: "mono_auth_token",
|
|
151
|
+
});
|
|
152
|
+
res.status(401).json({
|
|
153
|
+
error: "Invalid mono auth token.",
|
|
154
|
+
error_type: "MONO_AUTH_TOKEN_INVALID",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (deploymentMode === "mono_user") {
|
|
161
|
+
res.status(403).json({
|
|
162
|
+
error: "Workspace credentials login is forbidden in mono_user mode.",
|
|
163
|
+
error_type: "WORKSPACE_LOGIN_FORBIDDEN",
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const workspaceId = req.body?.workspaceId;
|
|
169
|
+
const workspaceSecret = req.body?.workspaceSecret;
|
|
170
|
+
if (!workspaceId || !workspaceSecret) {
|
|
171
|
+
res.status(401).json({ error: "Invalid workspace credentials." });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!workspaceIdPattern.test(workspaceId)) {
|
|
175
|
+
res.status(401).json({ error: "Invalid workspace credentials." });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const matches = await verifyWorkspaceSecret(workspaceId, workspaceSecret);
|
|
180
|
+
if (!matches) {
|
|
181
|
+
await appendAuditLog(workspaceId, "workspace_login_failed");
|
|
182
|
+
res.status(401).json({ error: "Invalid workspace credentials." });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await getWorkspaceUserIds(workspaceId);
|
|
186
|
+
const tokens = await issueWorkspaceTokens(workspaceId);
|
|
187
|
+
await appendAuditLog(workspaceId, "workspace_login_success");
|
|
188
|
+
res.json(tokens);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await appendAuditLog(workspaceId, "workspace_login_failed");
|
|
191
|
+
res.status(401).json({ error: "Invalid workspace credentials." });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
router.post("/workspaces/refresh", async (req, res) => {
|
|
196
|
+
const refreshToken = req.body?.refreshToken;
|
|
197
|
+
if (!refreshToken || typeof refreshToken !== "string") {
|
|
198
|
+
res.status(400).json({ error: "refreshToken is required." });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const rotated = await rotateWorkspaceRefreshToken(refreshToken);
|
|
203
|
+
if (!rotated?.ok) {
|
|
204
|
+
res.status(rotated?.status || 401).json(
|
|
205
|
+
rotated?.payload || { error: "Invalid refresh token.", code: "invalid_refresh_token" }
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
res.json(rotated.payload);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
res.status(500).json({ error: "Failed to refresh workspace token." });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
router.get("/workspaces/:workspaceId", async (req, res) => {
|
|
216
|
+
const workspaceId = req.params.workspaceId;
|
|
217
|
+
if (!workspaceIdPattern.test(workspaceId)) {
|
|
218
|
+
res.status(400).json({ error: "Invalid workspaceId." });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (req.workspaceId && req.workspaceId !== workspaceId) {
|
|
222
|
+
res.status(403).json({ error: "Forbidden." });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const config = await readWorkspaceConfig(workspaceId);
|
|
227
|
+
res.json({ workspaceId, providers: sanitizeProvidersForResponse(config?.providers) });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
res.status(400).json({ error: error.message || "Failed to load workspace." });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
router.patch("/workspaces/:workspaceId", async (req, res) => {
|
|
234
|
+
const workspaceId = req.params.workspaceId;
|
|
235
|
+
if (!workspaceIdPattern.test(workspaceId)) {
|
|
236
|
+
res.status(400).json({ error: "Invalid workspaceId." });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (req.workspaceId && req.workspaceId !== workspaceId) {
|
|
240
|
+
res.status(403).json({ error: "Forbidden." });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const existing = await readWorkspaceConfig(workspaceId).catch(() => null);
|
|
245
|
+
const mergedProviders = mergeProvidersForUpdate(
|
|
246
|
+
existing?.providers || {},
|
|
247
|
+
req.body?.providers || {}
|
|
248
|
+
);
|
|
249
|
+
const providersToDisable = Object.entries(mergedProviders)
|
|
250
|
+
.filter(([provider, config]) => {
|
|
251
|
+
if (!config || typeof config !== "object") {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const wasEnabled = Boolean(existing?.providers?.[provider]?.enabled);
|
|
255
|
+
return wasEnabled && config.enabled === false;
|
|
256
|
+
})
|
|
257
|
+
.map(([provider]) => provider);
|
|
258
|
+
if (providersToDisable.length) {
|
|
259
|
+
for (const provider of providersToDisable) {
|
|
260
|
+
if (await providerHasActiveSessions(workspaceId, provider)) {
|
|
261
|
+
res.status(403).json({
|
|
262
|
+
error: "Provider cannot be disabled: active sessions use it.",
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const codexChanged = providersConfigChanged(
|
|
269
|
+
existing?.providers?.codex,
|
|
270
|
+
mergedProviders?.codex
|
|
271
|
+
);
|
|
272
|
+
const payload = await updateWorkspace(workspaceId, mergedProviders);
|
|
273
|
+
if (codexChanged) {
|
|
274
|
+
await restartCodexClientsForWorkspace(workspaceId);
|
|
275
|
+
}
|
|
276
|
+
res.json({ workspaceId, providers: sanitizeProvidersForResponse(payload.providers) });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
res.status(400).json({ error: error.message || "Failed to update workspace." });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
router.delete("/workspaces/:workspaceId", async (req, res) => {
|
|
283
|
+
const workspaceId = req.params.workspaceId;
|
|
284
|
+
if (!workspaceIdPattern.test(workspaceId)) {
|
|
285
|
+
res.status(400).json({ error: "Invalid workspaceId." });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (req.workspaceId && req.workspaceId !== workspaceId) {
|
|
289
|
+
res.status(403).json({ error: "Forbidden." });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
res.status(405).json({ error: "Workspace deletion is currently disabled." });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return router;
|
|
296
|
+
}
|