gitnexus 1.4.9 → 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.
Files changed (186) hide show
  1. package/README.md +6 -5
  2. package/dist/cli/ai-context.d.ts +4 -1
  3. package/dist/cli/ai-context.js +19 -11
  4. package/dist/cli/analyze.d.ts +6 -0
  5. package/dist/cli/analyze.js +105 -251
  6. package/dist/cli/eval-server.js +20 -11
  7. package/dist/cli/index-repo.js +20 -22
  8. package/dist/cli/index.js +8 -7
  9. package/dist/cli/mcp.js +1 -1
  10. package/dist/cli/serve.js +29 -1
  11. package/dist/cli/setup.js +9 -9
  12. package/dist/cli/skill-gen.js +15 -9
  13. package/dist/cli/wiki.d.ts +2 -0
  14. package/dist/cli/wiki.js +141 -26
  15. package/dist/config/ignore-service.js +102 -22
  16. package/dist/config/supported-languages.d.ts +8 -42
  17. package/dist/config/supported-languages.js +8 -43
  18. package/dist/core/augmentation/engine.js +19 -7
  19. package/dist/core/embeddings/embedder.js +19 -15
  20. package/dist/core/embeddings/embedding-pipeline.js +6 -6
  21. package/dist/core/embeddings/http-client.js +3 -3
  22. package/dist/core/embeddings/text-generator.js +9 -24
  23. package/dist/core/embeddings/types.d.ts +1 -1
  24. package/dist/core/embeddings/types.js +1 -7
  25. package/dist/core/graph/graph.js +6 -2
  26. package/dist/core/graph/types.d.ts +9 -59
  27. package/dist/core/ingestion/ast-cache.js +3 -3
  28. package/dist/core/ingestion/call-processor.d.ts +20 -2
  29. package/dist/core/ingestion/call-processor.js +347 -144
  30. package/dist/core/ingestion/call-routing.js +10 -4
  31. package/dist/core/ingestion/call-sites/extract-language-call-site.d.ts +10 -0
  32. package/dist/core/ingestion/call-sites/extract-language-call-site.js +22 -0
  33. package/dist/core/ingestion/call-sites/java.d.ts +9 -0
  34. package/dist/core/ingestion/call-sites/java.js +30 -0
  35. package/dist/core/ingestion/cluster-enricher.js +6 -8
  36. package/dist/core/ingestion/cobol/cobol-copy-expander.js +10 -3
  37. package/dist/core/ingestion/cobol/cobol-preprocessor.js +287 -81
  38. package/dist/core/ingestion/cobol/jcl-parser.js +1 -1
  39. package/dist/core/ingestion/cobol/jcl-processor.js +1 -1
  40. package/dist/core/ingestion/cobol-processor.js +102 -56
  41. package/dist/core/ingestion/community-processor.js +21 -15
  42. package/dist/core/ingestion/entry-point-scoring.d.ts +1 -1
  43. package/dist/core/ingestion/entry-point-scoring.js +5 -6
  44. package/dist/core/ingestion/export-detection.js +32 -9
  45. package/dist/core/ingestion/field-extractor.d.ts +1 -1
  46. package/dist/core/ingestion/field-extractors/configs/c-cpp.js +8 -12
  47. package/dist/core/ingestion/field-extractors/configs/csharp.js +45 -2
  48. package/dist/core/ingestion/field-extractors/configs/dart.js +5 -3
  49. package/dist/core/ingestion/field-extractors/configs/go.js +3 -7
  50. package/dist/core/ingestion/field-extractors/configs/helpers.d.ts +5 -0
  51. package/dist/core/ingestion/field-extractors/configs/helpers.js +14 -0
  52. package/dist/core/ingestion/field-extractors/configs/jvm.js +7 -7
  53. package/dist/core/ingestion/field-extractors/configs/php.js +9 -11
  54. package/dist/core/ingestion/field-extractors/configs/python.js +1 -1
  55. package/dist/core/ingestion/field-extractors/configs/ruby.js +4 -3
  56. package/dist/core/ingestion/field-extractors/configs/rust.js +2 -5
  57. package/dist/core/ingestion/field-extractors/configs/swift.js +9 -7
  58. package/dist/core/ingestion/field-extractors/configs/typescript-javascript.js +2 -6
  59. package/dist/core/ingestion/field-extractors/generic.d.ts +5 -2
  60. package/dist/core/ingestion/field-extractors/generic.js +6 -0
  61. package/dist/core/ingestion/field-extractors/typescript.d.ts +1 -1
  62. package/dist/core/ingestion/field-extractors/typescript.js +1 -1
  63. package/dist/core/ingestion/field-types.d.ts +4 -2
  64. package/dist/core/ingestion/filesystem-walker.js +3 -3
  65. package/dist/core/ingestion/framework-detection.d.ts +1 -1
  66. package/dist/core/ingestion/framework-detection.js +355 -85
  67. package/dist/core/ingestion/heritage-processor.d.ts +24 -0
  68. package/dist/core/ingestion/heritage-processor.js +99 -8
  69. package/dist/core/ingestion/import-processor.js +44 -15
  70. package/dist/core/ingestion/import-resolvers/csharp.js +7 -3
  71. package/dist/core/ingestion/import-resolvers/dart.js +1 -1
  72. package/dist/core/ingestion/import-resolvers/go.js +4 -2
  73. package/dist/core/ingestion/import-resolvers/jvm.js +4 -4
  74. package/dist/core/ingestion/import-resolvers/php.js +4 -4
  75. package/dist/core/ingestion/import-resolvers/python.js +1 -1
  76. package/dist/core/ingestion/import-resolvers/rust.js +9 -3
  77. package/dist/core/ingestion/import-resolvers/standard.d.ts +1 -1
  78. package/dist/core/ingestion/import-resolvers/standard.js +6 -5
  79. package/dist/core/ingestion/import-resolvers/swift.js +2 -1
  80. package/dist/core/ingestion/import-resolvers/utils.js +26 -7
  81. package/dist/core/ingestion/language-config.js +5 -4
  82. package/dist/core/ingestion/language-provider.d.ts +7 -2
  83. package/dist/core/ingestion/languages/c-cpp.js +106 -21
  84. package/dist/core/ingestion/languages/cobol.js +1 -1
  85. package/dist/core/ingestion/languages/csharp.js +96 -19
  86. package/dist/core/ingestion/languages/dart.js +23 -7
  87. package/dist/core/ingestion/languages/go.js +1 -1
  88. package/dist/core/ingestion/languages/index.d.ts +1 -1
  89. package/dist/core/ingestion/languages/index.js +2 -3
  90. package/dist/core/ingestion/languages/java.js +4 -1
  91. package/dist/core/ingestion/languages/kotlin.js +60 -13
  92. package/dist/core/ingestion/languages/php.js +102 -25
  93. package/dist/core/ingestion/languages/python.js +28 -5
  94. package/dist/core/ingestion/languages/ruby.js +56 -14
  95. package/dist/core/ingestion/languages/rust.js +55 -11
  96. package/dist/core/ingestion/languages/swift.js +112 -27
  97. package/dist/core/ingestion/languages/typescript.js +95 -19
  98. package/dist/core/ingestion/markdown-processor.js +5 -5
  99. package/dist/core/ingestion/method-extractors/configs/csharp.d.ts +2 -0
  100. package/dist/core/ingestion/method-extractors/configs/csharp.js +283 -0
  101. package/dist/core/ingestion/method-extractors/configs/jvm.d.ts +3 -0
  102. package/dist/core/ingestion/method-extractors/configs/jvm.js +326 -0
  103. package/dist/core/ingestion/method-extractors/generic.d.ts +5 -0
  104. package/dist/core/ingestion/method-extractors/generic.js +137 -0
  105. package/dist/core/ingestion/method-types.d.ts +61 -0
  106. package/dist/core/ingestion/method-types.js +2 -0
  107. package/dist/core/ingestion/mro-processor.d.ts +1 -1
  108. package/dist/core/ingestion/mro-processor.js +12 -8
  109. package/dist/core/ingestion/named-binding-processor.js +2 -2
  110. package/dist/core/ingestion/named-bindings/rust.js +3 -1
  111. package/dist/core/ingestion/parsing-processor.js +74 -24
  112. package/dist/core/ingestion/pipeline.d.ts +2 -1
  113. package/dist/core/ingestion/pipeline.js +208 -102
  114. package/dist/core/ingestion/process-processor.js +12 -10
  115. package/dist/core/ingestion/resolution-context.js +3 -3
  116. package/dist/core/ingestion/route-extractors/middleware.js +31 -7
  117. package/dist/core/ingestion/route-extractors/php.js +2 -1
  118. package/dist/core/ingestion/route-extractors/response-shapes.js +8 -4
  119. package/dist/core/ingestion/structure-processor.d.ts +1 -1
  120. package/dist/core/ingestion/structure-processor.js +4 -4
  121. package/dist/core/ingestion/symbol-table.d.ts +1 -1
  122. package/dist/core/ingestion/symbol-table.js +22 -6
  123. package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -1
  124. package/dist/core/ingestion/tree-sitter-queries.js +1 -1
  125. package/dist/core/ingestion/type-env.d.ts +2 -2
  126. package/dist/core/ingestion/type-env.js +75 -50
  127. package/dist/core/ingestion/type-extractors/c-cpp.js +33 -30
  128. package/dist/core/ingestion/type-extractors/csharp.js +24 -14
  129. package/dist/core/ingestion/type-extractors/dart.js +6 -8
  130. package/dist/core/ingestion/type-extractors/go.js +7 -6
  131. package/dist/core/ingestion/type-extractors/jvm.js +10 -21
  132. package/dist/core/ingestion/type-extractors/php.js +26 -13
  133. package/dist/core/ingestion/type-extractors/python.js +11 -15
  134. package/dist/core/ingestion/type-extractors/ruby.js +8 -3
  135. package/dist/core/ingestion/type-extractors/rust.js +6 -8
  136. package/dist/core/ingestion/type-extractors/shared.js +134 -50
  137. package/dist/core/ingestion/type-extractors/swift.js +16 -13
  138. package/dist/core/ingestion/type-extractors/typescript.js +23 -15
  139. package/dist/core/ingestion/utils/ast-helpers.d.ts +8 -8
  140. package/dist/core/ingestion/utils/ast-helpers.js +72 -35
  141. package/dist/core/ingestion/utils/call-analysis.d.ts +2 -0
  142. package/dist/core/ingestion/utils/call-analysis.js +96 -49
  143. package/dist/core/ingestion/utils/event-loop.js +1 -1
  144. package/dist/core/ingestion/workers/parse-worker.d.ts +7 -2
  145. package/dist/core/ingestion/workers/parse-worker.js +364 -84
  146. package/dist/core/ingestion/workers/worker-pool.js +5 -10
  147. package/dist/core/lbug/csv-generator.js +54 -15
  148. package/dist/core/lbug/lbug-adapter.d.ts +5 -0
  149. package/dist/core/lbug/lbug-adapter.js +86 -23
  150. package/dist/core/lbug/schema.d.ts +3 -6
  151. package/dist/core/lbug/schema.js +6 -30
  152. package/dist/core/run-analyze.d.ts +49 -0
  153. package/dist/core/run-analyze.js +257 -0
  154. package/dist/core/tree-sitter/parser-loader.d.ts +1 -1
  155. package/dist/core/tree-sitter/parser-loader.js +1 -1
  156. package/dist/core/wiki/cursor-client.js +2 -7
  157. package/dist/core/wiki/generator.js +38 -23
  158. package/dist/core/wiki/graph-queries.js +10 -10
  159. package/dist/core/wiki/html-viewer.js +7 -3
  160. package/dist/core/wiki/llm-client.d.ts +23 -2
  161. package/dist/core/wiki/llm-client.js +96 -26
  162. package/dist/core/wiki/prompts.js +7 -6
  163. package/dist/mcp/core/embedder.js +1 -1
  164. package/dist/mcp/core/lbug-adapter.d.ts +4 -1
  165. package/dist/mcp/core/lbug-adapter.js +17 -7
  166. package/dist/mcp/local/local-backend.js +247 -95
  167. package/dist/mcp/resources.js +14 -6
  168. package/dist/mcp/server.js +13 -5
  169. package/dist/mcp/staleness.js +5 -1
  170. package/dist/mcp/tools.js +100 -23
  171. package/dist/server/analyze-job.d.ts +53 -0
  172. package/dist/server/analyze-job.js +146 -0
  173. package/dist/server/analyze-worker.d.ts +13 -0
  174. package/dist/server/analyze-worker.js +59 -0
  175. package/dist/server/api.js +795 -44
  176. package/dist/server/git-clone.d.ts +25 -0
  177. package/dist/server/git-clone.js +91 -0
  178. package/dist/storage/git.js +1 -3
  179. package/dist/storage/repo-manager.d.ts +5 -2
  180. package/dist/storage/repo-manager.js +4 -4
  181. package/dist/types/pipeline.d.ts +1 -21
  182. package/dist/types/pipeline.js +1 -18
  183. package/hooks/claude/gitnexus-hook.cjs +52 -22
  184. package/package.json +13 -13
  185. package/dist/core/ingestion/utils/language-detection.d.ts +0 -9
  186. package/dist/core/ingestion/utils/language-detection.js +0 -70
