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/Dockerfile +32 -16
- package/cloudbuild-agent-qa.yaml +43 -0
- package/docker-compose.yml +2 -2
- package/package.json +1 -1
- package/src/exec-utils.js +126 -23
- package/src/server.js +563 -45
- 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
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1803
|
-
duration:
|
|
1875
|
+
error: errorOutput || 'Compilation failed',
|
|
1876
|
+
duration: totalDuration
|
|
1804
1877
|
};
|
|
1805
1878
|
|
|
1806
1879
|
return res.json({
|
|
1807
1880
|
ok: false,
|
|
1808
|
-
error:
|
|
1809
|
-
stdout:
|
|
1810
|
-
|
|
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:
|
|
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:
|
|
1829
|
-
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
|
|
2351
|
-
const
|
|
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 || !
|
|
5379
|
-
console.log('WebSocket tunnel skipped: missing gatewayUrl
|
|
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
|
|
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
|
|