@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.
- 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/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +8 -1
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +8 -7
- 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/modules/analysis-history.js +11 -7
- 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/prompts/baseline/consolidation/balanced.js +6 -4
- package/src/ai/prompts/baseline/consolidation/fast.js +6 -2
- package/src/ai/prompts/baseline/consolidation/thorough.js +7 -6
- package/src/ai/prompts/baseline/orchestration/balanced.js +13 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +12 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +8 -7
- 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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
const GRACE_PERIOD_MS = 30_000; // 30 seconds after last WS disconnect
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory tracker that determines whether a pool worktree is "in use".
|
|
10
|
+
*
|
|
11
|
+
* A pool worktree is considered in-use when any of:
|
|
12
|
+
* - At least one WebSocket session is subscribed to its review topic
|
|
13
|
+
* - An AI analysis is running against it
|
|
14
|
+
* - The grace period after the last session disconnect hasn't expired
|
|
15
|
+
*
|
|
16
|
+
* When a worktree becomes idle (all of the above are false), the `onIdle`
|
|
17
|
+
* callback fires so the pool manager can mark it available.
|
|
18
|
+
*/
|
|
19
|
+
class WorktreePoolUsageTracker {
|
|
20
|
+
constructor() {
|
|
21
|
+
/** @type {Map<string, Set<string>>} worktreeId -> Set of active session keys */
|
|
22
|
+
this._sessions = new Map();
|
|
23
|
+
/** @type {Map<string, Set<string>>} worktreeId -> Set of active analysis IDs */
|
|
24
|
+
this._analyses = new Map();
|
|
25
|
+
/** @type {Map<string, NodeJS.Timeout>} worktreeId -> grace period timer */
|
|
26
|
+
this._graceTimers = new Map();
|
|
27
|
+
/** @type {Function|null} Callback when a worktree becomes idle: (worktreeId) => void */
|
|
28
|
+
this.onIdle = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register an active WebSocket session for a worktree.
|
|
33
|
+
* Clears any pending grace-period timer.
|
|
34
|
+
* @param {string} worktreeId - Pool worktree ID
|
|
35
|
+
* @param {string} sessionKey - Unique key for this WS connection
|
|
36
|
+
*/
|
|
37
|
+
addSession(worktreeId, sessionKey) {
|
|
38
|
+
if (!this._sessions.has(worktreeId)) {
|
|
39
|
+
this._sessions.set(worktreeId, new Set());
|
|
40
|
+
}
|
|
41
|
+
this._sessions.get(worktreeId).add(sessionKey);
|
|
42
|
+
|
|
43
|
+
// Cancel any pending grace timer
|
|
44
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
45
|
+
if (timer) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
this._graceTimers.delete(worktreeId);
|
|
48
|
+
logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new session connected`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a WebSocket session. Starts grace period if no sessions remain
|
|
54
|
+
* and no analyses are running.
|
|
55
|
+
* @param {string} worktreeId
|
|
56
|
+
* @param {string} sessionKey
|
|
57
|
+
*/
|
|
58
|
+
removeSession(worktreeId, sessionKey) {
|
|
59
|
+
const sessions = this._sessions.get(worktreeId);
|
|
60
|
+
if (!sessions) return;
|
|
61
|
+
|
|
62
|
+
sessions.delete(sessionKey);
|
|
63
|
+
if (sessions.size === 0) {
|
|
64
|
+
this._sessions.delete(worktreeId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this._checkIdle(worktreeId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Register an active analysis for a worktree.
|
|
72
|
+
* Clears any pending grace-period timer.
|
|
73
|
+
* @param {string} worktreeId - Pool worktree ID
|
|
74
|
+
* @param {string} analysisId - Unique analysis identifier
|
|
75
|
+
*/
|
|
76
|
+
addAnalysis(worktreeId, analysisId) {
|
|
77
|
+
if (!this._analyses.has(worktreeId)) {
|
|
78
|
+
this._analyses.set(worktreeId, new Set());
|
|
79
|
+
}
|
|
80
|
+
this._analyses.get(worktreeId).add(analysisId);
|
|
81
|
+
|
|
82
|
+
// Cancel any pending grace timer
|
|
83
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
84
|
+
if (timer) {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
this._graceTimers.delete(worktreeId);
|
|
87
|
+
logger.debug(`Grace period cancelled for pool worktree ${worktreeId} — new analysis started`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove an active analysis hold.
|
|
93
|
+
* @param {string} worktreeId
|
|
94
|
+
* @param {string} analysisId
|
|
95
|
+
*/
|
|
96
|
+
removeAnalysis(worktreeId, analysisId) {
|
|
97
|
+
const analyses = this._analyses.get(worktreeId);
|
|
98
|
+
if (!analyses) return;
|
|
99
|
+
|
|
100
|
+
analyses.delete(analysisId);
|
|
101
|
+
if (analyses.size === 0) {
|
|
102
|
+
this._analyses.delete(worktreeId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this._checkIdle(worktreeId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove an analysis hold by analysisId only (without knowing worktreeId).
|
|
110
|
+
* Searches all worktrees for the analysis.
|
|
111
|
+
* @param {string} analysisId
|
|
112
|
+
*/
|
|
113
|
+
removeAnalysisById(analysisId) {
|
|
114
|
+
for (const [worktreeId, analyses] of this._analyses) {
|
|
115
|
+
if (analyses.has(analysisId)) {
|
|
116
|
+
this.removeAnalysis(worktreeId, analysisId);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if a worktree is currently in use.
|
|
124
|
+
* @param {string} worktreeId
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
isInUse(worktreeId) {
|
|
128
|
+
const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
129
|
+
const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
130
|
+
const hasGraceTimer = this._graceTimers.has(worktreeId);
|
|
131
|
+
return hasSessions || hasAnalyses || hasGraceTimer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Return the set of active analysis IDs for a worktree (may be empty).
|
|
136
|
+
* @param {string} worktreeId
|
|
137
|
+
* @returns {Set<string>}
|
|
138
|
+
*/
|
|
139
|
+
getActiveAnalyses(worktreeId) {
|
|
140
|
+
return new Set(this._analyses.get(worktreeId) || []);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Forcefully clear all tracking state for a single worktree.
|
|
145
|
+
*
|
|
146
|
+
* Removes sessions, analyses, and grace timers.
|
|
147
|
+
* Does NOT fire the onIdle callback — the caller is assumed to be
|
|
148
|
+
* handling the worktree lifecycle directly (e.g., deleting or
|
|
149
|
+
* marking it available in the pool).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} worktreeId - Pool worktree ID to purge
|
|
152
|
+
*/
|
|
153
|
+
clearWorktree(worktreeId) {
|
|
154
|
+
this._sessions.delete(worktreeId);
|
|
155
|
+
this._analyses.delete(worktreeId);
|
|
156
|
+
const timer = this._graceTimers.get(worktreeId);
|
|
157
|
+
if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
this._graceTimers.delete(worktreeId);
|
|
160
|
+
}
|
|
161
|
+
logger.debug(`Cleared all tracking state for pool worktree ${worktreeId}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Internal: check if a worktree has become idle and start grace period or fire callback.
|
|
166
|
+
* @param {string} worktreeId
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
_checkIdle(worktreeId) {
|
|
170
|
+
const hasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
171
|
+
const hasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
172
|
+
|
|
173
|
+
if (hasSessions || hasAnalyses) return; // still in use
|
|
174
|
+
|
|
175
|
+
// Already have a grace timer? Let it run
|
|
176
|
+
if (this._graceTimers.has(worktreeId)) return;
|
|
177
|
+
|
|
178
|
+
// Start grace period
|
|
179
|
+
logger.debug(`Starting ${GRACE_PERIOD_MS / 1000}s grace period for pool worktree ${worktreeId}`);
|
|
180
|
+
const timer = setTimeout(async () => {
|
|
181
|
+
this._graceTimers.delete(worktreeId);
|
|
182
|
+
// Double-check still idle (a new session could have connected during grace)
|
|
183
|
+
const stillHasSessions = (this._sessions.get(worktreeId)?.size || 0) > 0;
|
|
184
|
+
const stillHasAnalyses = (this._analyses.get(worktreeId)?.size || 0) > 0;
|
|
185
|
+
if (!stillHasSessions && !stillHasAnalyses) {
|
|
186
|
+
logger.info(`Pool worktree ${worktreeId} idle after grace period`);
|
|
187
|
+
if (this.onIdle) {
|
|
188
|
+
try {
|
|
189
|
+
await this.onIdle(worktreeId);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error(`onIdle callback failed for ${worktreeId}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}, GRACE_PERIOD_MS);
|
|
196
|
+
|
|
197
|
+
// Don't hold the process open for grace timers
|
|
198
|
+
if (timer.unref) timer.unref();
|
|
199
|
+
this._graceTimers.set(worktreeId, timer);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clear all tracking state. Useful for testing.
|
|
204
|
+
*/
|
|
205
|
+
reset() {
|
|
206
|
+
this._sessions.clear();
|
|
207
|
+
this._analyses.clear();
|
|
208
|
+
for (const timer of this._graceTimers.values()) {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
this._graceTimers.clear();
|
|
212
|
+
this.onIdle = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { WorktreePoolUsageTracker, GRACE_PERIOD_MS };
|
package/src/git/worktree.js
CHANGED
|
@@ -241,10 +241,12 @@ class GitWorktreeManager {
|
|
|
241
241
|
* When set, worktree is created with --no-checkout from the main git root (no sparse-checkout inheritance),
|
|
242
242
|
* and the script is executed before checkout with PR context as environment variables.
|
|
243
243
|
* @param {number} [options.checkoutTimeout] - Timeout in ms for checkout script (default: 300000 = 5 minutes)
|
|
244
|
-
* @
|
|
244
|
+
* @param {string} [options.explicitId] - When provided, use this ID for the worktrees-table record
|
|
245
|
+
* instead of generating one. Used by the worktree pool to align the worktrees-table ID with the pool ID.
|
|
246
|
+
* @returns {Promise<{ path: string, id: string }>} Path and database ID of created worktree
|
|
245
247
|
*/
|
|
246
248
|
async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
|
|
247
|
-
const { worktreeSourcePath, checkoutScript, checkoutTimeout } = options;
|
|
249
|
+
const { worktreeSourcePath, checkoutScript, checkoutTimeout, explicitId } = options;
|
|
248
250
|
// Check if worktree already exists in DB
|
|
249
251
|
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
250
252
|
let worktreePath;
|
|
@@ -265,7 +267,25 @@ class GitWorktreeManager {
|
|
|
265
267
|
// Try to reuse existing worktree by refreshing it
|
|
266
268
|
console.log(`Found existing worktree for PR #${prInfo.number} at ${worktreePath}`);
|
|
267
269
|
try {
|
|
268
|
-
|
|
270
|
+
const refreshedPath = await this.refreshWorktree(worktreeRecord, prInfo.number, prData, prInfo);
|
|
271
|
+
let returnId = worktreeRecord.id;
|
|
272
|
+
|
|
273
|
+
// If explicitId is provided and differs from the existing record's ID,
|
|
274
|
+
// migrate the worktrees-table row to use the pool ID. This happens when
|
|
275
|
+
// pool mode is enabled for a repo that already has legacy worktree records.
|
|
276
|
+
if (explicitId && worktreeRecord.id !== explicitId && this.worktreeRepo) {
|
|
277
|
+
const migrated = await this.worktreeRepo.getOrCreate({
|
|
278
|
+
prNumber: prInfo.number,
|
|
279
|
+
repository,
|
|
280
|
+
branch: prData.head_branch || prData.base_branch,
|
|
281
|
+
path: refreshedPath,
|
|
282
|
+
explicitId,
|
|
283
|
+
});
|
|
284
|
+
returnId = migrated.id;
|
|
285
|
+
console.log(`Migrated worktree record from ${worktreeRecord.id} to ${explicitId}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { path: refreshedPath, id: returnId };
|
|
269
289
|
} catch (refreshError) {
|
|
270
290
|
// If refresh fails due to uncommitted changes, propagate that error
|
|
271
291
|
if (refreshError.message.includes('uncommitted changes')) {
|
|
@@ -289,20 +309,23 @@ class GitWorktreeManager {
|
|
|
289
309
|
if (legacyExists && await this.isValidGitWorktree(legacyPath)) {
|
|
290
310
|
console.log(`Found legacy worktree for PR #${prInfo.number} at ${legacyPath}, adopting it`);
|
|
291
311
|
|
|
292
|
-
// Create DB record for the legacy worktree
|
|
312
|
+
// Create DB record for the legacy worktree — pass explicitId so the record
|
|
313
|
+
// is created with the pool ID when pool mode is active
|
|
293
314
|
if (this.worktreeRepo) {
|
|
294
315
|
worktreeRecord = await this.worktreeRepo.getOrCreate({
|
|
295
316
|
prNumber: prInfo.number,
|
|
296
317
|
repository,
|
|
297
318
|
branch: prData.head_branch || prData.base_branch,
|
|
298
|
-
path: legacyPath
|
|
319
|
+
path: legacyPath,
|
|
320
|
+
explicitId,
|
|
299
321
|
});
|
|
300
322
|
console.log(`Created database record for legacy worktree`);
|
|
301
323
|
}
|
|
302
324
|
|
|
303
325
|
// Try to refresh and reuse the legacy worktree
|
|
304
326
|
try {
|
|
305
|
-
|
|
327
|
+
const refreshedPath = await this.refreshWorktree({ path: legacyPath, id: worktreeRecord?.id }, prInfo.number, prData, prInfo);
|
|
328
|
+
return { path: refreshedPath, id: worktreeRecord?.id };
|
|
306
329
|
} catch (refreshError) {
|
|
307
330
|
// If refresh fails due to uncommitted changes, propagate that error
|
|
308
331
|
if (refreshError.message.includes('uncommitted changes')) {
|
|
@@ -438,8 +461,14 @@ class GitWorktreeManager {
|
|
|
438
461
|
}
|
|
439
462
|
|
|
440
463
|
// Checkout to PR head commit
|
|
441
|
-
|
|
442
|
-
|
|
464
|
+
const targetSha = prData.head_sha;
|
|
465
|
+
if (targetSha) {
|
|
466
|
+
console.log(`Checking out to PR head commit ${targetSha}...`);
|
|
467
|
+
await worktreeGit.checkout([targetSha]);
|
|
468
|
+
} else {
|
|
469
|
+
console.log(`Checking out to PR head ref ${remote}/pr-${prInfo.number}...`);
|
|
470
|
+
await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
|
|
471
|
+
}
|
|
443
472
|
|
|
444
473
|
// Verify we're at the correct commit
|
|
445
474
|
const currentCommit = await worktreeGit.revparse(['HEAD']);
|
|
@@ -448,18 +477,21 @@ class GitWorktreeManager {
|
|
|
448
477
|
}
|
|
449
478
|
|
|
450
479
|
// Store/update worktree record in database
|
|
480
|
+
let worktreeDbId;
|
|
451
481
|
if (this.worktreeRepo) {
|
|
452
|
-
await this.worktreeRepo.getOrCreate({
|
|
482
|
+
const record = await this.worktreeRepo.getOrCreate({
|
|
453
483
|
prNumber: prInfo.number,
|
|
454
484
|
repository,
|
|
455
485
|
branch: prData.head_branch || prData.base_branch,
|
|
456
|
-
path: worktreePath
|
|
486
|
+
path: worktreePath,
|
|
487
|
+
explicitId,
|
|
457
488
|
});
|
|
489
|
+
worktreeDbId = record.id;
|
|
458
490
|
console.log(`Worktree record stored in database`);
|
|
459
491
|
}
|
|
460
492
|
|
|
461
493
|
console.log(`Worktree created successfully at ${worktreePath}`);
|
|
462
|
-
return worktreePath;
|
|
494
|
+
return { path: worktreePath, id: worktreeDbId };
|
|
463
495
|
|
|
464
496
|
} catch (error) {
|
|
465
497
|
console.error('Error creating worktree:', error);
|
|
@@ -503,9 +535,10 @@ class GitWorktreeManager {
|
|
|
503
535
|
// Resolve which remote points to the PR's base repository (handles forks)
|
|
504
536
|
const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
|
|
505
537
|
|
|
506
|
-
// Fetch the latest from the resolved remote
|
|
538
|
+
// Fetch the latest from the resolved remote (--prune removes stale
|
|
539
|
+
// tracking refs that would otherwise block fetch on ref hierarchy conflicts)
|
|
507
540
|
console.log(`Fetching latest changes from ${remote}...`);
|
|
508
|
-
await worktreeGit.fetch([remote]);
|
|
541
|
+
await worktreeGit.fetch([remote, '--prune']);
|
|
509
542
|
|
|
510
543
|
// Fetch the PR head using GitHub's pull request refs
|
|
511
544
|
console.log(`Fetching PR #${number} head...`);
|