@in-the-loop-labs/pair-review 3.2.3 → 3.3.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 +7 -6
- package/package.json +5 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/repo-settings.css +347 -0
- package/public/index.html +46 -9
- package/public/js/components/AIPanel.js +79 -37
- package/public/js/components/DiffOptionsDropdown.js +84 -1
- package/public/js/index.js +31 -6
- package/public/js/pr.js +22 -0
- package/public/js/repo-settings.js +334 -6
- package/public/repo-settings.html +29 -0
- package/src/ai/analyzer.js +28 -19
- package/src/ai/claude-cli.js +2 -0
- package/src/ai/claude-provider.js +4 -1
- package/src/ai/provider.js +7 -6
- package/src/chat/session-manager.js +6 -3
- package/src/config.js +230 -38
- package/src/database.js +766 -38
- package/src/git/worktree-pool-lifecycle.js +674 -0
- package/src/git/worktree-pool-usage.js +216 -0
- package/src/git/worktree.js +46 -13
- package/src/main.js +185 -26
- package/src/routes/analyses.js +48 -26
- package/src/routes/chat.js +27 -3
- package/src/routes/config.js +17 -5
- package/src/routes/executable-analysis.js +38 -19
- package/src/routes/local.js +19 -6
- package/src/routes/mcp.js +13 -2
- package/src/routes/pr.js +72 -29
- package/src/routes/setup.js +41 -4
- package/src/routes/stack-analysis.js +29 -10
- package/src/routes/worktrees.js +294 -9
- package/src/server.js +20 -3
- package/src/setup/pr-setup.js +161 -27
- package/src/ws/server.js +51 -1
package/src/routes/worktrees.js
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const express = require('express');
|
|
12
|
-
const { query, queryOne, run, ReviewRepository } = require('../database');
|
|
12
|
+
const { query, queryOne, run, ReviewRepository, WorktreeRepository, WorktreePoolRepository, AnalysisRunRepository, RepoSettingsRepository } = require('../database');
|
|
13
13
|
const { setupPRReview } = require('../setup/pr-setup');
|
|
14
14
|
const { GitHubApiError } = require('../github/client');
|
|
15
15
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
16
|
+
const { activeAnalyses, reviewToAnalysisId, killProcesses, broadcastProgress } = require('./shared');
|
|
16
17
|
const fs = require('fs').promises;
|
|
17
18
|
const logger = require('../utils/logger');
|
|
19
|
+
const { resolvePoolConfig } = require('../config');
|
|
18
20
|
|
|
19
21
|
const router = express.Router();
|
|
20
22
|
|
|
@@ -64,6 +66,7 @@ router.post('/api/worktrees/create', async (req, res) => {
|
|
|
64
66
|
prNumber: parsedPrNumber,
|
|
65
67
|
githubToken,
|
|
66
68
|
config,
|
|
69
|
+
poolLifecycle: req.app.get('poolLifecycle'),
|
|
67
70
|
onProgress: (progress) => {
|
|
68
71
|
logger.info(`[Setup] ${progress.step}: ${progress.message}`);
|
|
69
72
|
}
|
|
@@ -173,12 +176,17 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
173
176
|
await fs.access(row.worktree_path);
|
|
174
177
|
storageStatus = 'local';
|
|
175
178
|
} catch {
|
|
176
|
-
// Worktree dir missing — clean up stale record asynchronously on first page
|
|
179
|
+
// Worktree dir missing — clean up stale record asynchronously on first page,
|
|
180
|
+
// but only if it's not a pool worktree (pool worktrees are managed separately)
|
|
177
181
|
if (!before) {
|
|
178
182
|
setImmediate(async () => {
|
|
179
183
|
try {
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
const poolRepo = new WorktreePoolRepository(db);
|
|
185
|
+
const isPool = await poolRepo.isPoolWorktree(row.worktree_id);
|
|
186
|
+
if (!isPool) {
|
|
187
|
+
await run(db, 'DELETE FROM worktrees WHERE id = ?', [row.worktree_id]);
|
|
188
|
+
logger.info(`Cleaned up stale worktree record ${row.worktree_id}`);
|
|
189
|
+
}
|
|
182
190
|
} catch (err) {
|
|
183
191
|
logger.warn(`Failed to cleanup stale worktree: ${err.message}`);
|
|
184
192
|
}
|
|
@@ -226,10 +234,11 @@ router.get('/api/worktrees/recent', async (req, res) => {
|
|
|
226
234
|
*
|
|
227
235
|
* @param {object} db - Database handle
|
|
228
236
|
* @param {number} metadataId - pr_metadata.id
|
|
237
|
+
* @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [poolLifecycle] - Pool lifecycle manager (optional)
|
|
229
238
|
* @returns {{ success: boolean, message: string }}
|
|
230
239
|
* @throws {Error} if deletion fails
|
|
231
240
|
*/
|
|
232
|
-
async function deleteReviewById(db, metadataId) {
|
|
241
|
+
async function deleteReviewById(db, metadataId, poolLifecycle) {
|
|
233
242
|
const metadata = await queryOne(db, `
|
|
234
243
|
SELECT id, pr_number, repository FROM pr_metadata WHERE id = ?
|
|
235
244
|
`, [metadataId]);
|
|
@@ -246,10 +255,17 @@ async function deleteReviewById(db, metadataId) {
|
|
|
246
255
|
SELECT id, path FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
247
256
|
`, [prNumber, repository]);
|
|
248
257
|
|
|
258
|
+
// Check if this worktree belongs to the pool — pool worktrees are preserved
|
|
259
|
+
const poolRepo = poolLifecycle ? poolLifecycle.poolRepo : new WorktreePoolRepository(db);
|
|
260
|
+
const isPool = worktree ? await poolRepo.isPoolWorktree(worktree.id) : false;
|
|
261
|
+
|
|
249
262
|
// Delete all associated database records in a transaction
|
|
250
263
|
await run(db, 'BEGIN TRANSACTION');
|
|
251
264
|
try {
|
|
252
|
-
|
|
265
|
+
// Pool worktrees: keep the worktrees row and pool entry intact
|
|
266
|
+
if (!isPool) {
|
|
267
|
+
await run(db, 'DELETE FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
|
|
268
|
+
}
|
|
253
269
|
await run(db, 'DELETE FROM chat_sessions WHERE review_id IN (SELECT id FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE)', [prNumber, repository]);
|
|
254
270
|
await run(db, `
|
|
255
271
|
DELETE FROM comments WHERE review_id IN (
|
|
@@ -270,7 +286,37 @@ async function deleteReviewById(db, metadataId) {
|
|
|
270
286
|
}
|
|
271
287
|
|
|
272
288
|
// Clean up worktree AFTER successful DB commit so rollback doesn't orphan data
|
|
273
|
-
|
|
289
|
+
// Pool worktrees: skip filesystem cleanup, mark as available instead
|
|
290
|
+
if (isPool) {
|
|
291
|
+
// Cancel any active analyses reading from this worktree before returning
|
|
292
|
+
// the slot to the pool. Without this, a reclaimed worktree could have its
|
|
293
|
+
// filesystem switched out from under a still-running analysis subprocess.
|
|
294
|
+
const activeAnalysisIds = poolLifecycle ? poolLifecycle.getActiveAnalyses(worktree.id) : new Set();
|
|
295
|
+
if (activeAnalysisIds.size > 0) {
|
|
296
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
297
|
+
for (const analysisId of activeAnalysisIds) {
|
|
298
|
+
killProcesses(analysisId);
|
|
299
|
+
const analysis = activeAnalyses.get(analysisId);
|
|
300
|
+
if (analysis) {
|
|
301
|
+
const cancelledStatus = { ...analysis, status: 'cancelled', cancelledAt: new Date().toISOString(), progress: 'Cancelled — review deleted' };
|
|
302
|
+
activeAnalyses.set(analysisId, cancelledStatus);
|
|
303
|
+
broadcastProgress(analysisId, cancelledStatus);
|
|
304
|
+
if (analysis.reviewId) reviewToAnalysisId.delete(analysis.reviewId);
|
|
305
|
+
}
|
|
306
|
+
if (analysis?.runId) {
|
|
307
|
+
try { await analysisRunRepo.update(analysis.runId, { status: 'cancelled' }); } catch { /* best effort */ }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
logger.info(`Cancelled ${activeAnalysisIds.size} active analysis(es) on pool worktree ${worktree.id}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Release all in-memory tracking and mark the slot available in DB so it
|
|
314
|
+
// cleanly returns to the pool.
|
|
315
|
+
if (poolLifecycle) {
|
|
316
|
+
await poolLifecycle.releaseForDeletion(worktree.id);
|
|
317
|
+
}
|
|
318
|
+
logger.info(`Pool worktree ${worktree.id} cleared and marked available after review deletion`);
|
|
319
|
+
} else if (worktree && worktree.path) {
|
|
274
320
|
try {
|
|
275
321
|
const worktreeManager = new GitWorktreeManager(db);
|
|
276
322
|
await worktreeManager.cleanupWorktree(worktree.path);
|
|
@@ -302,7 +348,8 @@ router.delete('/api/worktrees/:id', async (req, res) => {
|
|
|
302
348
|
}
|
|
303
349
|
|
|
304
350
|
const db = req.app.get('db');
|
|
305
|
-
const
|
|
351
|
+
const poolLifecycle = req.app.get('poolLifecycle');
|
|
352
|
+
const result = await deleteReviewById(db, metadataId, poolLifecycle);
|
|
306
353
|
|
|
307
354
|
if (!result.success) {
|
|
308
355
|
return res.status(404).json({
|
|
@@ -357,12 +404,13 @@ router.post('/api/worktrees/bulk-delete', async (req, res) => {
|
|
|
357
404
|
}
|
|
358
405
|
|
|
359
406
|
const db = req.app.get('db');
|
|
407
|
+
const poolLifecycle = req.app.get('poolLifecycle');
|
|
360
408
|
let deleted = 0;
|
|
361
409
|
const errors = [];
|
|
362
410
|
|
|
363
411
|
for (const id of parsedIds) {
|
|
364
412
|
try {
|
|
365
|
-
const result = await deleteReviewById(db, id);
|
|
413
|
+
const result = await deleteReviewById(db, id, poolLifecycle);
|
|
366
414
|
if (result.success) {
|
|
367
415
|
deleted++;
|
|
368
416
|
} else {
|
|
@@ -391,4 +439,241 @@ router.post('/api/worktrees/bulk-delete', async (req, res) => {
|
|
|
391
439
|
}
|
|
392
440
|
});
|
|
393
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Build a cancel-analyses callback for destroyPoolWorktree.
|
|
444
|
+
* Reuses the same pattern as deleteReviewById (lines 290-311).
|
|
445
|
+
*/
|
|
446
|
+
function buildCancelAnalysesFn(db) {
|
|
447
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
448
|
+
return async (_worktreeId, analysisIdSet) => {
|
|
449
|
+
for (const analysisId of analysisIdSet) {
|
|
450
|
+
killProcesses(analysisId);
|
|
451
|
+
const analysis = activeAnalyses.get(analysisId);
|
|
452
|
+
if (analysis) {
|
|
453
|
+
const cancelledStatus = {
|
|
454
|
+
...analysis,
|
|
455
|
+
status: 'cancelled',
|
|
456
|
+
cancelledAt: new Date().toISOString(),
|
|
457
|
+
progress: 'Cancelled — worktree deleted',
|
|
458
|
+
};
|
|
459
|
+
activeAnalyses.set(analysisId, cancelledStatus);
|
|
460
|
+
broadcastProgress(analysisId, cancelledStatus);
|
|
461
|
+
if (analysis.reviewId) reviewToAnalysisId.delete(analysis.reviewId);
|
|
462
|
+
}
|
|
463
|
+
if (analysis && analysis.runId) {
|
|
464
|
+
try { await analysisRunRepo.update(analysis.runId, { status: 'cancelled' }); } catch { /* best effort */ }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* List all worktrees for a repository with pool configuration info.
|
|
472
|
+
*/
|
|
473
|
+
router.get('/api/repos/:owner/:repo/worktrees', async (req, res) => {
|
|
474
|
+
try {
|
|
475
|
+
const { owner, repo } = req.params;
|
|
476
|
+
const repository = `${owner}/${repo}`;
|
|
477
|
+
const db = req.app.get('db');
|
|
478
|
+
const config = req.app.get('config');
|
|
479
|
+
|
|
480
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
481
|
+
const poolRepo = new WorktreePoolRepository(db);
|
|
482
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
483
|
+
|
|
484
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
485
|
+
const { poolSize, poolFetchIntervalMinutes: fetchInterval } = resolvePoolConfig(config, repository, repoSettings);
|
|
486
|
+
|
|
487
|
+
const [allWorktrees, poolEntries] = await Promise.all([
|
|
488
|
+
worktreeRepo.findAllByRepository(repository),
|
|
489
|
+
poolRepo.findAllForRepo(repository),
|
|
490
|
+
]);
|
|
491
|
+
|
|
492
|
+
const poolMap = new Map(poolEntries.map(p => [p.id, p]));
|
|
493
|
+
|
|
494
|
+
const worktrees = await Promise.all(allWorktrees.map(async (wt) => {
|
|
495
|
+
const poolEntry = poolMap.get(wt.id);
|
|
496
|
+
let diskExists = false;
|
|
497
|
+
if (wt.path) {
|
|
498
|
+
try {
|
|
499
|
+
await fs.access(wt.path);
|
|
500
|
+
diskExists = true;
|
|
501
|
+
} catch { /* missing */ }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
id: wt.id,
|
|
506
|
+
is_pool: !!poolEntry,
|
|
507
|
+
status: poolEntry ? poolEntry.status : 'active',
|
|
508
|
+
pr_number: poolEntry ? poolEntry.current_pr_number : wt.pr_number,
|
|
509
|
+
branch: wt.branch,
|
|
510
|
+
path: wt.path,
|
|
511
|
+
last_fetched_at: poolEntry ? poolEntry.last_fetched_at : null,
|
|
512
|
+
last_accessed_at: wt.last_accessed_at,
|
|
513
|
+
created_at: poolEntry ? poolEntry.created_at : wt.created_at,
|
|
514
|
+
disk_exists: diskExists,
|
|
515
|
+
};
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
// Include pool entries that have no corresponding worktrees record
|
|
519
|
+
// (e.g. 'creating' placeholders)
|
|
520
|
+
for (const poolEntry of poolEntries) {
|
|
521
|
+
if (!allWorktrees.some(wt => wt.id === poolEntry.id)) {
|
|
522
|
+
worktrees.push({
|
|
523
|
+
id: poolEntry.id,
|
|
524
|
+
is_pool: true,
|
|
525
|
+
status: poolEntry.status,
|
|
526
|
+
pr_number: poolEntry.current_pr_number,
|
|
527
|
+
branch: null,
|
|
528
|
+
path: poolEntry.path,
|
|
529
|
+
last_fetched_at: poolEntry.last_fetched_at,
|
|
530
|
+
last_accessed_at: null,
|
|
531
|
+
created_at: poolEntry.created_at,
|
|
532
|
+
disk_exists: false,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
res.json({
|
|
538
|
+
pool: {
|
|
539
|
+
configured: poolSize > 0,
|
|
540
|
+
size: poolSize,
|
|
541
|
+
fetch_interval_minutes: fetchInterval,
|
|
542
|
+
current_count: poolEntries.length,
|
|
543
|
+
},
|
|
544
|
+
worktrees,
|
|
545
|
+
});
|
|
546
|
+
} catch (error) {
|
|
547
|
+
logger.error('Error listing worktrees:', error);
|
|
548
|
+
res.status(500).json({ error: 'Failed to list worktrees: ' + error.message });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Delete a single worktree by ID (pool or non-pool).
|
|
554
|
+
* Removes from disk and database entirely.
|
|
555
|
+
*/
|
|
556
|
+
router.delete('/api/repos/:owner/:repo/worktrees/:worktreeId', async (req, res) => {
|
|
557
|
+
try {
|
|
558
|
+
const { worktreeId } = req.params;
|
|
559
|
+
const db = req.app.get('db');
|
|
560
|
+
const poolLifecycle = req.app.get('poolLifecycle');
|
|
561
|
+
|
|
562
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
563
|
+
const poolRepo = new WorktreePoolRepository(db);
|
|
564
|
+
|
|
565
|
+
const isPool = await poolRepo.isPoolWorktree(worktreeId);
|
|
566
|
+
|
|
567
|
+
if (isPool) {
|
|
568
|
+
if (!poolLifecycle) {
|
|
569
|
+
return res.status(500).json({ error: 'Pool lifecycle not available' });
|
|
570
|
+
}
|
|
571
|
+
await poolLifecycle.destroyPoolWorktree(worktreeId, {
|
|
572
|
+
cancelAnalyses: buildCancelAnalysesFn(db),
|
|
573
|
+
});
|
|
574
|
+
} else {
|
|
575
|
+
const record = await worktreeRepo.findById(worktreeId);
|
|
576
|
+
if (!record) {
|
|
577
|
+
return res.status(404).json({ error: 'Worktree not found' });
|
|
578
|
+
}
|
|
579
|
+
if (record.path) {
|
|
580
|
+
try {
|
|
581
|
+
const mgr = new GitWorktreeManager(db);
|
|
582
|
+
await mgr.cleanupWorktree(record.path);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
logger.warn(`Could not clean up worktree ${worktreeId} from disk: ${err.message}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
await worktreeRepo.delete(worktreeId);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
logger.success(`Deleted worktree ${worktreeId} (pool: ${isPool})`);
|
|
591
|
+
res.json({ success: true, message: `Worktree ${worktreeId} deleted` });
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error.message && error.message.startsWith('Cannot delete worktree')) {
|
|
594
|
+
return res.status(409).json({ error: error.message });
|
|
595
|
+
}
|
|
596
|
+
logger.error('Error deleting worktree:', error);
|
|
597
|
+
res.status(500).json({ error: 'Failed to delete worktree: ' + error.message });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Delete all worktrees for a repository.
|
|
603
|
+
* Removes each from disk and database. Partial failures reported per-ID.
|
|
604
|
+
*/
|
|
605
|
+
router.delete('/api/repos/:owner/:repo/worktrees', async (req, res) => {
|
|
606
|
+
try {
|
|
607
|
+
const { owner, repo } = req.params;
|
|
608
|
+
const repository = `${owner}/${repo}`;
|
|
609
|
+
const db = req.app.get('db');
|
|
610
|
+
const poolLifecycle = req.app.get('poolLifecycle');
|
|
611
|
+
|
|
612
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
613
|
+
const poolRepo = new WorktreePoolRepository(db);
|
|
614
|
+
|
|
615
|
+
const [allWorktrees, poolEntries] = await Promise.all([
|
|
616
|
+
worktreeRepo.findAllByRepository(repository),
|
|
617
|
+
poolRepo.findAllForRepo(repository),
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
const poolIds = new Set(poolEntries.map(p => p.id));
|
|
621
|
+
const cancelFn = buildCancelAnalysesFn(db);
|
|
622
|
+
|
|
623
|
+
let deleted = 0;
|
|
624
|
+
const errors = [];
|
|
625
|
+
|
|
626
|
+
for (const wt of allWorktrees) {
|
|
627
|
+
try {
|
|
628
|
+
if (poolIds.has(wt.id)) {
|
|
629
|
+
if (poolLifecycle) {
|
|
630
|
+
await poolLifecycle.destroyPoolWorktree(wt.id, { cancelAnalyses: cancelFn });
|
|
631
|
+
} else {
|
|
632
|
+
throw new Error('Pool lifecycle not available');
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
if (wt.path) {
|
|
636
|
+
try {
|
|
637
|
+
const mgr = new GitWorktreeManager(db);
|
|
638
|
+
await mgr.cleanupWorktree(wt.path);
|
|
639
|
+
} catch { /* best effort */ }
|
|
640
|
+
}
|
|
641
|
+
await worktreeRepo.delete(wt.id);
|
|
642
|
+
}
|
|
643
|
+
deleted++;
|
|
644
|
+
} catch (err) {
|
|
645
|
+
errors.push({ id: wt.id, error: err.message });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Handle pool-only entries (no worktrees record)
|
|
650
|
+
for (const pe of poolEntries) {
|
|
651
|
+
if (!allWorktrees.some(wt => wt.id === pe.id)) {
|
|
652
|
+
try {
|
|
653
|
+
if (poolLifecycle) {
|
|
654
|
+
await poolLifecycle.destroyPoolWorktree(pe.id, { cancelAnalyses: cancelFn });
|
|
655
|
+
} else {
|
|
656
|
+
throw new Error('Pool lifecycle not available');
|
|
657
|
+
}
|
|
658
|
+
deleted++;
|
|
659
|
+
} catch (err) {
|
|
660
|
+
errors.push({ id: pe.id, error: err.message });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (deleted > 0) logger.success(`Deleted ${deleted} worktree(s) for ${repository}`);
|
|
666
|
+
|
|
667
|
+
res.json({
|
|
668
|
+
success: deleted > 0 || errors.length === 0,
|
|
669
|
+
deleted,
|
|
670
|
+
failed: errors.length,
|
|
671
|
+
errors,
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
logger.error('Error deleting all worktrees:', error);
|
|
675
|
+
res.status(500).json({ error: 'Failed to delete worktrees: ' + error.message });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
394
679
|
module.exports = router;
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,7 @@ const { applyConfigOverrides, checkAllProviders } = require('./ai');
|
|
|
8
8
|
const { checkAllChatProviders } = require('./chat/chat-providers');
|
|
9
9
|
const logger = require('./utils/logger');
|
|
10
10
|
const { attachWebSocket, closeAll: closeAllWS } = require('./ws');
|
|
11
|
+
const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
|
|
11
12
|
|
|
12
13
|
let db = null;
|
|
13
14
|
let server = null;
|
|
@@ -86,8 +87,9 @@ function findAvailablePort(app, startPort, maxAttempts = 20) {
|
|
|
86
87
|
/**
|
|
87
88
|
* Start the Express server
|
|
88
89
|
* @param {sqlite3.Database} [sharedDb] - Optional shared database instance
|
|
90
|
+
* @param {import('./git/worktree-pool-lifecycle').WorktreePoolLifecycle} [sharedPoolLifecycle] - Optional shared pool lifecycle instance
|
|
89
91
|
*/
|
|
90
|
-
async function startServer(sharedDb = null) {
|
|
92
|
+
async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
91
93
|
try {
|
|
92
94
|
// Load configuration
|
|
93
95
|
const { config } = await loadConfig();
|
|
@@ -251,7 +253,12 @@ async function startServer(sharedDb = null) {
|
|
|
251
253
|
// When a user deletes a worktree, metadata is preserved but the
|
|
252
254
|
// worktree record is removed. Without this check the route serves
|
|
253
255
|
// pr.html for a missing worktree, causing 404s on file fetches.
|
|
254
|
-
const worktree = await queryOne(db,
|
|
256
|
+
const worktree = await queryOne(db, `
|
|
257
|
+
SELECT w.id FROM worktrees w
|
|
258
|
+
LEFT JOIN worktree_pool wp ON w.id = wp.id
|
|
259
|
+
WHERE w.pr_number = ? AND w.repository = ? COLLATE NOCASE
|
|
260
|
+
AND (wp.id IS NULL OR wp.status = 'in_use')
|
|
261
|
+
`, [prNumber, repository]);
|
|
255
262
|
if (worktree) {
|
|
256
263
|
// Update last_accessed_at so the recent reviews list reflects actual access
|
|
257
264
|
run(db, 'UPDATE pr_metadata SET last_accessed_at = ? WHERE id = ?', [new Date().toISOString(), existing.id]).catch(err => logger.warn(`Failed to update last_accessed_at: ${err.message}`));
|
|
@@ -297,6 +304,16 @@ async function startServer(sharedDb = null) {
|
|
|
297
304
|
app.set('githubToken', githubToken);
|
|
298
305
|
app.set('config', config);
|
|
299
306
|
|
|
307
|
+
// Create or reuse the worktree pool lifecycle instance.
|
|
308
|
+
// When called from main.js, the shared instance (already rehydrated) is
|
|
309
|
+
// passed in. When running standalone, create and rehydrate a fresh one.
|
|
310
|
+
let poolLifecycle = sharedPoolLifecycle;
|
|
311
|
+
if (!poolLifecycle) {
|
|
312
|
+
poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
313
|
+
await poolLifecycle.resetAndRehydrate();
|
|
314
|
+
}
|
|
315
|
+
app.set('poolLifecycle', poolLifecycle);
|
|
316
|
+
|
|
300
317
|
// API routes - split into focused modules
|
|
301
318
|
// Order matters: more specific routes must be mounted before general ones
|
|
302
319
|
// to ensure proper route matching
|
|
@@ -366,7 +383,7 @@ async function startServer(sharedDb = null) {
|
|
|
366
383
|
|
|
367
384
|
server = app.listen(port, () => {
|
|
368
385
|
console.log(`Server running on http://localhost:${port}`);
|
|
369
|
-
attachWebSocket(server);
|
|
386
|
+
attachWebSocket(server, db, poolLifecycle);
|
|
370
387
|
});
|
|
371
388
|
|
|
372
389
|
server.on('error', (error) => {
|