deepdebug-local-agent 1.0.19 → 1.0.21
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/CLAUDE_CODE_AGENT_INFINITE.md +623 -0
- package/Dockerfile +34 -16
- package/Dockerfile.full +63 -0
- package/cloudbuild-agent-qa.yaml +43 -0
- package/docker-compose.yml +2 -2
- package/local-agent-source.tar.gz +0 -0
- package/package.json +4 -3
- package/src/detectors/language-detector.js +29 -12
- package/src/detectors.js +291 -8
- package/src/exec-utils.js +106 -18
- package/src/local-agent-source.tar.gz +0 -0
- package/src/runtimes/java/maven-runtime.js +4 -3
- package/src/server.js +889 -54
- package/src/utils/exec-utils.js +8 -4
- package/src/vercel-proxy.js +226 -0
- package/tunnel-manager.js +70 -0
package/src/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "path";
|
|
|
5
5
|
import os from "os";
|
|
6
6
|
import fs from "fs";
|
|
7
7
|
const fsPromises = fs.promises;
|
|
8
|
-
import { exec } from "child_process";
|
|
8
|
+
import { exec, spawn } from "child_process";
|
|
9
9
|
import { promisify } from "util";
|
|
10
10
|
import { EventEmitter } from "events";
|
|
11
11
|
import { exists, listRecursive, readFile, writeFile } from "./fs-utils.js";
|
|
@@ -24,6 +24,7 @@ import { DTOAnalyzer } from "./analyzers/dto-analyzer.js";
|
|
|
24
24
|
import { ConfigAnalyzer } from "./analyzers/config-analyzer.js";
|
|
25
25
|
import { WorkspaceManager } from "./workspace-manager.js";
|
|
26
26
|
import { startMCPHttpServer } from "./mcp-http-server.js";
|
|
27
|
+
import { registerVercelRoutes } from "./vercel-proxy.js";
|
|
27
28
|
|
|
28
29
|
const execAsync = promisify(exec);
|
|
29
30
|
|
|
@@ -545,24 +546,49 @@ const app = express();
|
|
|
545
546
|
// FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
|
|
546
547
|
const PORT = process.env.PORT || process.env.LOCAL_AGENT_PORT || 5055;
|
|
547
548
|
|
|
548
|
-
//
|
|
549
|
+
// CORS - Allow from Cloud Run, local dev, and Lovable frontend domains
|
|
549
550
|
app.use(cors({
|
|
550
|
-
origin:
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
551
|
+
origin: function(origin, callback) {
|
|
552
|
+
// Allow requests with no origin (curl, mobile apps, server-to-server)
|
|
553
|
+
if (!origin) return callback(null, true);
|
|
554
|
+
|
|
555
|
+
const allowed = [
|
|
556
|
+
// Local development
|
|
557
|
+
"http://localhost:3010",
|
|
558
|
+
"http://localhost:3000",
|
|
559
|
+
"http://localhost:8085",
|
|
560
|
+
"http://127.0.0.1:3010",
|
|
561
|
+
"http://127.0.0.1:3000",
|
|
562
|
+
"http://127.0.0.1:8085",
|
|
563
|
+
];
|
|
564
|
+
|
|
565
|
+
const allowedPatterns = [
|
|
566
|
+
/https:\/\/.*\.run\.app$/, // Cloud Run
|
|
567
|
+
/https:\/\/.*\.web\.app$/, // Firebase hosting
|
|
568
|
+
/https:\/\/.*\.lovable\.app$/, // Lovable preview
|
|
569
|
+
/https:\/\/.*\.lovableproject\.com$/, // Lovable project domains
|
|
570
|
+
/https:\/\/.*\.netlify\.app$/, // Netlify
|
|
571
|
+
/https:\/\/.*\.vercel\.app$/, // Vercel
|
|
572
|
+
/https:\/\/deepdebug\.ai$/, // Production
|
|
573
|
+
/https:\/\/.*\.deepdebug\.ai$/, // Production subdomains
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
if (allowed.includes(origin) || allowedPatterns.some(p => p.test(origin))) {
|
|
577
|
+
return callback(null, true);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// In development/QA, log and allow unknown origins instead of blocking
|
|
581
|
+
const env = process.env.NODE_ENV || 'development';
|
|
582
|
+
if (env !== 'production') {
|
|
583
|
+
console.warn(`CORS: allowing unknown origin in non-prod: ${origin}`);
|
|
584
|
+
return callback(null, true);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
console.warn(`CORS: blocked origin: ${origin}`);
|
|
588
|
+
return callback(new Error(`CORS: origin ${origin} not allowed`));
|
|
589
|
+
},
|
|
564
590
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
565
|
-
allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID"],
|
|
591
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID"],
|
|
566
592
|
credentials: true
|
|
567
593
|
}));
|
|
568
594
|
|
|
@@ -573,7 +599,9 @@ app.use(bodyParser.json({ limit: "50mb" }));
|
|
|
573
599
|
|
|
574
600
|
// DEFAULT WORKSPACE - Define o workspace padro
|
|
575
601
|
// Pode ser sobrescrito via varivel de ambiente ou POST /workspace/open
|
|
576
|
-
const DEFAULT_WORKSPACE = process.env.
|
|
602
|
+
const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
|
|
603
|
+
|| process.env.DEFAULT_WORKSPACE
|
|
604
|
+
|| '/Users/macintosh/IdeaProjects/pure-core-ms';
|
|
577
605
|
|
|
578
606
|
let WORKSPACE_ROOT = fs.existsSync(DEFAULT_WORKSPACE) ? DEFAULT_WORKSPACE : null;
|
|
579
607
|
if (WORKSPACE_ROOT) {
|
|
@@ -622,7 +650,19 @@ aiEngine = new AIVibeCodingEngine(processManager, () => WORKSPACE_ROOT);
|
|
|
622
650
|
// ============================================
|
|
623
651
|
const BACKUPS = new Map();
|
|
624
652
|
const MAX_BACKUPS = 50;
|
|
625
|
-
|
|
653
|
+
// Store backup index on NFS Filestore so it survives container restarts
|
|
654
|
+
// Falls back to /tmp if NFS is not mounted (local dev)
|
|
655
|
+
const NFS_BACKUPS_DIR = process.env.WORKSPACE_MOUNT || '/mnt/workspaces';
|
|
656
|
+
const NFS_BACKUP_INDEX = path.join(NFS_BACKUPS_DIR, '.deepdebug-backups-index.json');
|
|
657
|
+
const BACKUP_INDEX_PATH = (() => {
|
|
658
|
+
try {
|
|
659
|
+
if (fs.existsSync(NFS_BACKUPS_DIR)) {
|
|
660
|
+
return NFS_BACKUP_INDEX;
|
|
661
|
+
}
|
|
662
|
+
} catch {}
|
|
663
|
+
return path.join(os.tmpdir(), 'deepdebug-backups-index.json');
|
|
664
|
+
})();
|
|
665
|
+
console.log(`[BACKUPS] Index path: ${BACKUP_INDEX_PATH}`);
|
|
626
666
|
|
|
627
667
|
/**
|
|
628
668
|
* Persist backup index to disk so diffs survive server restarts.
|
|
@@ -884,11 +924,15 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
884
924
|
const tenantId = body.tenantId;
|
|
885
925
|
const workspaceId = body.workspaceId;
|
|
886
926
|
const gitToken = body.gitToken;
|
|
887
|
-
|
|
927
|
+
// Accept gitBranch (onboarding frontend) OR branch (legacy)
|
|
928
|
+
const branch = body.branch || body.gitBranch;
|
|
929
|
+
// Accept gitProvider (onboarding frontend) for auth URL construction
|
|
930
|
+
const gitProvider = body.gitProvider || (gitUrl && gitUrl.includes('bitbucket') ? 'bitbucket' : gitUrl && gitUrl.includes('gitlab') ? 'gitlab' : 'github');
|
|
888
931
|
|
|
889
932
|
if (!gitUrl) return res.status(400).json({ ok: false, error: "gitUrl is required" });
|
|
890
933
|
|
|
891
|
-
|
|
934
|
+
// Strip ALL trailing .git suffixes (handles accidental double .git.git from frontend)
|
|
935
|
+
const repoName = gitUrl.split('/').pop().replace(/\.git$/i, '').replace(/\.git$/i, '');
|
|
892
936
|
|
|
893
937
|
const NFS_MOUNT = '/mnt/workspaces';
|
|
894
938
|
let absTarget;
|
|
@@ -903,17 +947,22 @@ app.post("/workspace/clone", async (req, res) => {
|
|
|
903
947
|
console.log(`[clone] Using fallback path: ${absTarget}`);
|
|
904
948
|
}
|
|
905
949
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
950
|
+
// Clean the gitUrl before using it (strip accidental double .git suffix)
|
|
951
|
+
const cleanGitUrl = gitUrl.replace(/\.git\.git$/i, '.git');
|
|
952
|
+
|
|
953
|
+
let authenticatedUrl = cleanGitUrl;
|
|
954
|
+
if (gitToken && cleanGitUrl.startsWith('https://') && !cleanGitUrl.includes('@')) {
|
|
955
|
+
const providerLower = (gitProvider || '').toLowerCase();
|
|
956
|
+
if (providerLower === 'bitbucket' || cleanGitUrl.includes('bitbucket.org')) {
|
|
957
|
+
authenticatedUrl = cleanGitUrl.replace('https://', `https://x-token-auth:${gitToken}@`);
|
|
958
|
+
} else if (providerLower === 'gitlab' || cleanGitUrl.includes('gitlab.com')) {
|
|
959
|
+
authenticatedUrl = cleanGitUrl.replace('https://', `https://oauth2:${gitToken}@`);
|
|
912
960
|
} else {
|
|
913
|
-
|
|
961
|
+
// github or any other provider
|
|
962
|
+
authenticatedUrl = cleanGitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
|
|
914
963
|
}
|
|
915
964
|
}
|
|
916
|
-
console.log(`Clone request: ${
|
|
965
|
+
console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
|
|
917
966
|
|
|
918
967
|
try {
|
|
919
968
|
// Ensure parent directory exists
|
|
@@ -1047,9 +1096,386 @@ app.get("/workspace/file-content", async (req, res) => {
|
|
|
1047
1096
|
}
|
|
1048
1097
|
});
|
|
1049
1098
|
|
|
1099
|
+
/**
|
|
1100
|
+
* POST /workspace/build-push
|
|
1101
|
+
* Compress workspace, upload to GCS, submit Cloud Build.
|
|
1102
|
+
* No Docker daemon needed — uses Cloud Build API + GCS.
|
|
1103
|
+
* Body: { workspaceId, imageName, gcpProject, saKeyJson }
|
|
1104
|
+
*/
|
|
1105
|
+
app.post("/workspace/build-push", async (req, res) => {
|
|
1106
|
+
const { workspaceId, imageName, gcpProject, saKeyJson } = req.body || {};
|
|
1107
|
+
if (!workspaceId || !imageName || !gcpProject || !saKeyJson) {
|
|
1108
|
+
return res.status(400).json({ error: "workspaceId, imageName, gcpProject and saKeyJson required" });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1112
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1113
|
+
res.setHeader("Connection", "keep-alive");
|
|
1114
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
1115
|
+
|
|
1116
|
+
const emit = (event, msg) => {
|
|
1117
|
+
const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
|
|
1118
|
+
res.write(`event:${event}\ndata:${data}\n\n`);
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
try {
|
|
1122
|
+
const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
|
|
1123
|
+
const wsPath = path.join(nfsBase, workspaceId);
|
|
1124
|
+
|
|
1125
|
+
if (!fs.existsSync(wsPath)) {
|
|
1126
|
+
emit("error", { error: "Workspace not found: " + wsPath });
|
|
1127
|
+
return res.end();
|
|
1128
|
+
}
|
|
1129
|
+
if (!fs.existsSync(path.join(wsPath, "Dockerfile"))) {
|
|
1130
|
+
emit("error", { error: "No Dockerfile in workspace. Run detect+generate first." });
|
|
1131
|
+
return res.end();
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Step 1: Get GCP access token from SA key
|
|
1135
|
+
emit("progress", "Authenticating with GCP...");
|
|
1136
|
+
let accessToken;
|
|
1137
|
+
try {
|
|
1138
|
+
const { createSign } = await import("crypto");
|
|
1139
|
+
const sa = JSON.parse(saKeyJson);
|
|
1140
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1141
|
+
const h = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
1142
|
+
const c = Buffer.from(JSON.stringify({
|
|
1143
|
+
iss: sa.client_email,
|
|
1144
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
1145
|
+
aud: "https://oauth2.googleapis.com/token",
|
|
1146
|
+
iat: now, exp: now + 3600
|
|
1147
|
+
})).toString("base64url");
|
|
1148
|
+
const sign = createSign("SHA256");
|
|
1149
|
+
sign.update(`${h}.${c}`);
|
|
1150
|
+
const sig = sign.sign(sa.private_key, "base64url");
|
|
1151
|
+
const jwt = `${h}.${c}.${sig}`;
|
|
1152
|
+
|
|
1153
|
+
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
|
|
1154
|
+
method: "POST",
|
|
1155
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1156
|
+
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`
|
|
1157
|
+
});
|
|
1158
|
+
const tokenData = await tokenResp.json();
|
|
1159
|
+
accessToken = tokenData.access_token;
|
|
1160
|
+
if (!accessToken) throw new Error("No access_token: " + JSON.stringify(tokenData));
|
|
1161
|
+
emit("progress", "GCP authentication successful");
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
emit("error", { error: "GCP auth failed: " + e.message });
|
|
1164
|
+
return res.end();
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Step 2: Compress workspace to tar.gz
|
|
1168
|
+
emit("progress", "Compressing source code...");
|
|
1169
|
+
const { execSync } = await import("child_process");
|
|
1170
|
+
const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
|
|
1171
|
+
try {
|
|
1172
|
+
execSync(
|
|
1173
|
+
`tar -czf "${tarPath}" --exclude=".git" --exclude="node_modules" ` +
|
|
1174
|
+
`--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
|
|
1175
|
+
`-C "${wsPath}" . 2>&1`,
|
|
1176
|
+
{ stdio: "pipe", timeout: 120000 }
|
|
1177
|
+
);
|
|
1178
|
+
const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
|
|
1179
|
+
emit("progress", `Source compressed: ${sizeKB}KB`);
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
emit("error", { error: "Compression failed: " + (e.message || "").substring(0, 200) });
|
|
1182
|
+
return res.end();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Step 3: Upload to GCS
|
|
1186
|
+
const bucketName = `${gcpProject}_cloudbuild`;
|
|
1187
|
+
const objectName = `source/${workspaceId}-${Date.now()}.tar.gz`;
|
|
1188
|
+
emit("progress", `Uploading source to GCS...`);
|
|
1189
|
+
try {
|
|
1190
|
+
// Create bucket (ignore if exists)
|
|
1191
|
+
await fetch(`https://storage.googleapis.com/storage/v1/b?project=${gcpProject}`, {
|
|
1192
|
+
method: "POST",
|
|
1193
|
+
headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
1194
|
+
body: JSON.stringify({ name: bucketName, location: "US" })
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
const fileBuffer = fs.readFileSync(tarPath);
|
|
1198
|
+
const uploadResp = await fetch(
|
|
1199
|
+
`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o?uploadType=media&name=${encodeURIComponent(objectName)}`,
|
|
1200
|
+
{
|
|
1201
|
+
method: "POST",
|
|
1202
|
+
headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/gzip" },
|
|
1203
|
+
body: fileBuffer
|
|
1204
|
+
}
|
|
1205
|
+
);
|
|
1206
|
+
if (!uploadResp.ok) {
|
|
1207
|
+
const errText = await uploadResp.text();
|
|
1208
|
+
throw new Error(`GCS upload ${uploadResp.status}: ${errText.substring(0, 200)}`);
|
|
1209
|
+
}
|
|
1210
|
+
emit("progress", "Source uploaded to GCS");
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
emit("error", { error: "GCS upload failed: " + e.message });
|
|
1213
|
+
return res.end();
|
|
1214
|
+
} finally {
|
|
1215
|
+
try { fs.unlinkSync(tarPath); } catch {}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Step 4: Submit Cloud Build
|
|
1219
|
+
emit("progress", "Submitting Cloud Build...");
|
|
1220
|
+
let buildId = "unknown";
|
|
1221
|
+
try {
|
|
1222
|
+
const buildReq = {
|
|
1223
|
+
source: { storageSource: { bucket: bucketName, object: objectName } },
|
|
1224
|
+
steps: [
|
|
1225
|
+
{ name: "gcr.io/cloud-builders/docker", args: ["build", "-t", imageName, "."], id: "build" },
|
|
1226
|
+
{ name: "gcr.io/cloud-builders/docker", args: ["push", imageName], id: "push", waitFor: ["build"] }
|
|
1227
|
+
],
|
|
1228
|
+
images: [imageName],
|
|
1229
|
+
timeout: "1200s"
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
const buildResp = await fetch(
|
|
1233
|
+
`https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds`,
|
|
1234
|
+
{
|
|
1235
|
+
method: "POST",
|
|
1236
|
+
headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
1237
|
+
body: JSON.stringify(buildReq)
|
|
1238
|
+
}
|
|
1239
|
+
);
|
|
1240
|
+
if (!buildResp.ok) {
|
|
1241
|
+
const errText = await buildResp.text();
|
|
1242
|
+
throw new Error(`Cloud Build ${buildResp.status}: ${errText.substring(0, 300)}`);
|
|
1243
|
+
}
|
|
1244
|
+
const buildData = await buildResp.json();
|
|
1245
|
+
buildId = buildData.metadata?.build?.id || buildData.name?.split("/").pop() || "unknown";
|
|
1246
|
+
emit("progress", `Cloud Build started: ${buildId}`);
|
|
1247
|
+
} catch (e) {
|
|
1248
|
+
emit("error", { error: "Cloud Build submit failed: " + e.message });
|
|
1249
|
+
return res.end();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Step 5: Poll build status
|
|
1253
|
+
emit("progress", "Building Docker image (3-5 minutes)...");
|
|
1254
|
+
let status = "WORKING";
|
|
1255
|
+
let attempts = 0;
|
|
1256
|
+
while (!["SUCCESS", "FAILURE", "CANCELLED", "TIMEOUT"].includes(status) && attempts < 40) {
|
|
1257
|
+
await new Promise(r => setTimeout(r, 15000));
|
|
1258
|
+
attempts++;
|
|
1259
|
+
try {
|
|
1260
|
+
const statusResp = await fetch(
|
|
1261
|
+
`https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds/${buildId}`,
|
|
1262
|
+
{ headers: { "Authorization": `Bearer ${accessToken}` } }
|
|
1263
|
+
);
|
|
1264
|
+
const statusData = await statusResp.json();
|
|
1265
|
+
status = statusData.status || "WORKING";
|
|
1266
|
+
emit("progress", `Build status: ${status} (${attempts}/40)`);
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
emit("progress", `Status check error: ${e.message}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (status === "SUCCESS") {
|
|
1273
|
+
emit("progress", "Docker image built and pushed successfully!");
|
|
1274
|
+
emit("complete", { success: true, imageName, buildId });
|
|
1275
|
+
} else {
|
|
1276
|
+
emit("error", { error: `Cloud Build ${status}. Check GCP Console.` });
|
|
1277
|
+
}
|
|
1278
|
+
res.end();
|
|
1279
|
+
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
console.error("[build-push] Fatal:", err.message);
|
|
1282
|
+
emit("error", { error: err.message });
|
|
1283
|
+
res.end();
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* POST /workspace/setup
|
|
1289
|
+
* AI Cloud Engineering Agent - autonomous setup:
|
|
1290
|
+
* 1. Clone repo 2. Detect stack 3. Generate Dockerfile 4. Register workspace
|
|
1291
|
+
* Returns SSE stream with real-time progress.
|
|
1292
|
+
*/
|
|
1293
|
+
app.post("/workspace/setup", async (req, res) => {
|
|
1294
|
+
const { workspaceId, repoUrl, gitToken, provider = "deepdebug_cloud",
|
|
1295
|
+
cloudProjectId, cloudToken } = req.body || {};
|
|
1296
|
+
|
|
1297
|
+
if (!workspaceId || !repoUrl) {
|
|
1298
|
+
return res.status(400).json({ error: "workspaceId and repoUrl required" });
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1302
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1303
|
+
res.setHeader("Connection", "keep-alive");
|
|
1304
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
1305
|
+
|
|
1306
|
+
const emit = (event, msg) => {
|
|
1307
|
+
const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
|
|
1308
|
+
res.write(`event:${event}\ndata:${data}\n\n`);
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
try {
|
|
1312
|
+
// Use /mnt/workspaces as base (not WORKSPACE_ROOT which is a specific workspace)
|
|
1313
|
+
const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
|
|
1314
|
+
const wsPath = path.join(nfsBase, workspaceId);
|
|
1315
|
+
|
|
1316
|
+
emit("progress", `Starting setup for workspace: ${workspaceId}`);
|
|
1317
|
+
emit("progress", `Workspace path: ${wsPath}`);
|
|
1318
|
+
|
|
1319
|
+
// STEP 1 - Clone
|
|
1320
|
+
emit("progress", "Cloning repository...");
|
|
1321
|
+
let authUrl = repoUrl;
|
|
1322
|
+
if (gitToken && gitToken.length > 5) {
|
|
1323
|
+
// Build authenticated URL
|
|
1324
|
+
try {
|
|
1325
|
+
const urlObj = new URL(repoUrl);
|
|
1326
|
+
urlObj.username = "x-access-token";
|
|
1327
|
+
urlObj.password = gitToken;
|
|
1328
|
+
authUrl = urlObj.toString();
|
|
1329
|
+
emit("progress", `Using authenticated clone for ${urlObj.hostname}`);
|
|
1330
|
+
} catch {
|
|
1331
|
+
authUrl = repoUrl.replace("https://", `https://x-access-token:${gitToken}@`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const { execSync } = await import("child_process");
|
|
1336
|
+
const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
|
|
1337
|
+
const execOpts = { stdio: "pipe", timeout: 300000, encoding: "utf8", env: cloneEnv };
|
|
1338
|
+
let cloneBranch = "main";
|
|
1339
|
+
|
|
1340
|
+
// Configure safe directory
|
|
1341
|
+
try { execSync(`git config --global --add safe.directory "${wsPath}"`, execOpts); } catch {}
|
|
1342
|
+
|
|
1343
|
+
if (fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1344
|
+
emit("progress", "Workspace exists, updating...");
|
|
1345
|
+
try {
|
|
1346
|
+
if (gitToken && gitToken.length > 5) {
|
|
1347
|
+
execSync(`git remote set-url origin "${authUrl}"`, { ...execOpts, cwd: wsPath });
|
|
1348
|
+
}
|
|
1349
|
+
execSync("git fetch origin", { ...execOpts, cwd: wsPath });
|
|
1350
|
+
cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
|
|
1351
|
+
execSync(`git reset --hard origin/${cloneBranch}`, { ...execOpts, cwd: wsPath });
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
const safeMsg = gitToken ? e.message.replace(gitToken, "***") : e.message;
|
|
1354
|
+
emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
|
|
1355
|
+
fs.rmSync(wsPath, { recursive: true, force: true });
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (!fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1360
|
+
// Remove existing empty dir before clone (git clone needs non-existent or empty target)
|
|
1361
|
+
if (fs.existsSync(wsPath)) {
|
|
1362
|
+
const contents = fs.readdirSync(wsPath);
|
|
1363
|
+
if (contents.length === 0) fs.rmSync(wsPath, { recursive: true });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
for (const b of ["main", "master", "develop"]) {
|
|
1367
|
+
try {
|
|
1368
|
+
emit("progress", `Trying branch: ${b}...`);
|
|
1369
|
+
execSync(`git clone --depth=1 --branch ${b} "${authUrl}" "${wsPath}"`, execOpts);
|
|
1370
|
+
cloneBranch = b;
|
|
1371
|
+
emit("progress", `Cloned on branch: ${b}`);
|
|
1372
|
+
break;
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
const stderr = (e.stderr || e.stdout || e.message || "").toString();
|
|
1375
|
+
const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
|
|
1376
|
+
console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
|
|
1377
|
+
emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
|
|
1378
|
+
// Clean up partial clone
|
|
1379
|
+
if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Fallback: clone without specifying branch
|
|
1384
|
+
if (!fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1385
|
+
try {
|
|
1386
|
+
emit("progress", "Trying default branch...");
|
|
1387
|
+
execSync(`git clone --depth=1 "${authUrl}" "${wsPath}" 2>&1`, execOpts);
|
|
1388
|
+
cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
|
|
1389
|
+
emit("progress", `Cloned on default branch: ${cloneBranch}`);
|
|
1390
|
+
} catch (e) {
|
|
1391
|
+
const stderr = (e.stderr || e.stdout || e.message || "").toString();
|
|
1392
|
+
const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
|
|
1393
|
+
console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
|
|
1394
|
+
emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
|
|
1395
|
+
if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (!fs.existsSync(path.join(wsPath, ".git"))) {
|
|
1401
|
+
emit("error", { error: "Clone failed for all branches. Check repo URL and permissions." });
|
|
1402
|
+
return res.end();
|
|
1403
|
+
}
|
|
1404
|
+
emit("progress", `Repository cloned on branch: ${cloneBranch}`);
|
|
1405
|
+
|
|
1406
|
+
// STEP 2 - Detect stack
|
|
1407
|
+
emit("progress", "AI analysing project structure...");
|
|
1408
|
+
const meta = await detectProject(wsPath);
|
|
1409
|
+
emit("progress", `Stack: ${meta.language} / ${meta.buildTool} / ${meta.framework || "unknown"}`);
|
|
1410
|
+
|
|
1411
|
+
// STEP 3 - Generate Dockerfile if missing
|
|
1412
|
+
const dockerfilePath = path.join(wsPath, "Dockerfile");
|
|
1413
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
1414
|
+
emit("progress", `Generating Dockerfile for ${meta.language}...`);
|
|
1415
|
+
const content = generateDockerfileContent(meta.language, meta.buildTool);
|
|
1416
|
+
fs.writeFileSync(dockerfilePath, content, "utf8");
|
|
1417
|
+
emit("progress", "Dockerfile generated");
|
|
1418
|
+
} else {
|
|
1419
|
+
emit("progress", "Dockerfile already exists");
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// STEP 4 - Register
|
|
1423
|
+
emit("progress", `Provider: ${provider}. Workspace ready.`);
|
|
1424
|
+
|
|
1425
|
+
emit("progress", "AI Cloud Engineering setup complete!");
|
|
1426
|
+
emit("complete", {
|
|
1427
|
+
success: true, workspaceId, wsPath, provider, branch: cloneBranch,
|
|
1428
|
+
stack: { language: meta.language, buildTool: meta.buildTool, framework: meta.framework },
|
|
1429
|
+
message: "Setup complete. Monitoring is now active."
|
|
1430
|
+
});
|
|
1431
|
+
res.end();
|
|
1432
|
+
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
console.error("[Setup] Error:", err.message);
|
|
1435
|
+
emit("error", { error: err.message });
|
|
1436
|
+
res.end();
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
function generateDockerfileContent(language, buildTool) {
|
|
1441
|
+
if (language === "java" && (buildTool === "maven" || buildTool === "mvn"))
|
|
1442
|
+
return `FROM maven:3.9-eclipse-temurin-17 AS build\nWORKDIR /app\nCOPY pom.xml .\nRUN mvn dependency:go-offline -B\nCOPY src ./src\nRUN mvn clean package -DskipTests -B\nFROM eclipse-temurin:17-jre-jammy\nWORKDIR /app\nCOPY --from=build /app/target/*.jar app.jar\nEXPOSE 8080\nENTRYPOINT ["java", "-jar", "app.jar"]\n`;
|
|
1443
|
+
if (language === "java")
|
|
1444
|
+
return `FROM gradle:8-jdk17 AS build\nWORKDIR /app\nCOPY . .\nRUN gradle build -x test\nFROM eclipse-temurin:17-jre-jammy\nWORKDIR /app\nCOPY --from=build /app/build/libs/*.jar app.jar\nEXPOSE 8080\nENTRYPOINT ["java", "-jar", "app.jar"]\n`;
|
|
1445
|
+
if (language === "node" || language === "javascript" || language === "typescript")
|
|
1446
|
+
return `FROM node:20-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nEXPOSE 8080\nENV PORT=8080\nCMD ["node", "src/index.js"]\n`;
|
|
1447
|
+
if (language === "python")
|
|
1448
|
+
return `FROM python:3.11-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8080\nCMD ["python", "app.py"]\n`;
|
|
1449
|
+
if (language === "go")
|
|
1450
|
+
return `FROM golang:1.21-alpine AS build\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -o main .\nFROM alpine:3.18\nCOPY --from=build /app/main /app/main\nEXPOSE 8080\nCMD ["/app/main"]\n`;
|
|
1451
|
+
return `FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .\nEXPOSE 8080\nCMD ["./start.sh"]\n`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Detect project stack from files (pom.xml, package.json, etc.) */
|
|
1455
|
+
app.post("/workspace/detect", async (req, res) => {
|
|
1456
|
+
const wsRoot = resolveWorkspaceRoot(req);
|
|
1457
|
+
if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
|
|
1458
|
+
try {
|
|
1459
|
+
const meta = await detectProject(wsRoot);
|
|
1460
|
+
const fs = await import("fs");
|
|
1461
|
+
const path = await import("path");
|
|
1462
|
+
res.json({
|
|
1463
|
+
ok: true,
|
|
1464
|
+
language: meta.language || "unknown",
|
|
1465
|
+
buildTool: meta.buildTool || "unknown",
|
|
1466
|
+
framework: meta.framework || "unknown",
|
|
1467
|
+
hasDockerfile: fs.existsSync(path.join(wsRoot, "Dockerfile")),
|
|
1468
|
+
path: wsRoot
|
|
1469
|
+
});
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
res.json({ ok: false, language: "unknown", buildTool: "unknown",
|
|
1472
|
+
framework: "unknown", error: e.message });
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1050
1476
|
/** Escreve/salva conteudo de arquivo no workspace */
|
|
1051
1477
|
app.post("/workspace/write-file", async (req, res) => {
|
|
1052
|
-
const workspaceRoot =
|
|
1478
|
+
const workspaceRoot = resolveWorkspaceRoot(req);
|
|
1053
1479
|
if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
|
|
1054
1480
|
|
|
1055
1481
|
const { path: relativePath, content: fileContent } = req.body || {};
|
|
@@ -1653,7 +2079,9 @@ app.post("/workspace/write", async (req, res) => {
|
|
|
1653
2079
|
const { path: rel, content } = req.body || {};
|
|
1654
2080
|
if (!rel) return res.status(400).json({ error: "path is required" });
|
|
1655
2081
|
try {
|
|
1656
|
-
|
|
2082
|
+
const fullPath = path.join(wsRoot, rel);
|
|
2083
|
+
await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2084
|
+
await writeFile(fullPath, content ?? "", "utf8");
|
|
1657
2085
|
res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
|
|
1658
2086
|
} catch (e) {
|
|
1659
2087
|
res.status(400).json({ error: "write failed", details: String(e) });
|
|
@@ -1775,6 +2203,85 @@ app.get("/workspace/test-local/state", async (req, res) => {
|
|
|
1775
2203
|
});
|
|
1776
2204
|
});
|
|
1777
2205
|
|
|
2206
|
+
/**
|
|
2207
|
+
* POST /workspace/git/prepare
|
|
2208
|
+
* Prepares a clean workspace for a new fix:
|
|
2209
|
+
* 1. Detects main branch (main, master, develop)
|
|
2210
|
+
* 2. git fetch origin
|
|
2211
|
+
* 3. git checkout main branch
|
|
2212
|
+
* 4. git reset --hard origin/main branch
|
|
2213
|
+
*/
|
|
2214
|
+
app.post("/workspace/git/prepare", async (req, res) => {
|
|
2215
|
+
const wsRoot = resolveWorkspaceRoot(req);
|
|
2216
|
+
if (!wsRoot) {
|
|
2217
|
+
return res.status(400).json({ ok: false, error: "workspace not set" });
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
const { gitToken, repoUrl } = req.body || {};
|
|
2221
|
+
console.log("[git-prepare] Starting workspace preparation...");
|
|
2222
|
+
|
|
2223
|
+
try {
|
|
2224
|
+
const { execSync } = await import("child_process");
|
|
2225
|
+
const execGit = (cmd) => {
|
|
2226
|
+
try {
|
|
2227
|
+
return { ok: true, output: execSync(cmd, {
|
|
2228
|
+
cwd: wsRoot, encoding: "utf8", timeout: 60000,
|
|
2229
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
2230
|
+
}).trim() };
|
|
2231
|
+
} catch (err) {
|
|
2232
|
+
return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
execGit(`git config --global --add safe.directory "${wsRoot}"`);
|
|
2237
|
+
|
|
2238
|
+
if (gitToken && repoUrl) {
|
|
2239
|
+
const urlWithToken = repoUrl.replace("https://", `https://x-access-token:${gitToken}@`);
|
|
2240
|
+
execGit(`git remote set-url origin "${urlWithToken}"`);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
console.log("[git-prepare] Fetching origin...");
|
|
2244
|
+
execGit("git fetch origin --prune");
|
|
2245
|
+
|
|
2246
|
+
let mainBranch = null;
|
|
2247
|
+
for (const branch of ["main", "master", "develop", "dev"]) {
|
|
2248
|
+
if (execGit(`git rev-parse --verify origin/${branch}`).ok) {
|
|
2249
|
+
mainBranch = branch;
|
|
2250
|
+
break;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
if (!mainBranch) {
|
|
2254
|
+
const cur = execGit("git rev-parse --abbrev-ref HEAD");
|
|
2255
|
+
mainBranch = cur.ok ? cur.output : "main";
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const prevBranch = (execGit("git rev-parse --abbrev-ref HEAD").output || "unknown");
|
|
2259
|
+
const prevCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
|
|
2260
|
+
|
|
2261
|
+
console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
|
|
2262
|
+
|
|
2263
|
+
let checkout = execGit(`git checkout ${mainBranch}`);
|
|
2264
|
+
if (!checkout.ok) checkout = execGit(`git checkout -f ${mainBranch}`);
|
|
2265
|
+
if (!checkout.ok) {
|
|
2266
|
+
return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
|
|
2267
|
+
mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
execGit(`git reset --hard origin/${mainBranch}`);
|
|
2271
|
+
execGit('git clean -fd --exclude="*.env" --exclude=".env*"');
|
|
2272
|
+
|
|
2273
|
+
const newCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
|
|
2274
|
+
console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit}`);
|
|
2275
|
+
|
|
2276
|
+
res.json({ ok: true, mainBranch, currentCommit: newCommit,
|
|
2277
|
+
previousBranch: prevBranch, previousCommit: prevCommit,
|
|
2278
|
+
message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
console.error("[git-prepare] Error:", err.message);
|
|
2281
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
|
|
1778
2285
|
/**
|
|
1779
2286
|
* POST /workspace/test-local/compile
|
|
1780
2287
|
* Compiles the project without starting server
|
|
@@ -1795,23 +2302,29 @@ app.post("/workspace/test-local/compile", async (req, res) => {
|
|
|
1795
2302
|
skipTests: true
|
|
1796
2303
|
});
|
|
1797
2304
|
|
|
1798
|
-
|
|
1799
|
-
|
|
2305
|
+
// Support both formats:
|
|
2306
|
+
// - new format: { success, code, steps[] } from updated exec-utils.js
|
|
2307
|
+
// - legacy format: { code, stdout, stderr } from older exec-utils.js
|
|
2308
|
+
const isSuccess = compileResult.success !== undefined
|
|
2309
|
+
? compileResult.success
|
|
2310
|
+
: compileResult.code === 0;
|
|
1800
2311
|
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
.join('\n')
|
|
1805
|
-
.trim();
|
|
2312
|
+
const errorOutput = compileResult.steps
|
|
2313
|
+
? compileResult.steps.map(s => s.stderr || '').filter(Boolean).join('\n').trim()
|
|
2314
|
+
: (compileResult.stderr || '');
|
|
1806
2315
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
.join('\n')
|
|
1811
|
-
.trim();
|
|
2316
|
+
const stdoutOutput = compileResult.steps
|
|
2317
|
+
? compileResult.steps.map(s => s.stdout || '').filter(Boolean).join('\n').trim()
|
|
2318
|
+
: (compileResult.stdout || '');
|
|
1812
2319
|
|
|
1813
|
-
|
|
1814
|
-
|
|
2320
|
+
const totalDuration = compileResult.steps
|
|
2321
|
+
? compileResult.steps.reduce((sum, s) => sum + (s.duration || 0), 0)
|
|
2322
|
+
: (compileResult.duration || 0);
|
|
2323
|
+
|
|
2324
|
+
if (!isSuccess) {
|
|
2325
|
+
TEST_LOCAL_STATE.status = "error";
|
|
2326
|
+
console.error("[TEST-LOCAL] Compilation failed. stderr:", errorOutput.substring(0, 500));
|
|
2327
|
+
console.error("[TEST-LOCAL] Compilation failed. stdout:", stdoutOutput.substring(0, 500));
|
|
1815
2328
|
|
|
1816
2329
|
TEST_LOCAL_STATE.compilationResult = {
|
|
1817
2330
|
success: false,
|
|
@@ -1828,9 +2341,6 @@ app.post("/workspace/test-local/compile", async (req, res) => {
|
|
|
1828
2341
|
});
|
|
1829
2342
|
}
|
|
1830
2343
|
|
|
1831
|
-
const totalDuration = (compileResult.steps || [])
|
|
1832
|
-
.reduce((sum, s) => sum + (s.duration || 0), 0);
|
|
1833
|
-
|
|
1834
2344
|
TEST_LOCAL_STATE.status = "compiled";
|
|
1835
2345
|
TEST_LOCAL_STATE.compilationResult = {
|
|
1836
2346
|
success: true,
|
|
@@ -1846,7 +2356,7 @@ app.post("/workspace/test-local/compile", async (req, res) => {
|
|
|
1846
2356
|
language: meta.language,
|
|
1847
2357
|
buildTool: meta.buildTool,
|
|
1848
2358
|
duration: totalDuration,
|
|
1849
|
-
stdout:
|
|
2359
|
+
stdout: stdoutOutput
|
|
1850
2360
|
});
|
|
1851
2361
|
} catch (err) {
|
|
1852
2362
|
console.error("[TEST-LOCAL] Compilation failed:", err.message);
|
|
@@ -2367,8 +2877,9 @@ app.post("/workspace/safe-patch", async (req, res) => {
|
|
|
2367
2877
|
incidentId: incidentId || null
|
|
2368
2878
|
});
|
|
2369
2879
|
|
|
2370
|
-
// Save backup files to
|
|
2371
|
-
const
|
|
2880
|
+
// Save backup files to NFS Filestore for persistence across container restarts
|
|
2881
|
+
const backupsBase = fs.existsSync(NFS_BACKUPS_DIR) ? path.join(NFS_BACKUPS_DIR, '.deepdebug-backups') : path.join(os.tmpdir(), 'deepdebug-backups');
|
|
2882
|
+
const backupDir = path.join(backupsBase, backupId);
|
|
2372
2883
|
try {
|
|
2373
2884
|
await fsPromises.mkdir(backupDir, { recursive: true });
|
|
2374
2885
|
for (const file of backupFiles) {
|
|
@@ -5858,14 +6369,24 @@ app.post("/workspace/test-local/run-single", async (req, res) => {
|
|
|
5858
6369
|
try {
|
|
5859
6370
|
const meta = await detectProject(wsRoot);
|
|
5860
6371
|
|
|
6372
|
+
// Build JAVA_HOME env (same fix as compile endpoint)
|
|
6373
|
+
const javaHome = process.env.JAVA_HOME || "/usr/local/java-home";
|
|
6374
|
+
const mavenEnv = {
|
|
6375
|
+
...process.env,
|
|
6376
|
+
JAVA_HOME: javaHome,
|
|
6377
|
+
PATH: `${javaHome}/bin:${process.env.PATH}`
|
|
6378
|
+
};
|
|
6379
|
+
|
|
5861
6380
|
let result;
|
|
5862
6381
|
|
|
5863
6382
|
if (meta.buildTool === 'maven') {
|
|
5864
|
-
//
|
|
5865
|
-
|
|
6383
|
+
// Prefer ./mvnw wrapper, pass JAVA_HOME env
|
|
6384
|
+
const hasMvnw = fs.existsSync(path.join(wsRoot, 'mvnw'));
|
|
6385
|
+
const mvnCmd = hasMvnw ? './mvnw' : 'mvn';
|
|
6386
|
+
result = await run(mvnCmd, ['test', `-Dtest=${resolvedClass}`, '-DskipTests=false'], wsRoot, 3 * 60 * 1000, mavenEnv);
|
|
5866
6387
|
} else if (meta.buildTool === 'gradle') {
|
|
5867
6388
|
const gradleCmd = fs.existsSync(path.join(wsRoot, 'gradlew')) ? './gradlew' : 'gradle';
|
|
5868
|
-
result = await run(gradleCmd, ['test', '--tests', `*.${resolvedClass}`], wsRoot);
|
|
6389
|
+
result = await run(gradleCmd, ['test', '--tests', `*.${resolvedClass}`], wsRoot, 3 * 60 * 1000, mavenEnv);
|
|
5869
6390
|
} else if (meta.language === 'node') {
|
|
5870
6391
|
result = await run('npx', ['jest', resolvedClass, '--no-coverage'], wsRoot);
|
|
5871
6392
|
} else {
|
|
@@ -5931,6 +6452,320 @@ app.post("/workspace/test-local/run-single", async (req, res) => {
|
|
|
5931
6452
|
}
|
|
5932
6453
|
});
|
|
5933
6454
|
|
|
6455
|
+
// ============================================
|
|
6456
|
+
// SPRINT 1 - S1-T4: Session Isolation via Git Worktree
|
|
6457
|
+
// ============================================
|
|
6458
|
+
|
|
6459
|
+
/**
|
|
6460
|
+
* POST /workspace/session/create
|
|
6461
|
+
*
|
|
6462
|
+
* Creates an isolated git worktree for a session.
|
|
6463
|
+
* Each session gets its own directory and branch - zero collision between parallel sessions.
|
|
6464
|
+
*
|
|
6465
|
+
* Body: { sessionId, branchName, tenantId }
|
|
6466
|
+
* Response: { ok, sessionId, worktreePath, branchName }
|
|
6467
|
+
*/
|
|
6468
|
+
app.post("/workspace/session/create", async (req, res) => {
|
|
6469
|
+
const wsRoot = resolveWorkspaceRoot(req);
|
|
6470
|
+
if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
|
|
6471
|
+
|
|
6472
|
+
const { sessionId, branchName, tenantId } = req.body || {};
|
|
6473
|
+
if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId is required" });
|
|
6474
|
+
|
|
6475
|
+
const safeBranch = (branchName || `session-${sessionId}`).replace(/[^a-zA-Z0-9/_-]/g, "-").toLowerCase();
|
|
6476
|
+
const tenantPart = tenantId || path.basename(path.dirname(wsRoot));
|
|
6477
|
+
|
|
6478
|
+
// Sessions dir: /mnt/workspaces/{tenantId}/sessions/{sessionId}
|
|
6479
|
+
const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
|
|
6480
|
+
const worktreePath = path.join(sessionsBase, sessionId);
|
|
6481
|
+
|
|
6482
|
+
console.log(`[SESSION] Creating worktree: ${worktreePath} (branch: ${safeBranch})`);
|
|
6483
|
+
|
|
6484
|
+
try {
|
|
6485
|
+
await fsPromises.mkdir(sessionsBase, { recursive: true });
|
|
6486
|
+
|
|
6487
|
+
// Remove stale worktree if exists
|
|
6488
|
+
if (fs.existsSync(worktreePath)) {
|
|
6489
|
+
console.log(`[SESSION] Removing stale worktree: ${worktreePath}`);
|
|
6490
|
+
await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
|
|
6491
|
+
await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
|
|
6492
|
+
}
|
|
6493
|
+
|
|
6494
|
+
// Create new worktree with new branch
|
|
6495
|
+
await execAsync(`git worktree add "${worktreePath}" -b "${safeBranch}"`, { cwd: wsRoot });
|
|
6496
|
+
|
|
6497
|
+
console.log(`[SESSION] Worktree created: ${worktreePath}`);
|
|
6498
|
+
return res.json({
|
|
6499
|
+
ok: true,
|
|
6500
|
+
sessionId,
|
|
6501
|
+
worktreePath,
|
|
6502
|
+
branchName: safeBranch
|
|
6503
|
+
});
|
|
6504
|
+
} catch (err) {
|
|
6505
|
+
console.error(`[SESSION] Failed to create worktree: ${err.message}`);
|
|
6506
|
+
// Fallback: return main workspace path (no isolation but no crash)
|
|
6507
|
+
return res.status(500).json({
|
|
6508
|
+
ok: false,
|
|
6509
|
+
sessionId,
|
|
6510
|
+
worktreePath: wsRoot,
|
|
6511
|
+
branchName: safeBranch,
|
|
6512
|
+
error: err.message,
|
|
6513
|
+
fallback: true
|
|
6514
|
+
});
|
|
6515
|
+
}
|
|
6516
|
+
});
|
|
6517
|
+
|
|
6518
|
+
/**
|
|
6519
|
+
* DELETE /workspace/session/:sessionId
|
|
6520
|
+
*
|
|
6521
|
+
* Removes a session worktree and its branch.
|
|
6522
|
+
* Called after PR is created or session times out.
|
|
6523
|
+
*
|
|
6524
|
+
* Query: ?tenantId=xxx
|
|
6525
|
+
*/
|
|
6526
|
+
app.delete("/workspace/session/:sessionId", async (req, res) => {
|
|
6527
|
+
const wsRoot = resolveWorkspaceRoot(req);
|
|
6528
|
+
if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
|
|
6529
|
+
|
|
6530
|
+
const { sessionId } = req.params;
|
|
6531
|
+
const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
|
|
6532
|
+
const worktreePath = path.join(sessionsBase, sessionId);
|
|
6533
|
+
|
|
6534
|
+
console.log(`[SESSION] Removing worktree: ${worktreePath}`);
|
|
6535
|
+
|
|
6536
|
+
try {
|
|
6537
|
+
if (fs.existsSync(worktreePath)) {
|
|
6538
|
+
await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
|
|
6539
|
+
await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
|
|
6540
|
+
}
|
|
6541
|
+
|
|
6542
|
+
// Clean up branch
|
|
6543
|
+
const branchName = `session-${sessionId}`;
|
|
6544
|
+
await execAsync(`git branch -D "${branchName}"`, { cwd: wsRoot }).catch(() => {});
|
|
6545
|
+
|
|
6546
|
+
console.log(`[SESSION] Worktree removed: ${worktreePath}`);
|
|
6547
|
+
return res.json({ ok: true, sessionId, removed: worktreePath });
|
|
6548
|
+
} catch (err) {
|
|
6549
|
+
console.error(`[SESSION] Failed to remove worktree: ${err.message}`);
|
|
6550
|
+
return res.status(500).json({ ok: false, sessionId, error: err.message });
|
|
6551
|
+
}
|
|
6552
|
+
});
|
|
6553
|
+
|
|
6554
|
+
/**
|
|
6555
|
+
* GET /workspace/session/:sessionId/status
|
|
6556
|
+
*
|
|
6557
|
+
* Returns status of a session worktree.
|
|
6558
|
+
*/
|
|
6559
|
+
app.get("/workspace/session/:sessionId/status", async (req, res) => {
|
|
6560
|
+
const wsRoot = resolveWorkspaceRoot(req);
|
|
6561
|
+
if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
|
|
6562
|
+
|
|
6563
|
+
const { sessionId } = req.params;
|
|
6564
|
+
const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
|
|
6565
|
+
const worktreePath = path.join(sessionsBase, sessionId);
|
|
6566
|
+
const exists = fs.existsSync(worktreePath);
|
|
6567
|
+
|
|
6568
|
+
return res.json({
|
|
6569
|
+
ok: true,
|
|
6570
|
+
sessionId,
|
|
6571
|
+
worktreePath,
|
|
6572
|
+
exists
|
|
6573
|
+
});
|
|
6574
|
+
});
|
|
6575
|
+
|
|
6576
|
+
|
|
6577
|
+
// ============================================
|
|
6578
|
+
// FASE 5 - ENV MANAGER + PREVIEW TUNNEL
|
|
6579
|
+
// State: active vibe environments per sessionId
|
|
6580
|
+
// ============================================
|
|
6581
|
+
const vibeEnvs = new Map(); // sessionId -> { port, pid, serviceId, tunnelUrl, tunnelPid, worktreePath, status }
|
|
6582
|
+
|
|
6583
|
+
function findFreePort(min = 9000, max = 9999) {
|
|
6584
|
+
const used = new Set([...vibeEnvs.values()].map(e => e.port));
|
|
6585
|
+
for (let p = min; p <= max; p++) {
|
|
6586
|
+
if (!used.has(p)) return p;
|
|
6587
|
+
}
|
|
6588
|
+
throw new Error('No free ports available in range 9000-9999');
|
|
6589
|
+
}
|
|
6590
|
+
|
|
6591
|
+
/**
|
|
6592
|
+
* POST /workspace/vibe/env/start
|
|
6593
|
+
*
|
|
6594
|
+
* Compiles (if needed) and starts the service on a dynamic port.
|
|
6595
|
+
* Body: { sessionId, worktreePath }
|
|
6596
|
+
* Response: { ok, port, serviceId, readyUrl }
|
|
6597
|
+
*/
|
|
6598
|
+
app.post("/workspace/vibe/env/start", async (req, res) => {
|
|
6599
|
+
const { sessionId, worktreePath } = req.body || {};
|
|
6600
|
+
if (!sessionId || !worktreePath) {
|
|
6601
|
+
return res.status(400).json({ ok: false, error: "sessionId and worktreePath required" });
|
|
6602
|
+
}
|
|
6603
|
+
if (vibeEnvs.has(sessionId)) {
|
|
6604
|
+
const env = vibeEnvs.get(sessionId);
|
|
6605
|
+
return res.json({ ok: true, port: env.port, serviceId: env.serviceId, readyUrl: `http://localhost:${env.port}`, alreadyRunning: true });
|
|
6606
|
+
}
|
|
6607
|
+
try {
|
|
6608
|
+
const port = findFreePort();
|
|
6609
|
+
const serviceId = `vibe-${sessionId}`;
|
|
6610
|
+
const meta = await detectProject(worktreePath);
|
|
6611
|
+
let startConfig;
|
|
6612
|
+
if (meta.language === 'java') {
|
|
6613
|
+
const targetDir = path.join(worktreePath, 'target');
|
|
6614
|
+
let jarPath = null;
|
|
6615
|
+
if (fs.existsSync(targetDir)) {
|
|
6616
|
+
const jars = fs.readdirSync(targetDir).filter(f =>
|
|
6617
|
+
f.endsWith('.jar') && !f.endsWith('.original') &&
|
|
6618
|
+
!f.includes('-sources') && !f.includes('-javadoc'));
|
|
6619
|
+
if (jars.length > 0) jarPath = path.join(targetDir, jars[0]);
|
|
6620
|
+
}
|
|
6621
|
+
if (!jarPath) {
|
|
6622
|
+
return res.status(400).json({ ok: false, error: "No JAR found. Run compile_project first." });
|
|
6623
|
+
}
|
|
6624
|
+
const cleanEnv = { ...process.env };
|
|
6625
|
+
Object.keys(cleanEnv).forEach(k => { if (k.startsWith('SPRING_')) delete cleanEnv[k]; });
|
|
6626
|
+
cleanEnv.SERVER_PORT = String(port);
|
|
6627
|
+
cleanEnv.PORT = String(port);
|
|
6628
|
+
startConfig = { command: 'java', args: ['-jar', jarPath, `--server.port=${port}`], cwd: worktreePath, port, env: cleanEnv };
|
|
6629
|
+
} else if (meta.language === 'node' || meta.language === 'javascript') {
|
|
6630
|
+
startConfig = { command: 'node', args: ['index.js'], cwd: worktreePath, port, env: { ...process.env, PORT: String(port) } };
|
|
6631
|
+
} else {
|
|
6632
|
+
return res.status(400).json({ ok: false, error: `Unsupported language: ${meta.language}` });
|
|
6633
|
+
}
|
|
6634
|
+
vibeEnvs.set(sessionId, { port, serviceId, tunnelUrl: null, tunnelPid: null, worktreePath, status: 'starting' });
|
|
6635
|
+
await processManager.start(serviceId, startConfig);
|
|
6636
|
+
vibeEnvs.get(sessionId).status = 'running';
|
|
6637
|
+
console.log(`[VIBE-ENV] Started ${serviceId} on port ${port}`);
|
|
6638
|
+
return res.json({ ok: true, port, serviceId, readyUrl: `http://localhost:${port}` });
|
|
6639
|
+
} catch (err) {
|
|
6640
|
+
console.error(`[VIBE-ENV] Start failed: ${err.message}`);
|
|
6641
|
+
vibeEnvs.delete(sessionId);
|
|
6642
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
6643
|
+
}
|
|
6644
|
+
});
|
|
6645
|
+
|
|
6646
|
+
/**
|
|
6647
|
+
* POST /workspace/vibe/env/stop
|
|
6648
|
+
*
|
|
6649
|
+
* Stops the service and tunnel for a session.
|
|
6650
|
+
* Body: { sessionId }
|
|
6651
|
+
*/
|
|
6652
|
+
app.post("/workspace/vibe/env/stop", async (req, res) => {
|
|
6653
|
+
const { sessionId } = req.body || {};
|
|
6654
|
+
if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
|
|
6655
|
+
const env = vibeEnvs.get(sessionId);
|
|
6656
|
+
if (!env) return res.json({ ok: true, message: "Not running" });
|
|
6657
|
+
try {
|
|
6658
|
+
await processManager.stop(env.serviceId);
|
|
6659
|
+
if (env.tunnelPid) {
|
|
6660
|
+
try { process.kill(env.tunnelPid, 'SIGTERM'); } catch (_) {}
|
|
6661
|
+
}
|
|
6662
|
+
vibeEnvs.delete(sessionId);
|
|
6663
|
+
console.log(`[VIBE-ENV] Stopped ${env.serviceId}`);
|
|
6664
|
+
return res.json({ ok: true });
|
|
6665
|
+
} catch (err) {
|
|
6666
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
6667
|
+
}
|
|
6668
|
+
});
|
|
6669
|
+
|
|
6670
|
+
/**
|
|
6671
|
+
* GET /workspace/vibe/env/status/:sessionId
|
|
6672
|
+
*
|
|
6673
|
+
* Returns current status of a vibe environment.
|
|
6674
|
+
*/
|
|
6675
|
+
app.get("/workspace/vibe/env/status/:sessionId", async (req, res) => {
|
|
6676
|
+
const { sessionId } = req.params;
|
|
6677
|
+
const env = vibeEnvs.get(sessionId);
|
|
6678
|
+
if (!env) return res.json({ ok: true, status: 'stopped', sessionId });
|
|
6679
|
+
return res.json({ ok: true, sessionId, status: env.status, port: env.port, serviceId: env.serviceId, tunnelUrl: env.tunnelUrl, readyUrl: `http://localhost:${env.port}` });
|
|
6680
|
+
});
|
|
6681
|
+
|
|
6682
|
+
/**
|
|
6683
|
+
* POST /workspace/vibe/tunnel/start
|
|
6684
|
+
*
|
|
6685
|
+
* Starts a cloudflared tunnel for a vibe environment.
|
|
6686
|
+
* Body: { sessionId }
|
|
6687
|
+
* Response: { ok, tunnelUrl }
|
|
6688
|
+
*
|
|
6689
|
+
* Downloads cloudflared binary automatically if not present.
|
|
6690
|
+
*/
|
|
6691
|
+
app.post("/workspace/vibe/tunnel/start", async (req, res) => {
|
|
6692
|
+
const { sessionId } = req.body || {};
|
|
6693
|
+
if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
|
|
6694
|
+
const env = vibeEnvs.get(sessionId);
|
|
6695
|
+
if (!env) return res.status(404).json({ ok: false, error: "Environment not running. Call /vibe/env/start first." });
|
|
6696
|
+
if (env.tunnelUrl) return res.json({ ok: true, tunnelUrl: env.tunnelUrl, alreadyRunning: true });
|
|
6697
|
+
try {
|
|
6698
|
+
// Resolve cloudflared binary path
|
|
6699
|
+
const cfDir = path.join(process.env.HOME || '/tmp', '.deepdebug', 'bin');
|
|
6700
|
+
const cfPath = path.join(cfDir, 'cloudflared');
|
|
6701
|
+
// Download if missing
|
|
6702
|
+
if (!fs.existsSync(cfPath)) {
|
|
6703
|
+
fs.mkdirSync(cfDir, { recursive: true });
|
|
6704
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
6705
|
+
const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
6706
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${platform}-${arch}`;
|
|
6707
|
+
console.log(`[VIBE-TUNNEL] Downloading cloudflared from ${url}...`);
|
|
6708
|
+
await execAsync(`curl -fsSL "${url}" -o "${cfPath}" && chmod +x "${cfPath}"`);
|
|
6709
|
+
console.log(`[VIBE-TUNNEL] cloudflared downloaded to ${cfPath}`);
|
|
6710
|
+
}
|
|
6711
|
+
// Start tunnel
|
|
6712
|
+
const tunnelProcess = spawn(cfPath, ['tunnel', '--url', `http://localhost:${env.port}`], {
|
|
6713
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
6714
|
+
detached: false
|
|
6715
|
+
});
|
|
6716
|
+
env.tunnelPid = tunnelProcess.pid;
|
|
6717
|
+
env.status = 'tunneling';
|
|
6718
|
+
// Capture tunnel URL from stdout/stderr
|
|
6719
|
+
const tunnelUrl = await new Promise((resolve, reject) => {
|
|
6720
|
+
const timeout = setTimeout(() => reject(new Error('Tunnel URL not found within 30s')), 30000);
|
|
6721
|
+
const handler = (data) => {
|
|
6722
|
+
const text = data.toString();
|
|
6723
|
+
const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
6724
|
+
if (match) {
|
|
6725
|
+
clearTimeout(timeout);
|
|
6726
|
+
tunnelProcess.stdout.off('data', handler);
|
|
6727
|
+
tunnelProcess.stderr.off('data', handler);
|
|
6728
|
+
resolve(match[0]);
|
|
6729
|
+
}
|
|
6730
|
+
};
|
|
6731
|
+
tunnelProcess.stdout.on('data', handler);
|
|
6732
|
+
tunnelProcess.stderr.on('data', handler);
|
|
6733
|
+
tunnelProcess.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
6734
|
+
});
|
|
6735
|
+
env.tunnelUrl = tunnelUrl;
|
|
6736
|
+
console.log(`[VIBE-TUNNEL] Tunnel started: ${tunnelUrl}`);
|
|
6737
|
+
return res.json({ ok: true, tunnelUrl });
|
|
6738
|
+
} catch (err) {
|
|
6739
|
+
console.error(`[VIBE-TUNNEL] Failed: ${err.message}`);
|
|
6740
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
6741
|
+
}
|
|
6742
|
+
});
|
|
6743
|
+
|
|
6744
|
+
/**
|
|
6745
|
+
* DELETE /workspace/vibe/tunnel/:sessionId
|
|
6746
|
+
*
|
|
6747
|
+
* Stops only the tunnel (keeps service running).
|
|
6748
|
+
*/
|
|
6749
|
+
app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
|
|
6750
|
+
const { sessionId } = req.params;
|
|
6751
|
+
const env = vibeEnvs.get(sessionId);
|
|
6752
|
+
if (!env || !env.tunnelPid) return res.json({ ok: true, message: "No tunnel running" });
|
|
6753
|
+
try {
|
|
6754
|
+
process.kill(env.tunnelPid, 'SIGTERM');
|
|
6755
|
+
env.tunnelPid = null;
|
|
6756
|
+
env.tunnelUrl = null;
|
|
6757
|
+
env.status = 'running';
|
|
6758
|
+
return res.json({ ok: true });
|
|
6759
|
+
} catch (err) {
|
|
6760
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
6761
|
+
}
|
|
6762
|
+
});
|
|
6763
|
+
|
|
6764
|
+
// ============================================
|
|
6765
|
+
// VERCEL PROXY ROUTES
|
|
6766
|
+
// ============================================
|
|
6767
|
+
registerVercelRoutes(app);
|
|
6768
|
+
|
|
5934
6769
|
// ============================================
|
|
5935
6770
|
// START SERVER
|
|
5936
6771
|
|