deepdebug-local-agent 1.0.18 → 1.0.20

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/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
- // FIXED: Allow CORS from Cloud Run and local development
549
+ // CORS - Allow from Cloud Run, local dev, and Lovable frontend domains
549
550
  app.use(cors({
550
- origin: [
551
- "http://localhost:3010",
552
- "http://localhost:3000",
553
- "http://localhost:8085",
554
- "http://127.0.0.1:3010",
555
- "http://127.0.0.1:3000",
556
- "http://127.0.0.1:8085",
557
- // Cloud Run URLs (regex patterns)
558
- /https:\/\/.*\.run\.app$/,
559
- /https:\/\/.*\.web\.app$/,
560
- // Production frontend
561
- "https://deepdebug.ai",
562
- "https://www.deepdebug.ai"
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.DEFAULT_WORKSPACE || '/Users/macintosh/IdeaProjects/pure-core-ms';
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
- const BACKUP_INDEX_PATH = path.join(os.tmpdir(), 'deepdebug-backups-index.json');
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
- const branch = body.branch;
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
- const repoName = gitUrl.split('/').pop().replace('.git', '');
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
- let authenticatedUrl = gitUrl;
907
- if (gitToken && gitUrl.startsWith('https://') && !gitUrl.includes('@')) {
908
- if (gitUrl.includes('bitbucket.org')) {
909
- authenticatedUrl = gitUrl.replace('https://', `https://x-token-auth:${gitToken}@`);
910
- } else if (gitUrl.includes('gitlab.com')) {
911
- authenticatedUrl = gitUrl.replace('https://', `https://oauth2:${gitToken}@`);
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
- authenticatedUrl = gitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
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: ${gitUrl} -> ${absTarget}`);
965
+ console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
917
966
 
918
967
  try {
919
968
  // Ensure parent directory exists
@@ -1049,7 +1098,7 @@ app.get("/workspace/file-content", async (req, res) => {
1049
1098
 
1050
1099
  /** Escreve/salva conteudo de arquivo no workspace */
1051
1100
  app.post("/workspace/write-file", async (req, res) => {
1052
- const workspaceRoot = getEffectiveRoot(req);
1101
+ const workspaceRoot = resolveWorkspaceRoot(req);
1053
1102
  if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
1054
1103
 
1055
1104
  const { path: relativePath, content: fileContent } = req.body || {};
@@ -1653,7 +1702,9 @@ app.post("/workspace/write", async (req, res) => {
1653
1702
  const { path: rel, content } = req.body || {};
1654
1703
  if (!rel) return res.status(400).json({ error: "path is required" });
1655
1704
  try {
1656
- await writeFile(path.join(wsRoot, rel), content ?? "", "utf8");
1705
+ const fullPath = path.join(wsRoot, rel);
1706
+ await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
1707
+ await writeFile(fullPath, content ?? "", "utf8");
1657
1708
  res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
1658
1709
  } catch (e) {
1659
1710
  res.status(400).json({ error: "write failed", details: String(e) });
@@ -1795,19 +1846,42 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1795
1846
  skipTests: true
1796
1847
  });
1797
1848
 
1798
- if (compileResult.code !== 0) {
1849
+ // Support both formats:
1850
+ // - new format: { success, code, steps[] } from updated exec-utils.js
1851
+ // - legacy format: { code, stdout, stderr } from older exec-utils.js
1852
+ const isSuccess = compileResult.success !== undefined
1853
+ ? compileResult.success
1854
+ : compileResult.code === 0;
1855
+
1856
+ const errorOutput = compileResult.steps
1857
+ ? compileResult.steps.map(s => s.stderr || '').filter(Boolean).join('\n').trim()
1858
+ : (compileResult.stderr || '');
1859
+
1860
+ const stdoutOutput = compileResult.steps
1861
+ ? compileResult.steps.map(s => s.stdout || '').filter(Boolean).join('\n').trim()
1862
+ : (compileResult.stdout || '');
1863
+
1864
+ const totalDuration = compileResult.steps
1865
+ ? compileResult.steps.reduce((sum, s) => sum + (s.duration || 0), 0)
1866
+ : (compileResult.duration || 0);
1867
+
1868
+ if (!isSuccess) {
1799
1869
  TEST_LOCAL_STATE.status = "error";
1870
+ console.error("[TEST-LOCAL] Compilation failed. stderr:", errorOutput.substring(0, 500));
1871
+ console.error("[TEST-LOCAL] Compilation failed. stdout:", stdoutOutput.substring(0, 500));
1872
+
1800
1873
  TEST_LOCAL_STATE.compilationResult = {
1801
1874
  success: false,
1802
- error: compileResult.stderr,
1803
- duration: compileResult.duration
1875
+ error: errorOutput || 'Compilation failed',
1876
+ duration: totalDuration
1804
1877
  };
1805
1878
 
1806
1879
  return res.json({
1807
1880
  ok: false,
1808
- error: compileResult.stderr,
1809
- stdout: compileResult.stdout,
1810
- duration: compileResult.duration
1881
+ error: errorOutput || 'Compilation failed',
1882
+ stdout: stdoutOutput,
1883
+ steps: compileResult.steps,
1884
+ duration: totalDuration
1811
1885
  });
1812
1886
  }
1813
1887
 
@@ -1816,7 +1890,7 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1816
1890
  success: true,
1817
1891
  language: meta.language,
1818
1892
  buildTool: meta.buildTool,
1819
- duration: compileResult.duration
1893
+ duration: totalDuration
1820
1894
  };
1821
1895
 
1822
1896
  console.log("[TEST-LOCAL] Compilation successful");
@@ -1825,8 +1899,8 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1825
1899
  ok: true,
1826
1900
  language: meta.language,
1827
1901
  buildTool: meta.buildTool,
1828
- duration: compileResult.duration,
1829
- stdout: compileResult.stdout
1902
+ duration: totalDuration,
1903
+ stdout: stdoutOutput
1830
1904
  });
1831
1905
  } catch (err) {
1832
1906
  console.error("[TEST-LOCAL] Compilation failed:", err.message);
@@ -2347,8 +2421,9 @@ app.post("/workspace/safe-patch", async (req, res) => {
2347
2421
  incidentId: incidentId || null
2348
2422
  });
2349
2423
 
2350
- // Save backup files to disk for persistence across restarts
2351
- const backupDir = path.join(os.tmpdir(), 'deepdebug-backups', backupId);
2424
+ // Save backup files to NFS Filestore for persistence across container restarts
2425
+ const backupsBase = fs.existsSync(NFS_BACKUPS_DIR) ? path.join(NFS_BACKUPS_DIR, '.deepdebug-backups') : path.join(os.tmpdir(), 'deepdebug-backups');
2426
+ const backupDir = path.join(backupsBase, backupId);
2352
2427
  try {
2353
2428
  await fsPromises.mkdir(backupDir, { recursive: true });
2354
2429
  for (const file of backupFiles) {
@@ -5375,13 +5450,13 @@ app.post("/workspace/:workspaceId/run", async (req, res) => {
5375
5450
  // Connects Local Agent to Gateway no public URL needed
5376
5451
  // ============================================
5377
5452
  async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
5378
- if (!gatewayUrl || !apiKey || !tenantId) {
5379
- console.log('WebSocket tunnel skipped: missing gatewayUrl, apiKey or tenantId in ~/.deepdebug/config.json');
5453
+ if (!gatewayUrl || !tenantId) {
5454
+ console.log('WebSocket tunnel skipped: missing gatewayUrl or tenantId');
5380
5455
  return;
5381
5456
  }
5382
5457
 
5383
5458
  const wsUrl = gatewayUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
5384
- const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}&apiKey=${encodeURIComponent(apiKey)}`;
5459
+ const fullUrl = `${wsUrl}/api/v1/agent/ws?tenantId=${encodeURIComponent(tenantId)}${apiKey ? '&apiKey=' + encodeURIComponent(apiKey) : ''}`;
5385
5460
 
5386
5461
  let reconnectDelay = 2000;
5387
5462
  let isShuttingDown = false;
@@ -5792,6 +5867,449 @@ app.get("/setup/auth-status", async (req, res) => {
5792
5867
  }
5793
5868
  });
5794
5869
 
5870
+ // ============================================
5871
+ // SPRINT 1 S1-T2: POST /workspace/test-local/run-single
5872
+ // Run a single test class (faster than full suite)
5873
+ // ============================================
5874
+
5875
+ /**
5876
+ * POST /workspace/test-local/run-single
5877
+ *
5878
+ * Runs a single test class by name much faster than running the full suite.
5879
+ * Used by the agentic loop (tool: run_single_test) to validate a specific fix
5880
+ * without waiting for all tests to complete.
5881
+ *
5882
+ * Body:
5883
+ * { className: "BookingServiceTest" }
5884
+ * OR { testFile: "src/test/java/com/example/BookingServiceTest.java" }
5885
+ *
5886
+ * Response:
5887
+ * { ok, className, passed, failed, skipped, total, duration, output, failures[], exitCode }
5888
+ */
5889
+ app.post("/workspace/test-local/run-single", async (req, res) => {
5890
+ const wsRoot = resolveWorkspaceRoot(req);
5891
+ if (!wsRoot) return res.status(400).json({ error: "workspace not set" });
5892
+
5893
+ const { className, testFile } = req.body || {};
5894
+
5895
+ // Resolve class name from either param
5896
+ let resolvedClass = className;
5897
+ if (!resolvedClass && testFile) {
5898
+ resolvedClass = path.basename(testFile, '.java');
5899
+ }
5900
+
5901
+ if (!resolvedClass || !resolvedClass.trim()) {
5902
+ return res.status(400).json({
5903
+ ok: false,
5904
+ error: "className or testFile is required. Example: { className: 'BookingServiceTest' }"
5905
+ });
5906
+ }
5907
+
5908
+ resolvedClass = resolvedClass.trim();
5909
+ console.log(`[RUN-SINGLE] Running test class: ${resolvedClass} in ${wsRoot}`);
5910
+
5911
+ const startTime = Date.now();
5912
+
5913
+ try {
5914
+ const meta = await detectProject(wsRoot);
5915
+
5916
+ // Build JAVA_HOME env (same fix as compile endpoint)
5917
+ const javaHome = process.env.JAVA_HOME || "/usr/local/java-home";
5918
+ const mavenEnv = {
5919
+ ...process.env,
5920
+ JAVA_HOME: javaHome,
5921
+ PATH: `${javaHome}/bin:${process.env.PATH}`
5922
+ };
5923
+
5924
+ let result;
5925
+
5926
+ if (meta.buildTool === 'maven') {
5927
+ // Prefer ./mvnw wrapper, pass JAVA_HOME env
5928
+ const hasMvnw = fs.existsSync(path.join(wsRoot, 'mvnw'));
5929
+ const mvnCmd = hasMvnw ? './mvnw' : 'mvn';
5930
+ result = await run(mvnCmd, ['test', `-Dtest=${resolvedClass}`, '-DskipTests=false'], wsRoot, 3 * 60 * 1000, mavenEnv);
5931
+ } else if (meta.buildTool === 'gradle') {
5932
+ const gradleCmd = fs.existsSync(path.join(wsRoot, 'gradlew')) ? './gradlew' : 'gradle';
5933
+ result = await run(gradleCmd, ['test', '--tests', `*.${resolvedClass}`], wsRoot, 3 * 60 * 1000, mavenEnv);
5934
+ } else if (meta.language === 'node') {
5935
+ result = await run('npx', ['jest', resolvedClass, '--no-coverage'], wsRoot);
5936
+ } else {
5937
+ return res.status(400).json({
5938
+ ok: false,
5939
+ error: `Single test not supported for buildTool: ${meta.buildTool}. Use /workspace/test-local/compile instead.`
5940
+ });
5941
+ }
5942
+
5943
+ const duration = Date.now() - startTime;
5944
+ const output = (result.stdout || '') + (result.stderr || '');
5945
+
5946
+ // Parse Maven Surefire / JUnit output
5947
+ const testsRunMatch = output.match(/Tests run: (\d+)/);
5948
+ const failuresMatch = output.match(/Failures: (\d+)/);
5949
+ const errorsMatch = output.match(/Errors: (\d+)/);
5950
+ const skippedMatch = output.match(/Skipped: (\d+)/);
5951
+
5952
+ const total = testsRunMatch ? parseInt(testsRunMatch[1]) : 0;
5953
+ const failures = failuresMatch ? parseInt(failuresMatch[1]) : 0;
5954
+ const errors = errorsMatch ? parseInt(errorsMatch[1]) : 0;
5955
+ const skipped = skippedMatch ? parseInt(skippedMatch[1]) : 0;
5956
+ const passed = total - failures - errors - skipped;
5957
+ const success = result.code === 0 && failures === 0 && errors === 0;
5958
+
5959
+ // Extract failure names from output
5960
+ const failureDetails = [];
5961
+ const failureRegex = /FAILED\s+([^\n]+)/g;
5962
+ let match;
5963
+ while ((match = failureRegex.exec(output)) !== null) {
5964
+ failureDetails.push(match[1].trim());
5965
+ }
5966
+
5967
+ console.log(`[RUN-SINGLE] ${resolvedClass}: ${success ? 'PASSED' : 'FAILED'} ` +
5968
+ `(${duration}ms, ${passed}/${total})`);
5969
+
5970
+ res.json({
5971
+ ok: success,
5972
+ className: resolvedClass,
5973
+ passed,
5974
+ failed: failures + errors,
5975
+ skipped,
5976
+ total,
5977
+ duration,
5978
+ output: output.length > 8000 ? output.substring(0, 8000) + '\n...[truncated]' : output,
5979
+ failures: failureDetails,
5980
+ exitCode: result.code
5981
+ });
5982
+
5983
+ } catch (err) {
5984
+ const duration = Date.now() - startTime;
5985
+ console.error(`[RUN-SINGLE] Failed for ${resolvedClass}:`, err.message);
5986
+ res.status(500).json({
5987
+ ok: false,
5988
+ className: resolvedClass,
5989
+ error: err.message,
5990
+ duration,
5991
+ passed: 0,
5992
+ failed: 1,
5993
+ skipped: 0,
5994
+ total: 0
5995
+ });
5996
+ }
5997
+ });
5998
+
5999
+ // ============================================
6000
+ // SPRINT 1 - S1-T4: Session Isolation via Git Worktree
6001
+ // ============================================
6002
+
6003
+ /**
6004
+ * POST /workspace/session/create
6005
+ *
6006
+ * Creates an isolated git worktree for a session.
6007
+ * Each session gets its own directory and branch - zero collision between parallel sessions.
6008
+ *
6009
+ * Body: { sessionId, branchName, tenantId }
6010
+ * Response: { ok, sessionId, worktreePath, branchName }
6011
+ */
6012
+ app.post("/workspace/session/create", async (req, res) => {
6013
+ const wsRoot = resolveWorkspaceRoot(req);
6014
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6015
+
6016
+ const { sessionId, branchName, tenantId } = req.body || {};
6017
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId is required" });
6018
+
6019
+ const safeBranch = (branchName || `session-${sessionId}`).replace(/[^a-zA-Z0-9/_-]/g, "-").toLowerCase();
6020
+ const tenantPart = tenantId || path.basename(path.dirname(wsRoot));
6021
+
6022
+ // Sessions dir: /mnt/workspaces/{tenantId}/sessions/{sessionId}
6023
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6024
+ const worktreePath = path.join(sessionsBase, sessionId);
6025
+
6026
+ console.log(`[SESSION] Creating worktree: ${worktreePath} (branch: ${safeBranch})`);
6027
+
6028
+ try {
6029
+ await fsPromises.mkdir(sessionsBase, { recursive: true });
6030
+
6031
+ // Remove stale worktree if exists
6032
+ if (fs.existsSync(worktreePath)) {
6033
+ console.log(`[SESSION] Removing stale worktree: ${worktreePath}`);
6034
+ await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
6035
+ await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
6036
+ }
6037
+
6038
+ // Create new worktree with new branch
6039
+ await execAsync(`git worktree add "${worktreePath}" -b "${safeBranch}"`, { cwd: wsRoot });
6040
+
6041
+ console.log(`[SESSION] Worktree created: ${worktreePath}`);
6042
+ return res.json({
6043
+ ok: true,
6044
+ sessionId,
6045
+ worktreePath,
6046
+ branchName: safeBranch
6047
+ });
6048
+ } catch (err) {
6049
+ console.error(`[SESSION] Failed to create worktree: ${err.message}`);
6050
+ // Fallback: return main workspace path (no isolation but no crash)
6051
+ return res.status(500).json({
6052
+ ok: false,
6053
+ sessionId,
6054
+ worktreePath: wsRoot,
6055
+ branchName: safeBranch,
6056
+ error: err.message,
6057
+ fallback: true
6058
+ });
6059
+ }
6060
+ });
6061
+
6062
+ /**
6063
+ * DELETE /workspace/session/:sessionId
6064
+ *
6065
+ * Removes a session worktree and its branch.
6066
+ * Called after PR is created or session times out.
6067
+ *
6068
+ * Query: ?tenantId=xxx
6069
+ */
6070
+ app.delete("/workspace/session/:sessionId", async (req, res) => {
6071
+ const wsRoot = resolveWorkspaceRoot(req);
6072
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6073
+
6074
+ const { sessionId } = req.params;
6075
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6076
+ const worktreePath = path.join(sessionsBase, sessionId);
6077
+
6078
+ console.log(`[SESSION] Removing worktree: ${worktreePath}`);
6079
+
6080
+ try {
6081
+ if (fs.existsSync(worktreePath)) {
6082
+ await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
6083
+ await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
6084
+ }
6085
+
6086
+ // Clean up branch
6087
+ const branchName = `session-${sessionId}`;
6088
+ await execAsync(`git branch -D "${branchName}"`, { cwd: wsRoot }).catch(() => {});
6089
+
6090
+ console.log(`[SESSION] Worktree removed: ${worktreePath}`);
6091
+ return res.json({ ok: true, sessionId, removed: worktreePath });
6092
+ } catch (err) {
6093
+ console.error(`[SESSION] Failed to remove worktree: ${err.message}`);
6094
+ return res.status(500).json({ ok: false, sessionId, error: err.message });
6095
+ }
6096
+ });
6097
+
6098
+ /**
6099
+ * GET /workspace/session/:sessionId/status
6100
+ *
6101
+ * Returns status of a session worktree.
6102
+ */
6103
+ app.get("/workspace/session/:sessionId/status", async (req, res) => {
6104
+ const wsRoot = resolveWorkspaceRoot(req);
6105
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6106
+
6107
+ const { sessionId } = req.params;
6108
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6109
+ const worktreePath = path.join(sessionsBase, sessionId);
6110
+ const exists = fs.existsSync(worktreePath);
6111
+
6112
+ return res.json({
6113
+ ok: true,
6114
+ sessionId,
6115
+ worktreePath,
6116
+ exists
6117
+ });
6118
+ });
6119
+
6120
+
6121
+ // ============================================
6122
+ // FASE 5 - ENV MANAGER + PREVIEW TUNNEL
6123
+ // State: active vibe environments per sessionId
6124
+ // ============================================
6125
+ const vibeEnvs = new Map(); // sessionId -> { port, pid, serviceId, tunnelUrl, tunnelPid, worktreePath, status }
6126
+
6127
+ function findFreePort(min = 9000, max = 9999) {
6128
+ const used = new Set([...vibeEnvs.values()].map(e => e.port));
6129
+ for (let p = min; p <= max; p++) {
6130
+ if (!used.has(p)) return p;
6131
+ }
6132
+ throw new Error('No free ports available in range 9000-9999');
6133
+ }
6134
+
6135
+ /**
6136
+ * POST /workspace/vibe/env/start
6137
+ *
6138
+ * Compiles (if needed) and starts the service on a dynamic port.
6139
+ * Body: { sessionId, worktreePath }
6140
+ * Response: { ok, port, serviceId, readyUrl }
6141
+ */
6142
+ app.post("/workspace/vibe/env/start", async (req, res) => {
6143
+ const { sessionId, worktreePath } = req.body || {};
6144
+ if (!sessionId || !worktreePath) {
6145
+ return res.status(400).json({ ok: false, error: "sessionId and worktreePath required" });
6146
+ }
6147
+ if (vibeEnvs.has(sessionId)) {
6148
+ const env = vibeEnvs.get(sessionId);
6149
+ return res.json({ ok: true, port: env.port, serviceId: env.serviceId, readyUrl: `http://localhost:${env.port}`, alreadyRunning: true });
6150
+ }
6151
+ try {
6152
+ const port = findFreePort();
6153
+ const serviceId = `vibe-${sessionId}`;
6154
+ const meta = await detectProject(worktreePath);
6155
+ let startConfig;
6156
+ if (meta.language === 'java') {
6157
+ const targetDir = path.join(worktreePath, 'target');
6158
+ let jarPath = null;
6159
+ if (fs.existsSync(targetDir)) {
6160
+ const jars = fs.readdirSync(targetDir).filter(f =>
6161
+ f.endsWith('.jar') && !f.endsWith('.original') &&
6162
+ !f.includes('-sources') && !f.includes('-javadoc'));
6163
+ if (jars.length > 0) jarPath = path.join(targetDir, jars[0]);
6164
+ }
6165
+ if (!jarPath) {
6166
+ return res.status(400).json({ ok: false, error: "No JAR found. Run compile_project first." });
6167
+ }
6168
+ const cleanEnv = { ...process.env };
6169
+ Object.keys(cleanEnv).forEach(k => { if (k.startsWith('SPRING_')) delete cleanEnv[k]; });
6170
+ cleanEnv.SERVER_PORT = String(port);
6171
+ cleanEnv.PORT = String(port);
6172
+ startConfig = { command: 'java', args: ['-jar', jarPath, `--server.port=${port}`], cwd: worktreePath, port, env: cleanEnv };
6173
+ } else if (meta.language === 'node' || meta.language === 'javascript') {
6174
+ startConfig = { command: 'node', args: ['index.js'], cwd: worktreePath, port, env: { ...process.env, PORT: String(port) } };
6175
+ } else {
6176
+ return res.status(400).json({ ok: false, error: `Unsupported language: ${meta.language}` });
6177
+ }
6178
+ vibeEnvs.set(sessionId, { port, serviceId, tunnelUrl: null, tunnelPid: null, worktreePath, status: 'starting' });
6179
+ await processManager.start(serviceId, startConfig);
6180
+ vibeEnvs.get(sessionId).status = 'running';
6181
+ console.log(`[VIBE-ENV] Started ${serviceId} on port ${port}`);
6182
+ return res.json({ ok: true, port, serviceId, readyUrl: `http://localhost:${port}` });
6183
+ } catch (err) {
6184
+ console.error(`[VIBE-ENV] Start failed: ${err.message}`);
6185
+ vibeEnvs.delete(sessionId);
6186
+ return res.status(500).json({ ok: false, error: err.message });
6187
+ }
6188
+ });
6189
+
6190
+ /**
6191
+ * POST /workspace/vibe/env/stop
6192
+ *
6193
+ * Stops the service and tunnel for a session.
6194
+ * Body: { sessionId }
6195
+ */
6196
+ app.post("/workspace/vibe/env/stop", async (req, res) => {
6197
+ const { sessionId } = req.body || {};
6198
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
6199
+ const env = vibeEnvs.get(sessionId);
6200
+ if (!env) return res.json({ ok: true, message: "Not running" });
6201
+ try {
6202
+ await processManager.stop(env.serviceId);
6203
+ if (env.tunnelPid) {
6204
+ try { process.kill(env.tunnelPid, 'SIGTERM'); } catch (_) {}
6205
+ }
6206
+ vibeEnvs.delete(sessionId);
6207
+ console.log(`[VIBE-ENV] Stopped ${env.serviceId}`);
6208
+ return res.json({ ok: true });
6209
+ } catch (err) {
6210
+ return res.status(500).json({ ok: false, error: err.message });
6211
+ }
6212
+ });
6213
+
6214
+ /**
6215
+ * GET /workspace/vibe/env/status/:sessionId
6216
+ *
6217
+ * Returns current status of a vibe environment.
6218
+ */
6219
+ app.get("/workspace/vibe/env/status/:sessionId", async (req, res) => {
6220
+ const { sessionId } = req.params;
6221
+ const env = vibeEnvs.get(sessionId);
6222
+ if (!env) return res.json({ ok: true, status: 'stopped', sessionId });
6223
+ return res.json({ ok: true, sessionId, status: env.status, port: env.port, serviceId: env.serviceId, tunnelUrl: env.tunnelUrl, readyUrl: `http://localhost:${env.port}` });
6224
+ });
6225
+
6226
+ /**
6227
+ * POST /workspace/vibe/tunnel/start
6228
+ *
6229
+ * Starts a cloudflared tunnel for a vibe environment.
6230
+ * Body: { sessionId }
6231
+ * Response: { ok, tunnelUrl }
6232
+ *
6233
+ * Downloads cloudflared binary automatically if not present.
6234
+ */
6235
+ app.post("/workspace/vibe/tunnel/start", async (req, res) => {
6236
+ const { sessionId } = req.body || {};
6237
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
6238
+ const env = vibeEnvs.get(sessionId);
6239
+ if (!env) return res.status(404).json({ ok: false, error: "Environment not running. Call /vibe/env/start first." });
6240
+ if (env.tunnelUrl) return res.json({ ok: true, tunnelUrl: env.tunnelUrl, alreadyRunning: true });
6241
+ try {
6242
+ // Resolve cloudflared binary path
6243
+ const cfDir = path.join(process.env.HOME || '/tmp', '.deepdebug', 'bin');
6244
+ const cfPath = path.join(cfDir, 'cloudflared');
6245
+ // Download if missing
6246
+ if (!fs.existsSync(cfPath)) {
6247
+ fs.mkdirSync(cfDir, { recursive: true });
6248
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
6249
+ const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
6250
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${platform}-${arch}`;
6251
+ console.log(`[VIBE-TUNNEL] Downloading cloudflared from ${url}...`);
6252
+ await execAsync(`curl -fsSL "${url}" -o "${cfPath}" && chmod +x "${cfPath}"`);
6253
+ console.log(`[VIBE-TUNNEL] cloudflared downloaded to ${cfPath}`);
6254
+ }
6255
+ // Start tunnel
6256
+ const tunnelProcess = spawn(cfPath, ['tunnel', '--url', `http://localhost:${env.port}`], {
6257
+ stdio: ['ignore', 'pipe', 'pipe'],
6258
+ detached: false
6259
+ });
6260
+ env.tunnelPid = tunnelProcess.pid;
6261
+ env.status = 'tunneling';
6262
+ // Capture tunnel URL from stdout/stderr
6263
+ const tunnelUrl = await new Promise((resolve, reject) => {
6264
+ const timeout = setTimeout(() => reject(new Error('Tunnel URL not found within 30s')), 30000);
6265
+ const handler = (data) => {
6266
+ const text = data.toString();
6267
+ const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
6268
+ if (match) {
6269
+ clearTimeout(timeout);
6270
+ tunnelProcess.stdout.off('data', handler);
6271
+ tunnelProcess.stderr.off('data', handler);
6272
+ resolve(match[0]);
6273
+ }
6274
+ };
6275
+ tunnelProcess.stdout.on('data', handler);
6276
+ tunnelProcess.stderr.on('data', handler);
6277
+ tunnelProcess.on('error', (err) => { clearTimeout(timeout); reject(err); });
6278
+ });
6279
+ env.tunnelUrl = tunnelUrl;
6280
+ console.log(`[VIBE-TUNNEL] Tunnel started: ${tunnelUrl}`);
6281
+ return res.json({ ok: true, tunnelUrl });
6282
+ } catch (err) {
6283
+ console.error(`[VIBE-TUNNEL] Failed: ${err.message}`);
6284
+ return res.status(500).json({ ok: false, error: err.message });
6285
+ }
6286
+ });
6287
+
6288
+ /**
6289
+ * DELETE /workspace/vibe/tunnel/:sessionId
6290
+ *
6291
+ * Stops only the tunnel (keeps service running).
6292
+ */
6293
+ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
6294
+ const { sessionId } = req.params;
6295
+ const env = vibeEnvs.get(sessionId);
6296
+ if (!env || !env.tunnelPid) return res.json({ ok: true, message: "No tunnel running" });
6297
+ try {
6298
+ process.kill(env.tunnelPid, 'SIGTERM');
6299
+ env.tunnelPid = null;
6300
+ env.tunnelUrl = null;
6301
+ env.status = 'running';
6302
+ return res.json({ ok: true });
6303
+ } catch (err) {
6304
+ return res.status(500).json({ ok: false, error: err.message });
6305
+ }
6306
+ });
6307
+
6308
+ // ============================================
6309
+ // VERCEL PROXY ROUTES
6310
+ // ============================================
6311
+ registerVercelRoutes(app);
6312
+
5795
6313
  // ============================================
5796
6314
  // START SERVER
5797
6315