@@ -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 { loadMeta, listRegisteredRepos } from '../storage/repo-manager.js';
15
- import { executeQuery, closeLbug, withLbugDb } from '../core/lbug/lbug-adapter.js';
16
- import { NODE_TABLES } from '../core/lbug/schema.js';
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
- || origin === 'http://localhost'
47
- || origin.startsWith('http://127.0.0.1:')
48
- || origin === 'http://127.0.0.1'
49
- || origin.startsWith('http://[::1]:')
50
- || origin === 'http://[::1]'
51
- || origin === 'https://gitnexus.vercel.app') {
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 = `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`;
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 = `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`;
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, path: r.path, indexedAt: r.indexedAt,
199
- lastCommit: r.lastCommit, stats: r.stats,
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 graph = await withLbugDb(lbugPath, async () => buildGraph());
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
- const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
283
- if (isEmbedderReady()) {
284
- const { semanticSearch } = await import('../core/embeddings/embedding-pipeline.js');
285
- return hybridSearch(query, limit, executeQuery, semanticSearch);
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
- // FTS-only fallback when embeddings aren't loaded
288
- return searchFTSFromLbug(query, limit);
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 content = await fs.readFile(fullPath, 'utf-8');
317
- res.json({ content });
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.status(statusFromError(err)).json({ error: err.message || 'Failed to query process detail' });
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.status(statusFromError(err)).json({ error: err.message || 'Failed to query cluster detail' });
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
- const server = app.listen(port, host, () => {
392
- console.log(`GitNexus server running on http://${host}:${port}`);
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
  };