gitnexus 1.4.10 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/cli/ai-context.d.ts +4 -1
- package/dist/cli/ai-context.js +19 -11
- package/dist/cli/analyze.d.ts +6 -0
- package/dist/cli/analyze.js +105 -251
- package/dist/cli/eval-server.js +20 -11
- package/dist/cli/index-repo.js +20 -22
- package/dist/cli/index.js +8 -7
- package/dist/cli/mcp.js +1 -1
- package/dist/cli/serve.js +29 -1
- package/dist/cli/setup.js +9 -9
- package/dist/cli/skill-gen.js +15 -9
- package/dist/cli/wiki.d.ts +2 -0
- package/dist/cli/wiki.js +141 -26
- package/dist/config/ignore-service.js +102 -22
- package/dist/config/supported-languages.d.ts +8 -42
- package/dist/config/supported-languages.js +8 -43
- package/dist/core/augmentation/engine.js +19 -7
- package/dist/core/embeddings/embedder.js +19 -15
- package/dist/core/embeddings/embedding-pipeline.js +6 -6
- package/dist/core/embeddings/http-client.js +3 -3
- package/dist/core/embeddings/text-generator.js +9 -24
- package/dist/core/embeddings/types.d.ts +1 -1
- package/dist/core/embeddings/types.js +1 -7
- package/dist/core/graph/graph.js +6 -2
- package/dist/core/graph/types.d.ts +9 -59
- package/dist/core/ingestion/ast-cache.js +3 -3
- package/dist/core/ingestion/call-processor.d.ts +20 -2
- package/dist/core/ingestion/call-processor.js +347 -144
- package/dist/core/ingestion/call-routing.js +10 -4
- package/dist/core/ingestion/call-sites/extract-language-call-site.d.ts +10 -0
- package/dist/core/ingestion/call-sites/extract-language-call-site.js +22 -0
- package/dist/core/ingestion/call-sites/java.d.ts +9 -0
- package/dist/core/ingestion/call-sites/java.js +30 -0
- package/dist/core/ingestion/cluster-enricher.js +6 -8
- package/dist/core/ingestion/cobol/cobol-copy-expander.js +10 -3
- package/dist/core/ingestion/cobol/cobol-preprocessor.js +287 -81
- package/dist/core/ingestion/cobol/jcl-parser.js +1 -1
- package/dist/core/ingestion/cobol/jcl-processor.js +1 -1
- package/dist/core/ingestion/cobol-processor.js +102 -56
- package/dist/core/ingestion/community-processor.js +21 -15
- package/dist/core/ingestion/entry-point-scoring.d.ts +1 -1
- package/dist/core/ingestion/entry-point-scoring.js +5 -6
- package/dist/core/ingestion/export-detection.js +32 -9
- package/dist/core/ingestion/field-extractor.d.ts +1 -1
- package/dist/core/ingestion/field-extractors/configs/c-cpp.js +8 -12
- package/dist/core/ingestion/field-extractors/configs/csharp.js +45 -2
- package/dist/core/ingestion/field-extractors/configs/dart.js +5 -3
- package/dist/core/ingestion/field-extractors/configs/go.js +3 -7
- package/dist/core/ingestion/field-extractors/configs/helpers.d.ts +5 -0
- package/dist/core/ingestion/field-extractors/configs/helpers.js +14 -0
- package/dist/core/ingestion/field-extractors/configs/jvm.js +7 -7
- package/dist/core/ingestion/field-extractors/configs/php.js +9 -11
- package/dist/core/ingestion/field-extractors/configs/python.js +1 -1
- package/dist/core/ingestion/field-extractors/configs/ruby.js +4 -3
- package/dist/core/ingestion/field-extractors/configs/rust.js +2 -5
- package/dist/core/ingestion/field-extractors/configs/swift.js +9 -7
- package/dist/core/ingestion/field-extractors/configs/typescript-javascript.js +2 -6
- package/dist/core/ingestion/field-extractors/generic.d.ts +5 -2
- package/dist/core/ingestion/field-extractors/generic.js +6 -0
- package/dist/core/ingestion/field-extractors/typescript.d.ts +1 -1
- package/dist/core/ingestion/field-extractors/typescript.js +1 -1
- package/dist/core/ingestion/field-types.d.ts +4 -2
- package/dist/core/ingestion/filesystem-walker.js +3 -3
- package/dist/core/ingestion/framework-detection.d.ts +1 -1
- package/dist/core/ingestion/framework-detection.js +355 -85
- package/dist/core/ingestion/heritage-processor.d.ts +24 -0
- package/dist/core/ingestion/heritage-processor.js +99 -8
- package/dist/core/ingestion/import-processor.js +44 -15
- package/dist/core/ingestion/import-resolvers/csharp.js +7 -3
- package/dist/core/ingestion/import-resolvers/dart.js +1 -1
- package/dist/core/ingestion/import-resolvers/go.js +4 -2
- package/dist/core/ingestion/import-resolvers/jvm.js +4 -4
- package/dist/core/ingestion/import-resolvers/php.js +4 -4
- package/dist/core/ingestion/import-resolvers/python.js +1 -1
- package/dist/core/ingestion/import-resolvers/rust.js +9 -3
- package/dist/core/ingestion/import-resolvers/standard.d.ts +1 -1
- package/dist/core/ingestion/import-resolvers/standard.js +6 -5
- package/dist/core/ingestion/import-resolvers/swift.js +2 -1
- package/dist/core/ingestion/import-resolvers/utils.js +26 -7
- package/dist/core/ingestion/language-config.js +5 -4
- package/dist/core/ingestion/language-provider.d.ts +7 -2
- package/dist/core/ingestion/languages/c-cpp.js +106 -21
- package/dist/core/ingestion/languages/cobol.js +1 -1
- package/dist/core/ingestion/languages/csharp.js +96 -19
- package/dist/core/ingestion/languages/dart.js +23 -7
- package/dist/core/ingestion/languages/go.js +1 -1
- package/dist/core/ingestion/languages/index.d.ts +1 -1
- package/dist/core/ingestion/languages/index.js +2 -3
- package/dist/core/ingestion/languages/java.js +4 -1
- package/dist/core/ingestion/languages/kotlin.js +60 -13
- package/dist/core/ingestion/languages/php.js +102 -25
- package/dist/core/ingestion/languages/python.js +28 -5
- package/dist/core/ingestion/languages/ruby.js +56 -14
- package/dist/core/ingestion/languages/rust.js +55 -11
- package/dist/core/ingestion/languages/swift.js +112 -27
- package/dist/core/ingestion/languages/typescript.js +95 -19
- package/dist/core/ingestion/markdown-processor.js +5 -5
- package/dist/core/ingestion/method-extractors/configs/csharp.d.ts +2 -0
- package/dist/core/ingestion/method-extractors/configs/csharp.js +283 -0
- package/dist/core/ingestion/method-extractors/configs/jvm.d.ts +3 -0
- package/dist/core/ingestion/method-extractors/configs/jvm.js +326 -0
- package/dist/core/ingestion/method-extractors/generic.d.ts +5 -0
- package/dist/core/ingestion/method-extractors/generic.js +137 -0
- package/dist/core/ingestion/method-types.d.ts +61 -0
- package/dist/core/ingestion/method-types.js +2 -0
- package/dist/core/ingestion/mro-processor.d.ts +1 -1
- package/dist/core/ingestion/mro-processor.js +12 -8
- package/dist/core/ingestion/named-binding-processor.js +2 -2
- package/dist/core/ingestion/named-bindings/rust.js +3 -1
- package/dist/core/ingestion/parsing-processor.js +74 -24
- package/dist/core/ingestion/pipeline.d.ts +2 -1
- package/dist/core/ingestion/pipeline.js +208 -102
- package/dist/core/ingestion/process-processor.js +12 -10
- package/dist/core/ingestion/resolution-context.js +3 -3
- package/dist/core/ingestion/route-extractors/middleware.js +31 -7
- package/dist/core/ingestion/route-extractors/php.js +2 -1
- package/dist/core/ingestion/route-extractors/response-shapes.js +8 -4
- package/dist/core/ingestion/structure-processor.d.ts +1 -1
- package/dist/core/ingestion/structure-processor.js +4 -4
- package/dist/core/ingestion/symbol-table.d.ts +1 -1
- package/dist/core/ingestion/symbol-table.js +22 -6
- package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -1
- package/dist/core/ingestion/tree-sitter-queries.js +1 -1
- package/dist/core/ingestion/type-env.d.ts +2 -2
- package/dist/core/ingestion/type-env.js +75 -50
- package/dist/core/ingestion/type-extractors/c-cpp.js +33 -30
- package/dist/core/ingestion/type-extractors/csharp.js +24 -14
- package/dist/core/ingestion/type-extractors/dart.js +6 -8
- package/dist/core/ingestion/type-extractors/go.js +7 -6
- package/dist/core/ingestion/type-extractors/jvm.js +10 -21
- package/dist/core/ingestion/type-extractors/php.js +26 -13
- package/dist/core/ingestion/type-extractors/python.js +11 -15
- package/dist/core/ingestion/type-extractors/ruby.js +8 -3
- package/dist/core/ingestion/type-extractors/rust.js +6 -8
- package/dist/core/ingestion/type-extractors/shared.js +134 -50
- package/dist/core/ingestion/type-extractors/swift.js +16 -13
- package/dist/core/ingestion/type-extractors/typescript.js +23 -15
- package/dist/core/ingestion/utils/ast-helpers.d.ts +8 -8
- package/dist/core/ingestion/utils/ast-helpers.js +72 -35
- package/dist/core/ingestion/utils/call-analysis.d.ts +2 -0
- package/dist/core/ingestion/utils/call-analysis.js +96 -49
- package/dist/core/ingestion/utils/event-loop.js +1 -1
- package/dist/core/ingestion/workers/parse-worker.d.ts +7 -2
- package/dist/core/ingestion/workers/parse-worker.js +364 -84
- package/dist/core/ingestion/workers/worker-pool.js +5 -10
- package/dist/core/lbug/csv-generator.js +54 -15
- package/dist/core/lbug/lbug-adapter.d.ts +5 -0
- package/dist/core/lbug/lbug-adapter.js +86 -23
- package/dist/core/lbug/schema.d.ts +3 -6
- package/dist/core/lbug/schema.js +6 -30
- package/dist/core/run-analyze.d.ts +49 -0
- package/dist/core/run-analyze.js +257 -0
- package/dist/core/tree-sitter/parser-loader.d.ts +1 -1
- package/dist/core/tree-sitter/parser-loader.js +1 -1
- package/dist/core/wiki/cursor-client.js +2 -7
- package/dist/core/wiki/generator.js +38 -23
- package/dist/core/wiki/graph-queries.js +10 -10
- package/dist/core/wiki/html-viewer.js +7 -3
- package/dist/core/wiki/llm-client.d.ts +23 -2
- package/dist/core/wiki/llm-client.js +96 -26
- package/dist/core/wiki/prompts.js +7 -6
- package/dist/mcp/core/embedder.js +1 -1
- package/dist/mcp/core/lbug-adapter.d.ts +4 -1
- package/dist/mcp/core/lbug-adapter.js +17 -7
- package/dist/mcp/local/local-backend.js +247 -95
- package/dist/mcp/resources.js +14 -6
- package/dist/mcp/server.js +13 -5
- package/dist/mcp/staleness.js +5 -1
- package/dist/mcp/tools.js +100 -23
- package/dist/server/analyze-job.d.ts +53 -0
- package/dist/server/analyze-job.js +146 -0
- package/dist/server/analyze-worker.d.ts +13 -0
- package/dist/server/analyze-worker.js +59 -0
- package/dist/server/api.js +795 -44
- package/dist/server/git-clone.d.ts +25 -0
- package/dist/server/git-clone.js +91 -0
- package/dist/storage/git.js +1 -3
- package/dist/storage/repo-manager.d.ts +5 -2
- package/dist/storage/repo-manager.js +4 -4
- package/dist/types/pipeline.d.ts +1 -21
- package/dist/types/pipeline.js +1 -18
- package/hooks/claude/gitnexus-hook.cjs +52 -22
- package/package.json +3 -2
- package/dist/core/ingestion/utils/language-detection.d.ts +0 -9
- package/dist/core/ingestion/utils/language-detection.js +0 -70
package/dist/server/api.js
CHANGED
|
@@ -11,15 +11,23 @@ import express from 'express';
|
|
|
11
11
|
import cors from 'cors';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import fs from 'fs/promises';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
|
|
16
|
+
import { executeQuery, executePrepared, executeWithReusedStatement, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js';
|
|
17
|
+
import { isWriteQuery } from '../mcp/core/lbug-adapter.js';
|
|
18
|
+
import { NODE_TABLES } from 'gitnexus-shared';
|
|
17
19
|
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
|
|
18
20
|
import { hybridSearch } from '../core/search/hybrid-search.js';
|
|
19
21
|
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
|
20
22
|
// at server startup — crashes on unsupported Node ABI versions (#89)
|
|
21
23
|
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
22
24
|
import { mountMCPEndpoints } from './mcp-http.js';
|
|
25
|
+
import { fork } from 'child_process';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
27
|
+
import { JobManager } from './analyze-job.js';
|
|
28
|
+
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
|
|
29
|
+
const _require = createRequire(import.meta.url);
|
|
30
|
+
const pkg = _require('../../package.json');
|
|
23
31
|
/**
|
|
24
32
|
* Determine whether an HTTP Origin header value is allowed by CORS policy.
|
|
25
33
|
*
|
|
@@ -42,13 +50,13 @@ export const isAllowedOrigin = (origin) => {
|
|
|
42
50
|
// Non-browser requests (curl, server-to-server) have no Origin header
|
|
43
51
|
return true;
|
|
44
52
|
}
|
|
45
|
-
if (origin.startsWith('http://localhost:')
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
if (origin.startsWith('http://localhost:') ||
|
|
54
|
+
origin === 'http://localhost' ||
|
|
55
|
+
origin.startsWith('http://127.0.0.1:') ||
|
|
56
|
+
origin === 'http://127.0.0.1' ||
|
|
57
|
+
origin.startsWith('http://[::1]:') ||
|
|
58
|
+
origin === 'http://[::1]' ||
|
|
59
|
+
origin === 'https://gitnexus.vercel.app') {
|
|
52
60
|
return true;
|
|
53
61
|
}
|
|
54
62
|
// RFC 1918 private network ranges — allow any port on these hosts.
|
|
@@ -68,7 +76,7 @@ export const isAllowedOrigin = (origin) => {
|
|
|
68
76
|
if (protocol !== 'http:' && protocol !== 'https:')
|
|
69
77
|
return false;
|
|
70
78
|
const octets = hostname.split('.').map(Number);
|
|
71
|
-
if (octets.length !== 4 || octets.some(o => !Number.isInteger(o) || o < 0 || o > 255)) {
|
|
79
|
+
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
|
72
80
|
return false;
|
|
73
81
|
}
|
|
74
82
|
const [a, b] = octets;
|
|
@@ -83,13 +91,15 @@ export const isAllowedOrigin = (origin) => {
|
|
|
83
91
|
return true;
|
|
84
92
|
return false;
|
|
85
93
|
};
|
|
86
|
-
const buildGraph = async () => {
|
|
94
|
+
const buildGraph = async (includeContent = false) => {
|
|
87
95
|
const nodes = [];
|
|
88
96
|
for (const table of NODE_TABLES) {
|
|
89
97
|
try {
|
|
90
98
|
let query = '';
|
|
91
99
|
if (table === 'File') {
|
|
92
|
-
query =
|
|
100
|
+
query = includeContent
|
|
101
|
+
? `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
|
|
102
|
+
: `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
|
|
93
103
|
}
|
|
94
104
|
else if (table === 'Folder') {
|
|
95
105
|
query = `MATCH (n:Folder) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
|
|
@@ -101,7 +111,9 @@ const buildGraph = async () => {
|
|
|
101
111
|
query = `MATCH (n:Process) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
|
|
102
112
|
}
|
|
103
113
|
else {
|
|
104
|
-
query =
|
|
114
|
+
query = includeContent
|
|
115
|
+
? `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
|
|
116
|
+
: `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
|
105
117
|
}
|
|
106
118
|
const rows = await executeQuery(query);
|
|
107
119
|
for (const row of rows) {
|
|
@@ -113,7 +125,7 @@ const buildGraph = async () => {
|
|
|
113
125
|
filePath: row.filePath ?? row[2],
|
|
114
126
|
startLine: row.startLine,
|
|
115
127
|
endLine: row.endLine,
|
|
116
|
-
content: row.content,
|
|
128
|
+
content: includeContent ? row.content : undefined,
|
|
117
129
|
heuristicLabel: row.heuristicLabel,
|
|
118
130
|
cohesion: row.cohesion,
|
|
119
131
|
symbolCount: row.symbolCount,
|
|
@@ -145,6 +157,76 @@ const buildGraph = async () => {
|
|
|
145
157
|
}
|
|
146
158
|
return { nodes, relationships };
|
|
147
159
|
};
|
|
160
|
+
/**
|
|
161
|
+
* Mount an SSE progress endpoint for a JobManager.
|
|
162
|
+
* Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
|
|
163
|
+
*/
|
|
164
|
+
const mountSSEProgress = (app, routePath, jm) => {
|
|
165
|
+
app.get(routePath, (req, res) => {
|
|
166
|
+
const job = jm.getJob(req.params.jobId);
|
|
167
|
+
if (!job) {
|
|
168
|
+
res.status(404).json({ error: 'Job not found' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let eventId = 0;
|
|
172
|
+
res.writeHead(200, {
|
|
173
|
+
'Content-Type': 'text/event-stream',
|
|
174
|
+
'Cache-Control': 'no-cache',
|
|
175
|
+
Connection: 'keep-alive',
|
|
176
|
+
'X-Accel-Buffering': 'no',
|
|
177
|
+
});
|
|
178
|
+
// Send current state immediately
|
|
179
|
+
eventId++;
|
|
180
|
+
res.write(`id: ${eventId}\ndata: ${JSON.stringify(job.progress)}\n\n`);
|
|
181
|
+
// If already terminal, send event and close
|
|
182
|
+
if (job.status === 'complete' || job.status === 'failed') {
|
|
183
|
+
eventId++;
|
|
184
|
+
res.write(`id: ${eventId}\nevent: ${job.status}\ndata: ${JSON.stringify({
|
|
185
|
+
repoName: job.repoName,
|
|
186
|
+
error: job.error,
|
|
187
|
+
})}\n\n`);
|
|
188
|
+
res.end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Heartbeat to detect zombie connections
|
|
192
|
+
const heartbeat = setInterval(() => {
|
|
193
|
+
try {
|
|
194
|
+
res.write(':heartbeat\n\n');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
clearInterval(heartbeat);
|
|
198
|
+
unsubscribe();
|
|
199
|
+
}
|
|
200
|
+
}, 30_000);
|
|
201
|
+
// Subscribe to progress updates
|
|
202
|
+
const unsubscribe = jm.onProgress(job.id, (progress) => {
|
|
203
|
+
try {
|
|
204
|
+
eventId++;
|
|
205
|
+
if (progress.phase === 'complete' || progress.phase === 'failed') {
|
|
206
|
+
const eventJob = jm.getJob(req.params.jobId);
|
|
207
|
+
res.write(`id: ${eventId}\nevent: ${progress.phase}\ndata: ${JSON.stringify({
|
|
208
|
+
repoName: eventJob?.repoName,
|
|
209
|
+
error: eventJob?.error,
|
|
210
|
+
})}\n\n`);
|
|
211
|
+
clearInterval(heartbeat);
|
|
212
|
+
res.end();
|
|
213
|
+
unsubscribe();
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
res.write(`id: ${eventId}\ndata: ${JSON.stringify(progress)}\n\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
clearInterval(heartbeat);
|
|
221
|
+
unsubscribe();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
req.on('close', () => {
|
|
225
|
+
clearInterval(heartbeat);
|
|
226
|
+
unsubscribe();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
};
|
|
148
230
|
const statusFromError = (err) => {
|
|
149
231
|
const msg = String(err?.message ?? '');
|
|
150
232
|
if (msg.includes('No indexed repositories') || msg.includes('not found'))
|
|
@@ -164,6 +246,7 @@ const requestedRepo = (req) => {
|
|
|
164
246
|
};
|
|
165
247
|
export const createServer = async (port, host = '127.0.0.1') => {
|
|
166
248
|
const app = express();
|
|
249
|
+
app.disable('x-powered-by');
|
|
167
250
|
// CORS: allow localhost, private/LAN networks, and the deployed site.
|
|
168
251
|
// Non-browser requests (curl, server-to-server) have no origin and are allowed.
|
|
169
252
|
app.use(cors({
|
|
@@ -174,29 +257,81 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
174
257
|
else {
|
|
175
258
|
callback(new Error('Not allowed by CORS'));
|
|
176
259
|
}
|
|
177
|
-
}
|
|
260
|
+
},
|
|
178
261
|
}));
|
|
179
262
|
app.use(express.json({ limit: '10mb' }));
|
|
180
263
|
// Initialize MCP backend (multi-repo, shared across all MCP sessions)
|
|
181
264
|
const backend = new LocalBackend();
|
|
182
265
|
await backend.init();
|
|
183
266
|
const cleanupMcp = mountMCPEndpoints(app, backend);
|
|
267
|
+
const jobManager = new JobManager();
|
|
268
|
+
// Shared repo lock — prevents concurrent analyze + embed on the same repo path,
|
|
269
|
+
// which would corrupt LadybugDB (analyze calls closeLbug + initLbug while embed has queries in flight).
|
|
270
|
+
const activeRepoPaths = new Set();
|
|
271
|
+
const acquireRepoLock = (repoPath) => {
|
|
272
|
+
if (activeRepoPaths.has(repoPath)) {
|
|
273
|
+
return `Another job is already active for this repository`;
|
|
274
|
+
}
|
|
275
|
+
activeRepoPaths.add(repoPath);
|
|
276
|
+
return null;
|
|
277
|
+
};
|
|
278
|
+
const releaseRepoLock = (repoPath) => {
|
|
279
|
+
activeRepoPaths.delete(repoPath);
|
|
280
|
+
};
|
|
184
281
|
// Helper: resolve a repo by name from the global registry, or default to first
|
|
185
282
|
const resolveRepo = async (repoName) => {
|
|
186
283
|
const repos = await listRegisteredRepos();
|
|
187
284
|
if (repos.length === 0)
|
|
188
285
|
return null;
|
|
189
286
|
if (repoName)
|
|
190
|
-
return repos.find(r => r.name === repoName) || null;
|
|
287
|
+
return repos.find((r) => r.name === repoName) || null;
|
|
191
288
|
return repos[0]; // default to first
|
|
192
289
|
};
|
|
290
|
+
// SSE heartbeat — clients connect to detect server liveness instantly.
|
|
291
|
+
// When the server shuts down, the TCP connection drops and the client's
|
|
292
|
+
// EventSource fires onerror immediately (no polling delay).
|
|
293
|
+
app.get('/api/heartbeat', (_req, res) => {
|
|
294
|
+
// Use res.set() instead of res.writeHead() to preserve CORS headers from middleware
|
|
295
|
+
res.set({
|
|
296
|
+
'Content-Type': 'text/event-stream',
|
|
297
|
+
'Cache-Control': 'no-cache',
|
|
298
|
+
Connection: 'keep-alive',
|
|
299
|
+
});
|
|
300
|
+
res.flushHeaders();
|
|
301
|
+
// Send initial ping so the client knows it connected
|
|
302
|
+
res.write(':ok\n\n');
|
|
303
|
+
// Keep-alive ping every 15s to prevent proxy/firewall timeout
|
|
304
|
+
const interval = setInterval(() => res.write(':ping\n\n'), 15_000);
|
|
305
|
+
_req.on('close', () => clearInterval(interval));
|
|
306
|
+
});
|
|
307
|
+
// Server info: version and launch context (npx / global / local dev)
|
|
308
|
+
app.get('/api/info', (_req, res) => {
|
|
309
|
+
const execPath = process.env.npm_execpath ?? '';
|
|
310
|
+
const argv0 = process.argv[1] ?? '';
|
|
311
|
+
let launchContext;
|
|
312
|
+
if (execPath.includes('npx') ||
|
|
313
|
+
argv0.includes('_npx') ||
|
|
314
|
+
process.env.npm_config_prefix?.includes('_npx')) {
|
|
315
|
+
launchContext = 'npx';
|
|
316
|
+
}
|
|
317
|
+
else if (argv0.includes('node_modules')) {
|
|
318
|
+
launchContext = 'local';
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
launchContext = 'global';
|
|
322
|
+
}
|
|
323
|
+
res.json({ version: pkg.version, launchContext, nodeVersion: process.version });
|
|
324
|
+
});
|
|
193
325
|
// List all registered repos
|
|
194
326
|
app.get('/api/repos', async (_req, res) => {
|
|
195
327
|
try {
|
|
196
328
|
const repos = await listRegisteredRepos();
|
|
197
|
-
res.json(repos.map(r => ({
|
|
198
|
-
name: r.name,
|
|
199
|
-
|
|
329
|
+
res.json(repos.map((r) => ({
|
|
330
|
+
name: r.name,
|
|
331
|
+
path: r.path,
|
|
332
|
+
indexedAt: r.indexedAt,
|
|
333
|
+
lastCommit: r.lastCommit,
|
|
334
|
+
stats: r.stats,
|
|
200
335
|
})));
|
|
201
336
|
}
|
|
202
337
|
catch (err) {
|
|
@@ -223,6 +358,61 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
223
358
|
res.status(500).json({ error: err.message || 'Failed to get repo info' });
|
|
224
359
|
}
|
|
225
360
|
});
|
|
361
|
+
// Delete a repo — removes index, clone dir (if any), and unregisters it
|
|
362
|
+
app.delete('/api/repo', async (req, res) => {
|
|
363
|
+
try {
|
|
364
|
+
const repoName = requestedRepo(req);
|
|
365
|
+
if (!repoName) {
|
|
366
|
+
res.status(400).json({ error: 'Missing repo name' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const entry = await resolveRepo(repoName);
|
|
370
|
+
if (!entry) {
|
|
371
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// Acquire repo lock — prevents deleting while analyze/embed is in flight
|
|
375
|
+
const lockKey = getStoragePath(entry.path);
|
|
376
|
+
const lockErr = acquireRepoLock(lockKey);
|
|
377
|
+
if (lockErr) {
|
|
378
|
+
res.status(409).json({ error: lockErr });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
// Close any open LadybugDB handle before deleting files
|
|
383
|
+
try {
|
|
384
|
+
await closeLbug();
|
|
385
|
+
}
|
|
386
|
+
catch { }
|
|
387
|
+
// 1. Delete the .gitnexus index/storage directory
|
|
388
|
+
const storagePath = getStoragePath(entry.path);
|
|
389
|
+
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => { });
|
|
390
|
+
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/
|
|
391
|
+
const cloneDir = getCloneDir(entry.name);
|
|
392
|
+
try {
|
|
393
|
+
const stat = await fs.stat(cloneDir);
|
|
394
|
+
if (stat.isDirectory()) {
|
|
395
|
+
await fs.rm(cloneDir, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* clone dir may not exist (local repos) */
|
|
400
|
+
}
|
|
401
|
+
// 3. Unregister from the global registry
|
|
402
|
+
const { unregisterRepo } = await import('../storage/repo-manager.js');
|
|
403
|
+
await unregisterRepo(entry.path);
|
|
404
|
+
// 4. Reinitialize backend to reflect the removal
|
|
405
|
+
await backend.init().catch(() => { });
|
|
406
|
+
res.json({ deleted: entry.name });
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
releaseRepoLock(lockKey);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
res.status(500).json({ error: err.message || 'Failed to delete repo' });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
226
416
|
// Get full graph
|
|
227
417
|
app.get('/api/graph', async (req, res) => {
|
|
228
418
|
try {
|
|
@@ -232,7 +422,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
232
422
|
return;
|
|
233
423
|
}
|
|
234
424
|
const lbugPath = path.join(entry.storagePath, 'lbug');
|
|
235
|
-
const
|
|
425
|
+
const includeContent = req.query.includeContent === 'true';
|
|
426
|
+
const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent));
|
|
236
427
|
res.json(graph);
|
|
237
428
|
}
|
|
238
429
|
catch (err) {
|
|
@@ -247,6 +438,10 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
247
438
|
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
|
248
439
|
return;
|
|
249
440
|
}
|
|
441
|
+
if (isWriteQuery(cypher)) {
|
|
442
|
+
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
250
445
|
const entry = await resolveRepo(requestedRepo(req));
|
|
251
446
|
if (!entry) {
|
|
252
447
|
res.status(404).json({ error: 'Repository not found' });
|
|
@@ -260,7 +455,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
260
455
|
res.status(500).json({ error: err.message || 'Query failed' });
|
|
261
456
|
}
|
|
262
457
|
});
|
|
263
|
-
// Search
|
|
458
|
+
// Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment)
|
|
264
459
|
app.post('/api/search', async (req, res) => {
|
|
265
460
|
try {
|
|
266
461
|
const query = (req.body.query ?? '').trim();
|
|
@@ -278,14 +473,108 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
278
473
|
const limit = Number.isFinite(parsedLimit)
|
|
279
474
|
? Math.max(1, Math.min(100, Math.trunc(parsedLimit)))
|
|
280
475
|
: 10;
|
|
476
|
+
const mode = req.body.mode ?? 'hybrid';
|
|
477
|
+
const enrich = req.body.enrich !== false; // default true
|
|
281
478
|
const results = await withLbugDb(lbugPath, async () => {
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
const {
|
|
285
|
-
|
|
479
|
+
let searchResults;
|
|
480
|
+
if (mode === 'semantic') {
|
|
481
|
+
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
|
|
482
|
+
if (!isEmbedderReady()) {
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
const { semanticSearch: semSearch } = await import('../core/embeddings/embedding-pipeline.js');
|
|
486
|
+
searchResults = await semSearch(executeQuery, query, limit);
|
|
487
|
+
// Normalize semantic results to HybridSearchResult shape
|
|
488
|
+
searchResults = searchResults.map((r, i) => ({
|
|
489
|
+
...r,
|
|
490
|
+
score: r.score ?? 1 - (r.distance ?? 0),
|
|
491
|
+
rank: i + 1,
|
|
492
|
+
sources: ['semantic'],
|
|
493
|
+
}));
|
|
286
494
|
}
|
|
287
|
-
|
|
288
|
-
|
|
495
|
+
else if (mode === 'bm25') {
|
|
496
|
+
searchResults = await searchFTSFromLbug(query, limit);
|
|
497
|
+
searchResults = searchResults.map((r, i) => ({
|
|
498
|
+
...r,
|
|
499
|
+
rank: i + 1,
|
|
500
|
+
sources: ['bm25'],
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// hybrid (default)
|
|
505
|
+
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
|
|
506
|
+
if (isEmbedderReady()) {
|
|
507
|
+
const { semanticSearch: semSearch } = await import('../core/embeddings/embedding-pipeline.js');
|
|
508
|
+
searchResults = await hybridSearch(query, limit, executeQuery, semSearch);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
searchResults = await searchFTSFromLbug(query, limit);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!enrich)
|
|
515
|
+
return searchResults;
|
|
516
|
+
// Server-side enrichment: add connections, cluster, processes per result
|
|
517
|
+
// Uses parameterized queries to prevent Cypher injection via nodeId
|
|
518
|
+
const validLabel = (label) => NODE_TABLES.includes(label);
|
|
519
|
+
const enriched = await Promise.all(searchResults.slice(0, limit).map(async (r) => {
|
|
520
|
+
const nodeId = r.nodeId || r.id || '';
|
|
521
|
+
const nodeLabel = nodeId.split(':')[0];
|
|
522
|
+
const enrichment = {};
|
|
523
|
+
if (!nodeId || !validLabel(nodeLabel))
|
|
524
|
+
return { ...r, ...enrichment };
|
|
525
|
+
// Run connections, cluster, and process queries in parallel
|
|
526
|
+
// Label is validated against NODE_TABLES (compile-time safe identifiers);
|
|
527
|
+
// nodeId uses $nid parameter binding to prevent injection
|
|
528
|
+
const [connRes, clusterRes, procRes] = await Promise.all([
|
|
529
|
+
executePrepared(`
|
|
530
|
+
MATCH (n:${nodeLabel} {id: $nid})
|
|
531
|
+
OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst)
|
|
532
|
+
OPTIONAL MATCH (src)-[r2:CodeRelation]->(n)
|
|
533
|
+
RETURN
|
|
534
|
+
collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing,
|
|
535
|
+
collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming
|
|
536
|
+
LIMIT 1
|
|
537
|
+
`, { nid: nodeId }).catch(() => []),
|
|
538
|
+
executePrepared(`
|
|
539
|
+
MATCH (n:${nodeLabel} {id: $nid})
|
|
540
|
+
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
541
|
+
RETURN c.label AS label, c.description AS description
|
|
542
|
+
LIMIT 1
|
|
543
|
+
`, { nid: nodeId }).catch(() => []),
|
|
544
|
+
executePrepared(`
|
|
545
|
+
MATCH (n:${nodeLabel} {id: $nid})
|
|
546
|
+
MATCH (n)-[rel:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
547
|
+
RETURN p.id AS id, p.label AS label, rel.step AS step, p.stepCount AS stepCount
|
|
548
|
+
ORDER BY rel.step
|
|
549
|
+
`, { nid: nodeId }).catch(() => []),
|
|
550
|
+
]);
|
|
551
|
+
if (connRes.length > 0) {
|
|
552
|
+
const row = connRes[0];
|
|
553
|
+
const outgoing = (Array.isArray(row) ? row[0] : row.outgoing || [])
|
|
554
|
+
.filter((c) => c?.name)
|
|
555
|
+
.slice(0, 5);
|
|
556
|
+
const incoming = (Array.isArray(row) ? row[1] : row.incoming || [])
|
|
557
|
+
.filter((c) => c?.name)
|
|
558
|
+
.slice(0, 5);
|
|
559
|
+
enrichment.connections = { outgoing, incoming };
|
|
560
|
+
}
|
|
561
|
+
if (clusterRes.length > 0) {
|
|
562
|
+
const row = clusterRes[0];
|
|
563
|
+
enrichment.cluster = Array.isArray(row) ? row[0] : row.label;
|
|
564
|
+
}
|
|
565
|
+
if (procRes.length > 0) {
|
|
566
|
+
enrichment.processes = procRes
|
|
567
|
+
.map((row) => ({
|
|
568
|
+
id: Array.isArray(row) ? row[0] : row.id,
|
|
569
|
+
label: Array.isArray(row) ? row[1] : row.label,
|
|
570
|
+
step: Array.isArray(row) ? row[2] : row.step,
|
|
571
|
+
stepCount: Array.isArray(row) ? row[3] : row.stepCount,
|
|
572
|
+
}))
|
|
573
|
+
.filter((p) => p.id && p.label);
|
|
574
|
+
}
|
|
575
|
+
return { ...r, ...enrichment };
|
|
576
|
+
}));
|
|
577
|
+
return enriched;
|
|
289
578
|
});
|
|
290
579
|
res.json({ results });
|
|
291
580
|
}
|
|
@@ -313,8 +602,27 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
313
602
|
res.status(403).json({ error: 'Path traversal denied' });
|
|
314
603
|
return;
|
|
315
604
|
}
|
|
316
|
-
const
|
|
317
|
-
|
|
605
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
606
|
+
// Optional line-range support: ?startLine=10&endLine=50
|
|
607
|
+
// Returns only the requested slice (0-indexed), plus metadata.
|
|
608
|
+
const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
|
|
609
|
+
const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
|
|
610
|
+
if (startLine !== undefined && Number.isFinite(startLine)) {
|
|
611
|
+
const lines = raw.split('\n');
|
|
612
|
+
const start = Math.max(0, startLine);
|
|
613
|
+
const end = endLine !== undefined && Number.isFinite(endLine)
|
|
614
|
+
? Math.min(lines.length, endLine + 1)
|
|
615
|
+
: lines.length;
|
|
616
|
+
res.json({
|
|
617
|
+
content: lines.slice(start, end).join('\n'),
|
|
618
|
+
startLine: start,
|
|
619
|
+
endLine: end - 1,
|
|
620
|
+
totalLines: lines.length,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
res.json({ content: raw, totalLines: raw.split('\n').length });
|
|
625
|
+
}
|
|
318
626
|
}
|
|
319
627
|
catch (err) {
|
|
320
628
|
if (err.code === 'ENOENT') {
|
|
@@ -325,6 +633,75 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
325
633
|
}
|
|
326
634
|
}
|
|
327
635
|
});
|
|
636
|
+
// Grep — regex search across file contents in the indexed repo
|
|
637
|
+
// Uses filesystem-based search for memory efficiency (never loads all files into memory)
|
|
638
|
+
app.get('/api/grep', async (req, res) => {
|
|
639
|
+
try {
|
|
640
|
+
const entry = await resolveRepo(requestedRepo(req));
|
|
641
|
+
if (!entry) {
|
|
642
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const pattern = req.query.pattern;
|
|
646
|
+
if (!pattern) {
|
|
647
|
+
res.status(400).json({ error: 'Missing "pattern" query parameter' });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// ReDoS protection: reject overly long or dangerous patterns
|
|
651
|
+
if (pattern.length > 200) {
|
|
652
|
+
res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// Validate regex syntax
|
|
656
|
+
let regex;
|
|
657
|
+
try {
|
|
658
|
+
regex = new RegExp(pattern, 'gim');
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
res.status(400).json({ error: 'Invalid regex pattern' });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const parsedLimit = Number(req.query.limit ?? 50);
|
|
665
|
+
const limit = Number.isFinite(parsedLimit)
|
|
666
|
+
? Math.max(1, Math.min(200, Math.trunc(parsedLimit)))
|
|
667
|
+
: 50;
|
|
668
|
+
const results = [];
|
|
669
|
+
const repoRoot = path.resolve(entry.path);
|
|
670
|
+
// Get file paths from the graph (lightweight — no content loaded)
|
|
671
|
+
const lbugPath = path.join(entry.storagePath, 'lbug');
|
|
672
|
+
const fileRows = await withLbugDb(lbugPath, () => executeQuery(`MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`));
|
|
673
|
+
// Search files on disk one at a time (constant memory)
|
|
674
|
+
for (const row of fileRows) {
|
|
675
|
+
if (results.length >= limit)
|
|
676
|
+
break;
|
|
677
|
+
const filePath = row.filePath || '';
|
|
678
|
+
const fullPath = path.resolve(repoRoot, filePath);
|
|
679
|
+
// Path traversal guard
|
|
680
|
+
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot)
|
|
681
|
+
continue;
|
|
682
|
+
let content;
|
|
683
|
+
try {
|
|
684
|
+
content = await fs.readFile(fullPath, 'utf-8');
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
continue; // File may have been deleted since indexing
|
|
688
|
+
}
|
|
689
|
+
const lines = content.split('\n');
|
|
690
|
+
for (let i = 0; i < lines.length; i++) {
|
|
691
|
+
if (results.length >= limit)
|
|
692
|
+
break;
|
|
693
|
+
if (regex.test(lines[i])) {
|
|
694
|
+
results.push({ filePath, line: i + 1, text: lines[i].trim().slice(0, 200) });
|
|
695
|
+
}
|
|
696
|
+
regex.lastIndex = 0;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
res.json({ results });
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
res.status(500).json({ error: err.message || 'Grep failed' });
|
|
703
|
+
}
|
|
704
|
+
});
|
|
328
705
|
// List all processes
|
|
329
706
|
app.get('/api/processes', async (req, res) => {
|
|
330
707
|
try {
|
|
@@ -351,7 +728,9 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
351
728
|
res.json(result);
|
|
352
729
|
}
|
|
353
730
|
catch (err) {
|
|
354
|
-
res
|
|
731
|
+
res
|
|
732
|
+
.status(statusFromError(err))
|
|
733
|
+
.json({ error: err.message || 'Failed to query process detail' });
|
|
355
734
|
}
|
|
356
735
|
});
|
|
357
736
|
// List all clusters
|
|
@@ -380,25 +759,397 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
380
759
|
res.json(result);
|
|
381
760
|
}
|
|
382
761
|
catch (err) {
|
|
383
|
-
res
|
|
762
|
+
res
|
|
763
|
+
.status(statusFromError(err))
|
|
764
|
+
.json({ error: err.message || 'Failed to query cluster detail' });
|
|
384
765
|
}
|
|
385
766
|
});
|
|
767
|
+
// ── Analyze API ──────────────────────────────────────────────────────
|
|
768
|
+
// POST /api/analyze — start a new analysis job
|
|
769
|
+
app.post('/api/analyze', async (req, res) => {
|
|
770
|
+
try {
|
|
771
|
+
const { url: repoUrl, path: repoLocalPath, force, embeddings } = req.body;
|
|
772
|
+
// Input type validation
|
|
773
|
+
if (repoUrl !== undefined && typeof repoUrl !== 'string') {
|
|
774
|
+
res.status(400).json({ error: '"url" must be a string' });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (repoLocalPath !== undefined && typeof repoLocalPath !== 'string') {
|
|
778
|
+
res.status(400).json({ error: '"path" must be a string' });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (!repoUrl && !repoLocalPath) {
|
|
782
|
+
res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
// Path validation: require absolute path, reject traversal (e.g. /tmp/../etc/passwd)
|
|
786
|
+
if (repoLocalPath) {
|
|
787
|
+
if (!path.isAbsolute(repoLocalPath)) {
|
|
788
|
+
res.status(400).json({ error: '"path" must be an absolute path' });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (path.normalize(repoLocalPath) !== path.resolve(repoLocalPath)) {
|
|
792
|
+
res.status(400).json({ error: '"path" must not contain traversal sequences' });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
|
|
797
|
+
// If job was already running (dedup), just return its id
|
|
798
|
+
if (job.status !== 'queued') {
|
|
799
|
+
res.status(202).json({ jobId: job.id, status: job.status });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Mark as active synchronously to prevent race with concurrent requests
|
|
803
|
+
jobManager.updateJob(job.id, { status: 'cloning' });
|
|
804
|
+
// Start async work — don't await
|
|
805
|
+
(async () => {
|
|
806
|
+
let targetPath = repoLocalPath;
|
|
807
|
+
try {
|
|
808
|
+
// Clone if URL provided
|
|
809
|
+
if (repoUrl && !repoLocalPath) {
|
|
810
|
+
const repoName = extractRepoName(repoUrl);
|
|
811
|
+
targetPath = getCloneDir(repoName);
|
|
812
|
+
jobManager.updateJob(job.id, {
|
|
813
|
+
status: 'cloning',
|
|
814
|
+
repoName,
|
|
815
|
+
progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
|
|
816
|
+
});
|
|
817
|
+
await cloneOrPull(repoUrl, targetPath, (progress) => {
|
|
818
|
+
jobManager.updateJob(job.id, {
|
|
819
|
+
progress: { phase: progress.phase, percent: 5, message: progress.message },
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (!targetPath) {
|
|
824
|
+
throw new Error('No target path resolved');
|
|
825
|
+
}
|
|
826
|
+
// Acquire shared repo lock (keyed on storagePath to match embed handler)
|
|
827
|
+
const analyzeLockKey = getStoragePath(targetPath);
|
|
828
|
+
const lockErr = acquireRepoLock(analyzeLockKey);
|
|
829
|
+
if (lockErr) {
|
|
830
|
+
jobManager.updateJob(job.id, { status: 'failed', error: lockErr });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
|
|
834
|
+
// ── Worker fork with auto-retry ──────────────────────────────
|
|
835
|
+
//
|
|
836
|
+
// Forks a child process with 8GB heap. If the worker crashes
|
|
837
|
+
// (OOM, native addon segfault, etc.), it retries up to
|
|
838
|
+
// MAX_WORKER_RETRIES times with exponential backoff before
|
|
839
|
+
// marking the job as permanently failed.
|
|
840
|
+
//
|
|
841
|
+
// In dev mode (tsx), registers the tsx ESM hook via a file://
|
|
842
|
+
// URL so the child can compile TypeScript on-the-fly.
|
|
843
|
+
const MAX_WORKER_RETRIES = 2;
|
|
844
|
+
const callerPath = fileURLToPath(import.meta.url);
|
|
845
|
+
const isDev = callerPath.endsWith('.ts');
|
|
846
|
+
const workerFile = isDev ? 'analyze-worker.ts' : 'analyze-worker.js';
|
|
847
|
+
const workerPath = path.join(path.dirname(callerPath), workerFile);
|
|
848
|
+
const tsxHookArgs = isDev
|
|
849
|
+
? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
|
|
850
|
+
: [];
|
|
851
|
+
const forkWorker = () => {
|
|
852
|
+
const currentJob = jobManager.getJob(job.id);
|
|
853
|
+
if (!currentJob || currentJob.status === 'complete' || currentJob.status === 'failed')
|
|
854
|
+
return;
|
|
855
|
+
const child = fork(workerPath, [], {
|
|
856
|
+
execArgv: [...tsxHookArgs, '--max-old-space-size=8192'],
|
|
857
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
858
|
+
});
|
|
859
|
+
// Capture stderr for crash diagnostics
|
|
860
|
+
let stderrChunks = '';
|
|
861
|
+
child.stderr?.on('data', (chunk) => {
|
|
862
|
+
stderrChunks += chunk.toString();
|
|
863
|
+
if (stderrChunks.length > 4096)
|
|
864
|
+
stderrChunks = stderrChunks.slice(-4096);
|
|
865
|
+
});
|
|
866
|
+
child.on('message', (msg) => {
|
|
867
|
+
if (msg.type === 'progress') {
|
|
868
|
+
jobManager.updateJob(job.id, {
|
|
869
|
+
status: 'analyzing',
|
|
870
|
+
progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else if (msg.type === 'complete') {
|
|
874
|
+
releaseRepoLock(analyzeLockKey);
|
|
875
|
+
// Reinitialize backend BEFORE marking complete — ensures the new
|
|
876
|
+
// repo is queryable when the client receives the SSE complete event.
|
|
877
|
+
backend
|
|
878
|
+
.init()
|
|
879
|
+
.then(() => {
|
|
880
|
+
jobManager.updateJob(job.id, {
|
|
881
|
+
status: 'complete',
|
|
882
|
+
repoName: msg.result.repoName,
|
|
883
|
+
});
|
|
884
|
+
})
|
|
885
|
+
.catch((err) => {
|
|
886
|
+
console.error('backend.init() failed after analyze:', err);
|
|
887
|
+
jobManager.updateJob(job.id, {
|
|
888
|
+
status: 'failed',
|
|
889
|
+
error: 'Server failed to reload after analysis. Try again.',
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
else if (msg.type === 'error') {
|
|
894
|
+
releaseRepoLock(analyzeLockKey);
|
|
895
|
+
jobManager.updateJob(job.id, {
|
|
896
|
+
status: 'failed',
|
|
897
|
+
error: msg.message,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
child.on('error', (err) => {
|
|
902
|
+
releaseRepoLock(analyzeLockKey);
|
|
903
|
+
jobManager.updateJob(job.id, {
|
|
904
|
+
status: 'failed',
|
|
905
|
+
error: `Worker process error: ${err.message}`,
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
child.on('exit', (code) => {
|
|
909
|
+
const j = jobManager.getJob(job.id);
|
|
910
|
+
if (!j || j.status === 'complete' || j.status === 'failed')
|
|
911
|
+
return;
|
|
912
|
+
// Worker crashed — attempt retry if under the limit
|
|
913
|
+
if (j.retryCount < MAX_WORKER_RETRIES) {
|
|
914
|
+
j.retryCount++;
|
|
915
|
+
const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s
|
|
916
|
+
const lastErr = stderrChunks.trim().split('\n').pop() || '';
|
|
917
|
+
console.warn(`Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` +
|
|
918
|
+
(lastErr ? `: ${lastErr}` : ''));
|
|
919
|
+
jobManager.updateJob(job.id, {
|
|
920
|
+
status: 'analyzing',
|
|
921
|
+
progress: {
|
|
922
|
+
phase: 'retrying',
|
|
923
|
+
percent: j.progress.percent,
|
|
924
|
+
message: `Worker crashed, retrying (${j.retryCount}/${MAX_WORKER_RETRIES})...`,
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
stderrChunks = '';
|
|
928
|
+
setTimeout(forkWorker, delay);
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
// Exhausted retries — permanent failure
|
|
932
|
+
releaseRepoLock(analyzeLockKey);
|
|
933
|
+
jobManager.updateJob(job.id, {
|
|
934
|
+
status: 'failed',
|
|
935
|
+
error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
// Register child for cancellation + timeout tracking
|
|
940
|
+
jobManager.registerChild(job.id, child);
|
|
941
|
+
// Send start command to child
|
|
942
|
+
child.send({
|
|
943
|
+
type: 'start',
|
|
944
|
+
repoPath: targetPath,
|
|
945
|
+
options: { force: !!force, embeddings: !!embeddings },
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
forkWorker();
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
if (targetPath)
|
|
952
|
+
releaseRepoLock(getStoragePath(targetPath));
|
|
953
|
+
jobManager.updateJob(job.id, {
|
|
954
|
+
status: 'failed',
|
|
955
|
+
error: err.message || 'Analysis failed',
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
})();
|
|
959
|
+
res.status(202).json({ jobId: job.id, status: job.status });
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
if (err.message?.includes('already in progress')) {
|
|
963
|
+
res.status(409).json({ error: err.message });
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
res.status(500).json({ error: err.message || 'Failed to start analysis' });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
// GET /api/analyze/:jobId — poll job status
|
|
971
|
+
app.get('/api/analyze/:jobId', (req, res) => {
|
|
972
|
+
const job = jobManager.getJob(req.params.jobId);
|
|
973
|
+
if (!job) {
|
|
974
|
+
res.status(404).json({ error: 'Job not found' });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
res.json({
|
|
978
|
+
id: job.id,
|
|
979
|
+
status: job.status,
|
|
980
|
+
repoUrl: job.repoUrl,
|
|
981
|
+
repoPath: job.repoPath,
|
|
982
|
+
repoName: job.repoName,
|
|
983
|
+
progress: job.progress,
|
|
984
|
+
error: job.error,
|
|
985
|
+
startedAt: job.startedAt,
|
|
986
|
+
completedAt: job.completedAt,
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
// GET /api/analyze/:jobId/progress — SSE stream (shared helper)
|
|
990
|
+
mountSSEProgress(app, '/api/analyze/:jobId/progress', jobManager);
|
|
991
|
+
// DELETE /api/analyze/:jobId — cancel a running analysis job
|
|
992
|
+
app.delete('/api/analyze/:jobId', (req, res) => {
|
|
993
|
+
const job = jobManager.getJob(req.params.jobId);
|
|
994
|
+
if (!job) {
|
|
995
|
+
res.status(404).json({ error: 'Job not found' });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (job.status === 'complete' || job.status === 'failed') {
|
|
999
|
+
res.status(400).json({ error: `Job already ${job.status}` });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
jobManager.cancelJob(req.params.jobId, 'Cancelled by user');
|
|
1003
|
+
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
|
|
1004
|
+
});
|
|
1005
|
+
// ── Embedding endpoints ────────────────────────────────────────────
|
|
1006
|
+
const embedJobManager = new JobManager();
|
|
1007
|
+
// POST /api/embed — trigger server-side embedding generation
|
|
1008
|
+
app.post('/api/embed', async (req, res) => {
|
|
1009
|
+
try {
|
|
1010
|
+
const entry = await resolveRepo(requestedRepo(req));
|
|
1011
|
+
if (!entry) {
|
|
1012
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
// Check shared repo lock — prevent concurrent analyze + embed on same repo
|
|
1016
|
+
const repoLockPath = entry.storagePath;
|
|
1017
|
+
const lockErr = acquireRepoLock(repoLockPath);
|
|
1018
|
+
if (lockErr) {
|
|
1019
|
+
res.status(409).json({ error: lockErr });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const job = embedJobManager.createJob({ repoPath: entry.storagePath });
|
|
1023
|
+
embedJobManager.updateJob(job.id, {
|
|
1024
|
+
repoName: entry.name,
|
|
1025
|
+
status: 'analyzing',
|
|
1026
|
+
progress: { phase: 'analyzing', percent: 0, message: 'Starting embedding generation...' },
|
|
1027
|
+
});
|
|
1028
|
+
// 30-minute timeout for embedding jobs (same as analyze jobs)
|
|
1029
|
+
const EMBED_TIMEOUT_MS = 30 * 60 * 1000;
|
|
1030
|
+
const embedTimeout = setTimeout(() => {
|
|
1031
|
+
const current = embedJobManager.getJob(job.id);
|
|
1032
|
+
if (current && current.status !== 'complete' && current.status !== 'failed') {
|
|
1033
|
+
releaseRepoLock(repoLockPath);
|
|
1034
|
+
embedJobManager.updateJob(job.id, {
|
|
1035
|
+
status: 'failed',
|
|
1036
|
+
error: 'Embedding timed out (30 minute limit)',
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}, EMBED_TIMEOUT_MS);
|
|
1040
|
+
// Run embedding pipeline asynchronously
|
|
1041
|
+
(async () => {
|
|
1042
|
+
try {
|
|
1043
|
+
const lbugPath = path.join(entry.storagePath, 'lbug');
|
|
1044
|
+
await withLbugDb(lbugPath, async () => {
|
|
1045
|
+
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
|
|
1046
|
+
await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (p) => {
|
|
1047
|
+
embedJobManager.updateJob(job.id, {
|
|
1048
|
+
progress: {
|
|
1049
|
+
phase: p.phase === 'ready' ? 'complete' : p.phase === 'error' ? 'failed' : p.phase,
|
|
1050
|
+
percent: p.percent,
|
|
1051
|
+
message: p.phase === 'loading-model'
|
|
1052
|
+
? 'Loading embedding model...'
|
|
1053
|
+
: p.phase === 'embedding'
|
|
1054
|
+
? `Embedding nodes (${p.percent}%)...`
|
|
1055
|
+
: p.phase === 'indexing'
|
|
1056
|
+
? 'Creating vector index...'
|
|
1057
|
+
: p.phase === 'ready'
|
|
1058
|
+
? 'Embeddings complete'
|
|
1059
|
+
: `${p.phase} (${p.percent}%)`,
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
clearTimeout(embedTimeout);
|
|
1065
|
+
releaseRepoLock(repoLockPath);
|
|
1066
|
+
// Don't overwrite 'failed' if the job was cancelled while the pipeline was running
|
|
1067
|
+
const current = embedJobManager.getJob(job.id);
|
|
1068
|
+
if (!current || current.status !== 'failed') {
|
|
1069
|
+
embedJobManager.updateJob(job.id, { status: 'complete' });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
catch (err) {
|
|
1073
|
+
clearTimeout(embedTimeout);
|
|
1074
|
+
releaseRepoLock(repoLockPath);
|
|
1075
|
+
const current = embedJobManager.getJob(job.id);
|
|
1076
|
+
if (!current || current.status !== 'failed') {
|
|
1077
|
+
embedJobManager.updateJob(job.id, {
|
|
1078
|
+
status: 'failed',
|
|
1079
|
+
error: err.message || 'Embedding generation failed',
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
})();
|
|
1084
|
+
res.status(202).json({ jobId: job.id, status: 'analyzing' });
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
if (err.message?.includes('already in progress')) {
|
|
1088
|
+
res.status(409).json({ error: err.message });
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
res.status(500).json({ error: err.message || 'Failed to start embedding generation' });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
// GET /api/embed/:jobId — poll embedding job status
|
|
1096
|
+
app.get('/api/embed/:jobId', (req, res) => {
|
|
1097
|
+
const job = embedJobManager.getJob(req.params.jobId);
|
|
1098
|
+
if (!job) {
|
|
1099
|
+
res.status(404).json({ error: 'Job not found' });
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
res.json({
|
|
1103
|
+
id: job.id,
|
|
1104
|
+
status: job.status,
|
|
1105
|
+
repoName: job.repoName,
|
|
1106
|
+
progress: job.progress,
|
|
1107
|
+
error: job.error,
|
|
1108
|
+
startedAt: job.startedAt,
|
|
1109
|
+
completedAt: job.completedAt,
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
// GET /api/embed/:jobId/progress — SSE stream (shared helper)
|
|
1113
|
+
mountSSEProgress(app, '/api/embed/:jobId/progress', embedJobManager);
|
|
1114
|
+
// DELETE /api/embed/:jobId — cancel embedding job
|
|
1115
|
+
app.delete('/api/embed/:jobId', (req, res) => {
|
|
1116
|
+
const job = embedJobManager.getJob(req.params.jobId);
|
|
1117
|
+
if (!job) {
|
|
1118
|
+
res.status(404).json({ error: 'Job not found' });
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (job.status === 'complete' || job.status === 'failed') {
|
|
1122
|
+
res.status(400).json({ error: `Job already ${job.status}` });
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
embedJobManager.cancelJob(req.params.jobId, 'Cancelled by user');
|
|
1126
|
+
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
|
|
1127
|
+
});
|
|
386
1128
|
// Global error handler — catch anything the route handlers miss
|
|
387
1129
|
app.use((err, _req, res, _next) => {
|
|
388
1130
|
console.error('Unhandled error:', err);
|
|
389
1131
|
res.status(500).json({ error: 'Internal server error' });
|
|
390
1132
|
});
|
|
391
|
-
|
|
392
|
-
|
|
1133
|
+
// Wrap listen in a promise so errors (EADDRINUSE, EACCES, etc.) propagate
|
|
1134
|
+
// to the caller instead of crashing with an unhandled 'error' event.
|
|
1135
|
+
await new Promise((resolve, reject) => {
|
|
1136
|
+
const server = app.listen(port, host, () => {
|
|
1137
|
+
console.log(`GitNexus server running on http://${host}:${port}`);
|
|
1138
|
+
resolve();
|
|
1139
|
+
});
|
|
1140
|
+
server.on('error', (err) => reject(err));
|
|
1141
|
+
// Graceful shutdown — close Express + LadybugDB cleanly
|
|
1142
|
+
const shutdown = async () => {
|
|
1143
|
+
console.log('\nShutting down...');
|
|
1144
|
+
server.close();
|
|
1145
|
+
jobManager.dispose();
|
|
1146
|
+
embedJobManager.dispose();
|
|
1147
|
+
await cleanupMcp();
|
|
1148
|
+
await closeLbug();
|
|
1149
|
+
await backend.disconnect();
|
|
1150
|
+
process.exit(0);
|
|
1151
|
+
};
|
|
1152
|
+
process.once('SIGINT', shutdown);
|
|
1153
|
+
process.once('SIGTERM', shutdown);
|
|
393
1154
|
});
|
|
394
|
-
// Graceful shutdown — close Express + LadybugDB cleanly
|
|
395
|
-
const shutdown = async () => {
|
|
396
|
-
server.close();
|
|
397
|
-
await cleanupMcp();
|
|
398
|
-
await closeLbug();
|
|
399
|
-
await backend.disconnect();
|
|
400
|
-
process.exit(0);
|
|
401
|
-
};
|
|
402
|
-
process.once('SIGINT', shutdown);
|
|
403
|
-
process.once('SIGTERM', shutdown);
|
|
404
1155
|
};
|