@in-the-loop-labs/pair-review 3.2.2 → 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.
Files changed (46) hide show
  1. package/README.md +7 -6
  2. package/package.json +5 -4
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
  6. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
  7. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
  8. package/public/css/repo-settings.css +347 -0
  9. package/public/index.html +46 -9
  10. package/public/js/components/AIPanel.js +79 -37
  11. package/public/js/components/DiffOptionsDropdown.js +84 -1
  12. package/public/js/index.js +31 -6
  13. package/public/js/modules/analysis-history.js +11 -7
  14. package/public/js/pr.js +22 -0
  15. package/public/js/repo-settings.js +334 -6
  16. package/public/repo-settings.html +29 -0
  17. package/src/ai/analyzer.js +28 -19
  18. package/src/ai/claude-cli.js +2 -0
  19. package/src/ai/claude-provider.js +4 -1
  20. package/src/ai/prompts/baseline/consolidation/balanced.js +6 -4
  21. package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
  22. package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
  23. package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
  24. package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
  25. package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
  26. package/src/ai/provider.js +7 -6
  27. package/src/chat/session-manager.js +6 -3
  28. package/src/config.js +230 -38
  29. package/src/database.js +766 -38
  30. package/src/git/worktree-pool-lifecycle.js +674 -0
  31. package/src/git/worktree-pool-usage.js +216 -0
  32. package/src/git/worktree.js +46 -13
  33. package/src/main.js +185 -26
  34. package/src/routes/analyses.js +48 -26
  35. package/src/routes/chat.js +27 -3
  36. package/src/routes/config.js +17 -5
  37. package/src/routes/executable-analysis.js +38 -19
  38. package/src/routes/local.js +19 -6
  39. package/src/routes/mcp.js +13 -2
  40. package/src/routes/pr.js +72 -29
  41. package/src/routes/setup.js +41 -4
  42. package/src/routes/stack-analysis.js +29 -10
  43. package/src/routes/worktrees.js +294 -9
  44. package/src/server.js +20 -3
  45. package/src/setup/pr-setup.js +161 -27
  46. package/src/ws/server.js +51 -1
@@ -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
- await run(db, 'DELETE FROM worktrees WHERE id = ?', [row.worktree_id]);
181
- logger.info(`Cleaned up stale worktree record ${row.worktree_id}`);
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
- await run(db, 'DELETE FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
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
- if (worktree && worktree.path) {
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 result = await deleteReviewById(db, metadataId);
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, 'SELECT id FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
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) => {