@in-the-loop-labs/pair-review 3.5.2 → 3.6.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/package.json +15 -20
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/pr.css +603 -6
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
const {
|
|
6
|
+
TOUR_PERSIST_MIN_STOPS,
|
|
7
|
+
TOUR_MAX_STOPS,
|
|
8
|
+
TOUR_TITLE_MAX,
|
|
9
|
+
TOUR_DESCRIPTION_MAX
|
|
10
|
+
} = require('./prompts/tour');
|
|
11
|
+
|
|
12
|
+
const TOUR_LOG_PREFIX = '[Tour]';
|
|
13
|
+
const SCRIPT_NAME = 'git-diff-lines';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tracks the most recently requested diff hash per review so that an in-flight
|
|
17
|
+
* tour generation can detect it has been superseded by a newer kickoff and
|
|
18
|
+
* skip persistence. Cleared when the latest hash successfully persists.
|
|
19
|
+
*
|
|
20
|
+
* Exported (module.exports) so tests can reset state between runs.
|
|
21
|
+
*/
|
|
22
|
+
const latestRequestedDiffHash = new Map();
|
|
23
|
+
|
|
24
|
+
const defaults = {
|
|
25
|
+
TourRepository: require('../database').TourRepository,
|
|
26
|
+
createProvider: require('./provider').createProvider,
|
|
27
|
+
resolveNonExecutableProviderId: require('./provider').resolveNonExecutableProviderId,
|
|
28
|
+
getTourProvider: require('../config').getTourProvider,
|
|
29
|
+
getTourModel: require('../config').getTourModel,
|
|
30
|
+
getTourEnabled: require('../config').getTourEnabled,
|
|
31
|
+
getTourAutoGenerate: require('../config').getTourAutoGenerate,
|
|
32
|
+
buildTourPrompt: require('./prompts/tour').buildTourPrompt,
|
|
33
|
+
extractJSON: require('../utils/json-extractor').extractJSON,
|
|
34
|
+
broadcastReviewEvent: require('../events/review-events').broadcastReviewEvent,
|
|
35
|
+
parseUnifiedDiffHunks: require('../utils/diff-hunks').parseUnifiedDiffHunks,
|
|
36
|
+
hashDiff: (diffText) => crypto.createHash('sha256').update(diffText).digest('hex').slice(0, 16),
|
|
37
|
+
backgroundQueue: null,
|
|
38
|
+
// Indirection so tests can swap the worker thunk and observe scheduling.
|
|
39
|
+
generateTourForReview: null
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the bare-name annotated-diff command string passed to the prompt.
|
|
44
|
+
* Bare command name (not absolute path) so provider tool allow-lists match.
|
|
45
|
+
* See analyzer.js `_buildScriptCommand`.
|
|
46
|
+
* @param {string|null} worktreePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function buildScriptCommand(worktreePath) {
|
|
50
|
+
if (!worktreePath) return SCRIPT_NAME;
|
|
51
|
+
return `${SCRIPT_NAME} --cwd "${worktreePath}"`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a unified-diff hunk header (`@@ -a,b +c,d @@`).
|
|
56
|
+
* @param {string} header
|
|
57
|
+
* @returns {{oldStart: number, oldLen: number, newStart: number, newLen: number}|null}
|
|
58
|
+
*/
|
|
59
|
+
function parseHunkHeader(header) {
|
|
60
|
+
const m = header && header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return {
|
|
63
|
+
oldStart: parseInt(m[1], 10),
|
|
64
|
+
oldLen: m[2] != null ? parseInt(m[2], 10) : 1,
|
|
65
|
+
newStart: parseInt(m[3], 10),
|
|
66
|
+
newLen: m[4] != null ? parseInt(m[4], 10) : 1
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build sets of changed line numbers per file per side from parsed hunks.
|
|
72
|
+
* @param {Map<string, Array<{header: string, lines: string[]}>>} hunksByFile
|
|
73
|
+
* @returns {{left: Map<string, Set<number>>, right: Map<string, Set<number>>}}
|
|
74
|
+
*/
|
|
75
|
+
function buildChangedLineIndex(hunksByFile) {
|
|
76
|
+
const left = new Map();
|
|
77
|
+
const right = new Map();
|
|
78
|
+
for (const [filePath, hunks] of hunksByFile.entries()) {
|
|
79
|
+
const leftSet = new Set();
|
|
80
|
+
const rightSet = new Set();
|
|
81
|
+
for (const hunk of hunks) {
|
|
82
|
+
const head = parseHunkHeader(hunk.header);
|
|
83
|
+
if (!head) continue;
|
|
84
|
+
let oldLine = head.oldStart;
|
|
85
|
+
let newLine = head.newStart;
|
|
86
|
+
for (const raw of hunk.lines) {
|
|
87
|
+
if (raw.startsWith('\\')) continue;
|
|
88
|
+
const marker = raw[0];
|
|
89
|
+
if (marker === '+') {
|
|
90
|
+
rightSet.add(newLine);
|
|
91
|
+
newLine++;
|
|
92
|
+
} else if (marker === '-') {
|
|
93
|
+
leftSet.add(oldLine);
|
|
94
|
+
oldLine++;
|
|
95
|
+
} else {
|
|
96
|
+
oldLine++;
|
|
97
|
+
newLine++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
left.set(filePath, leftSet);
|
|
102
|
+
right.set(filePath, rightSet);
|
|
103
|
+
}
|
|
104
|
+
return { left, right };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rangeIntersectsSet(start, end, set) {
|
|
108
|
+
if (!set || set.size === 0) return false;
|
|
109
|
+
for (let i = start; i <= end; i++) {
|
|
110
|
+
if (set.has(i)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate and normalize a single tour stop. Returns the cleaned stop or null.
|
|
117
|
+
* @param {unknown} stop
|
|
118
|
+
* @param {Object} ctx - { hunksByFile, changedLines, worktreePath }
|
|
119
|
+
* @returns {Promise<Object|null>}
|
|
120
|
+
*/
|
|
121
|
+
async function validateStop(stop, ctx) {
|
|
122
|
+
if (!stop || typeof stop !== 'object') return null;
|
|
123
|
+
|
|
124
|
+
const filePath = typeof stop.file_path === 'string' ? stop.file_path : null;
|
|
125
|
+
const title = typeof stop.title === 'string' ? stop.title.trim() : '';
|
|
126
|
+
const description = typeof stop.description === 'string' ? stop.description.trim() : '';
|
|
127
|
+
if (!filePath || !title || !description) return null;
|
|
128
|
+
|
|
129
|
+
const ls = Number(stop.line_start);
|
|
130
|
+
const le = Number(stop.line_end);
|
|
131
|
+
if (!Number.isInteger(ls) || ls < 1) return null;
|
|
132
|
+
if (!Number.isInteger(le) || le < ls) return null;
|
|
133
|
+
|
|
134
|
+
let normSide = typeof stop.side === 'string' ? stop.side.trim().toUpperCase() : 'RIGHT';
|
|
135
|
+
if (normSide !== 'LEFT' && normSide !== 'RIGHT') normSide = 'RIGHT';
|
|
136
|
+
|
|
137
|
+
// Context stops reference lines outside the rendered diff. The frontend
|
|
138
|
+
// renderer cannot anchor to rows that aren't in the DOM, so dropping them
|
|
139
|
+
// here keeps tours pointed only at lines a user can actually navigate to.
|
|
140
|
+
// Gap-expansion is a separate feature; see plans/semantic-hunk-summaries-and-tours.md.
|
|
141
|
+
if (stop.is_context === true) {
|
|
142
|
+
logger.info(
|
|
143
|
+
`${TOUR_LOG_PREFIX} dropping context stop ${filePath}:${ls}-${le} — gap expansion not yet supported in renderer`
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!ctx.hunksByFile.has(filePath)) {
|
|
149
|
+
logger.warn(`${TOUR_LOG_PREFIX} dropping changed-file stop for file outside diff: ${filePath}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const lineSet = (normSide === 'LEFT' ? ctx.changedLines.left : ctx.changedLines.right).get(filePath);
|
|
153
|
+
if (!rangeIntersectsSet(ls, le, lineSet)) {
|
|
154
|
+
logger.warn(
|
|
155
|
+
`${TOUR_LOG_PREFIX} dropping changed-file stop ${filePath}:${ls}-${le} (${normSide}) — does not intersect changed lines`
|
|
156
|
+
);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
file_path: filePath,
|
|
162
|
+
side: normSide,
|
|
163
|
+
line_start: ls,
|
|
164
|
+
line_end: le,
|
|
165
|
+
title: title.slice(0, TOUR_TITLE_MAX),
|
|
166
|
+
description: description.slice(0, TOUR_DESCRIPTION_MAX)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate a guided tour for a review and persist + broadcast it.
|
|
172
|
+
* @param {Object} params
|
|
173
|
+
* @param {Object} params.db
|
|
174
|
+
* @param {Object} params.config
|
|
175
|
+
* @param {number} params.reviewId
|
|
176
|
+
* @param {string} params.diffText - Full unified diff for the review snapshot.
|
|
177
|
+
* @param {string} params.worktreePath
|
|
178
|
+
* @param {Object} [params.reviewContext]
|
|
179
|
+
* @param {string} [params.diffHash] - Precomputed hash of `diffText`. When
|
|
180
|
+
* provided (e.g. from `kickOffTourJob`), used directly instead of being
|
|
181
|
+
* recomputed via `deps.hashDiff`. This makes the producer/consumer
|
|
182
|
+
* relationship between the kickoff and worker explicit rather than
|
|
183
|
+
* relying on the implicit invariant that `hashDiff` is deterministic
|
|
184
|
+
* across calls.
|
|
185
|
+
* @param {Object} [params._deps]
|
|
186
|
+
* @returns {Promise<{generated: boolean, stops: number, reason?: string}>}
|
|
187
|
+
*/
|
|
188
|
+
async function generateTourForReview({
|
|
189
|
+
db,
|
|
190
|
+
config,
|
|
191
|
+
reviewId,
|
|
192
|
+
diffText,
|
|
193
|
+
worktreePath,
|
|
194
|
+
reviewContext,
|
|
195
|
+
diffHash: providedDiffHash,
|
|
196
|
+
abortSignal,
|
|
197
|
+
_deps
|
|
198
|
+
}) {
|
|
199
|
+
const deps = { ...defaults, ..._deps };
|
|
200
|
+
|
|
201
|
+
// Helper: convert "aborted between awaits" into a rejected promise so we
|
|
202
|
+
// never persist partial work after a user cancel. Throwing an AbortError
|
|
203
|
+
// also tells the BackgroundQueue this completed via cancellation, which
|
|
204
|
+
// it surfaces in the broadcast event.
|
|
205
|
+
const throwIfAborted = () => {
|
|
206
|
+
if (abortSignal && abortSignal.aborted) {
|
|
207
|
+
const err = new Error(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled`);
|
|
208
|
+
err.name = 'AbortError';
|
|
209
|
+
err.isCancellation = true;
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (!diffText || !diffText.trim()) {
|
|
215
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no diff text; skipping`);
|
|
216
|
+
return { generated: false, stops: 0, reason: 'no_diff' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const hunksByFile = deps.parseUnifiedDiffHunks(diffText);
|
|
220
|
+
if (!hunksByFile || hunksByFile.size === 0) {
|
|
221
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: diff parsed to zero files; skipping`);
|
|
222
|
+
return { generated: false, stops: 0, reason: 'empty_diff' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Use the precomputed hash from the caller (kickOffTourJob) when
|
|
226
|
+
// available — that's the value stamped on `latestRequestedDiffHash`, so
|
|
227
|
+
// sharing it removes any "hashDiff must be deterministic" hidden
|
|
228
|
+
// contract between the two functions. Fall back to recomputing for
|
|
229
|
+
// direct callers (tests, ad-hoc invocations).
|
|
230
|
+
const diffHash = (typeof providedDiffHash === 'string' && providedDiffHash)
|
|
231
|
+
? providedDiffHash
|
|
232
|
+
: deps.hashDiff(diffText);
|
|
233
|
+
|
|
234
|
+
const tourRepo = new deps.TourRepository(db);
|
|
235
|
+
const existing = await tourRepo.get(reviewId);
|
|
236
|
+
if (existing && existing.diff_hash === diffHash) {
|
|
237
|
+
logger.debug(`${TOUR_LOG_PREFIX} review ${reviewId}: cached tour matches diff_hash; skipping`);
|
|
238
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
239
|
+
return { generated: false, stops: 0, reason: 'cached' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Skip the expensive provider call if a newer kickoff has already
|
|
243
|
+
// superseded this one before we even started exploring.
|
|
244
|
+
const latestBeforeProvider = latestRequestedDiffHash.get(reviewId);
|
|
245
|
+
if (latestBeforeProvider !== undefined && latestBeforeProvider !== diffHash) {
|
|
246
|
+
logger.debug(
|
|
247
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: superseded before provider call (have ${latestBeforeProvider}, this ${diffHash}); skipping`
|
|
248
|
+
);
|
|
249
|
+
return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const preferredProviderId = deps.getTourProvider(config);
|
|
253
|
+
const providerId = deps.resolveNonExecutableProviderId(preferredProviderId);
|
|
254
|
+
if (!providerId) {
|
|
255
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no agentic provider available; skipping`);
|
|
256
|
+
return { generated: false, stops: 0, reason: 'no_provider' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let provider;
|
|
260
|
+
let resolvedModel;
|
|
261
|
+
try {
|
|
262
|
+
const initial = deps.createProvider(providerId);
|
|
263
|
+
const ProviderClass = initial.constructor;
|
|
264
|
+
resolvedModel = deps.getTourModel(config, ProviderClass);
|
|
265
|
+
provider = deps.createProvider(providerId, resolvedModel);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: provider unavailable (${err.message}); skipping`);
|
|
268
|
+
return { generated: false, stops: 0, reason: 'provider_error' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const ctx = reviewContext || {};
|
|
272
|
+
const prompt = deps.buildTourPrompt({
|
|
273
|
+
prTitle: ctx.prTitle,
|
|
274
|
+
prDescription: ctx.prDescription,
|
|
275
|
+
scriptCommand: buildScriptCommand(worktreePath),
|
|
276
|
+
changedFiles: Array.from(hunksByFile.keys()),
|
|
277
|
+
worktreePath
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
throwIfAborted();
|
|
281
|
+
let result;
|
|
282
|
+
try {
|
|
283
|
+
result = await provider.execute(prompt, {
|
|
284
|
+
cwd: worktreePath,
|
|
285
|
+
logPrefix: TOUR_LOG_PREFIX,
|
|
286
|
+
abortSignal,
|
|
287
|
+
});
|
|
288
|
+
} catch (execErr) {
|
|
289
|
+
if (execErr && (execErr.name === 'AbortError' || execErr.isCancellation)) {
|
|
290
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled during provider call`);
|
|
291
|
+
throw execErr;
|
|
292
|
+
}
|
|
293
|
+
logger.error(`${TOUR_LOG_PREFIX} review ${reviewId}: provider error: ${execErr.message}`);
|
|
294
|
+
return { generated: false, stops: 0, reason: 'provider_throw' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let data;
|
|
298
|
+
if (result && Array.isArray(result.stops)) {
|
|
299
|
+
data = { stops: result.stops };
|
|
300
|
+
} else if (result && result.data && (result.parsed || result.success)) {
|
|
301
|
+
data = result.data;
|
|
302
|
+
} else {
|
|
303
|
+
const raw = (result && result.raw) || '';
|
|
304
|
+
const extracted = deps.extractJSON(raw, 'tour', TOUR_LOG_PREFIX);
|
|
305
|
+
if (!extracted || !extracted.success) {
|
|
306
|
+
const errMsg = extracted && extracted.error ? extracted.error : 'unknown error';
|
|
307
|
+
logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: JSON parse failed: ${errMsg}`);
|
|
308
|
+
return { generated: false, stops: 0, reason: 'malformed' };
|
|
309
|
+
}
|
|
310
|
+
data = extracted.data;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!data || !Array.isArray(data.stops)) {
|
|
314
|
+
logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: response missing stops[]`);
|
|
315
|
+
return { generated: false, stops: 0, reason: 'malformed' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const changedLines = buildChangedLineIndex(hunksByFile);
|
|
319
|
+
const validationCtx = {
|
|
320
|
+
hunksByFile,
|
|
321
|
+
changedLines,
|
|
322
|
+
worktreePath
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const validated = [];
|
|
326
|
+
for (const stop of data.stops) {
|
|
327
|
+
if (validated.length >= TOUR_MAX_STOPS) break;
|
|
328
|
+
const cleaned = await validateStop(stop, validationCtx);
|
|
329
|
+
if (!cleaned) continue;
|
|
330
|
+
|
|
331
|
+
// Drop stops that overlap an already-accepted stop on the same
|
|
332
|
+
// (file_path, side). Two ranges [a,b] and [c,d] overlap iff a <= d && c <= b.
|
|
333
|
+
const overlaps = validated.some((accepted) => (
|
|
334
|
+
accepted.file_path === cleaned.file_path
|
|
335
|
+
&& accepted.side === cleaned.side
|
|
336
|
+
&& cleaned.line_start <= accepted.line_end
|
|
337
|
+
&& accepted.line_start <= cleaned.line_end
|
|
338
|
+
));
|
|
339
|
+
if (overlaps) {
|
|
340
|
+
logger.debug(
|
|
341
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: dropping overlapping stop ${cleaned.file_path}:${cleaned.line_start}-${cleaned.line_end} (${cleaned.side})`
|
|
342
|
+
);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
validated.push(cleaned);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (validated.length < TOUR_PERSIST_MIN_STOPS) {
|
|
350
|
+
logger.info(
|
|
351
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: ${validated.length} valid stops after filtering; below persist threshold (${TOUR_PERSIST_MIN_STOPS}) — not tour-worthy`
|
|
352
|
+
);
|
|
353
|
+
return { generated: false, stops: 0, reason: 'not_tour_worthy' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
throwIfAborted();
|
|
357
|
+
// Last-chance superseded check: another kickoff with a different diff
|
|
358
|
+
// arrived while we were exploring/validating. Skip the write so we don't
|
|
359
|
+
// overwrite a tour that's about to be regenerated for a newer diff.
|
|
360
|
+
const latestBeforeUpsert = latestRequestedDiffHash.get(reviewId);
|
|
361
|
+
if (latestBeforeUpsert !== undefined && latestBeforeUpsert !== diffHash) {
|
|
362
|
+
logger.debug(
|
|
363
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: superseded before upsert (have ${latestBeforeUpsert}, this ${diffHash}); skipping persist`
|
|
364
|
+
);
|
|
365
|
+
return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
await tourRepo.upsert({
|
|
369
|
+
review_id: reviewId,
|
|
370
|
+
stops: JSON.stringify(validated),
|
|
371
|
+
diff_hash: diffHash,
|
|
372
|
+
provider: providerId,
|
|
373
|
+
model: resolvedModel
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Intentionally do NOT clear `latestRequestedDiffHash` on success. A
|
|
377
|
+
// predecessor worker whose cancel was lost (e.g., the provider's HTTP call
|
|
378
|
+
// didn't honor AbortSignal) may still be poised to reach its pre-upsert
|
|
379
|
+
// check. If we deleted our entry, that predecessor would see `undefined`,
|
|
380
|
+
// pass its `latestBeforeUpsert !== undefined && ...` guard, and overwrite
|
|
381
|
+
// our fresh row with a tour for the now-stale diff. Leaving the entry set
|
|
382
|
+
// to our hash makes the predecessor's hash mismatch and skip. The map
|
|
383
|
+
// grows by at most one entry per review until the next kickoff overwrites.
|
|
384
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
385
|
+
|
|
386
|
+
logger.info(
|
|
387
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: persisted tour with ${validated.length} stops (diff_hash=${diffHash})`
|
|
388
|
+
);
|
|
389
|
+
return { generated: true, stops: validated.length };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Gate the tour job and enqueue it on the background queue.
|
|
394
|
+
*
|
|
395
|
+
* Dedup via the queue's `(reviewId, 'tour')` key — concurrent kickoffs share
|
|
396
|
+
* a single execution. Staleness is checked inside the generator itself via
|
|
397
|
+
* `diff_hash` comparison.
|
|
398
|
+
*
|
|
399
|
+
* `trigger` controls how the enabled/auto_generate config interacts with
|
|
400
|
+
* kickoff:
|
|
401
|
+
* - `'auto'` (default): requires `tours.enabled && tours.auto_generate`
|
|
402
|
+
* - `'manual'`: only requires `tours.enabled` (user-initiated start)
|
|
403
|
+
*
|
|
404
|
+
* @param {Object} params
|
|
405
|
+
* @param {Object} params.db
|
|
406
|
+
* @param {Object} params.config
|
|
407
|
+
* @param {number} params.reviewId
|
|
408
|
+
* @param {string} params.diffText
|
|
409
|
+
* @param {string} params.worktreePath
|
|
410
|
+
* @param {Object} [params.reviewContext]
|
|
411
|
+
* @param {'auto'|'manual'} [params.trigger='auto']
|
|
412
|
+
* @param {Object} [params._deps]
|
|
413
|
+
* @returns {Promise<Object>|null}
|
|
414
|
+
*/
|
|
415
|
+
async function kickOffTourJob({
|
|
416
|
+
db,
|
|
417
|
+
config,
|
|
418
|
+
reviewId,
|
|
419
|
+
diffText,
|
|
420
|
+
worktreePath,
|
|
421
|
+
reviewContext,
|
|
422
|
+
trigger = 'auto',
|
|
423
|
+
_deps
|
|
424
|
+
}) {
|
|
425
|
+
const deps = { ...defaults, ...(_deps || {}) };
|
|
426
|
+
|
|
427
|
+
if (!deps.getTourEnabled(config)) return null;
|
|
428
|
+
|
|
429
|
+
if (!reviewId) {
|
|
430
|
+
logger.debug('kickOffTourJob skipped: missing reviewId');
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const queue = deps.backgroundQueue || require('./background-queue').backgroundQueue;
|
|
435
|
+
|
|
436
|
+
// Cancel any in-flight tour job whose diff hash no longer matches. This is
|
|
437
|
+
// load-bearing for both cost (stops a stale provider call from burning
|
|
438
|
+
// tokens) and correctness — the in-generator superseded check relies on
|
|
439
|
+
// `latestRequestedDiffHash` reflecting the current desired snapshot. Calling
|
|
440
|
+
// this even when the new diff is empty (a valid terminal snapshot after a
|
|
441
|
+
// refresh or scope change) keeps the old worker from observing a stale,
|
|
442
|
+
// matching hash and persisting a tour the user has moved past.
|
|
443
|
+
const cancelActiveTourJob = () => {
|
|
444
|
+
if (
|
|
445
|
+
typeof queue.findActiveJobType === 'function' &&
|
|
446
|
+
queue.findActiveJobType(reviewId, 'tour') &&
|
|
447
|
+
typeof queue.cancel === 'function'
|
|
448
|
+
) {
|
|
449
|
+
queue.cancel(reviewId, 'tour');
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
if (!diffText || !worktreePath) {
|
|
454
|
+
const missing = [];
|
|
455
|
+
if (!diffText) missing.push('diffText');
|
|
456
|
+
if (!worktreePath) missing.push('worktreePath');
|
|
457
|
+
logger.debug(`kickOffTourJob skipped: missing ${missing.join(', ')}`);
|
|
458
|
+
// Stamp a sentinel so any in-flight worker's pre-upsert check sees a
|
|
459
|
+
// different value than its own hash and bails. The sentinel is intentionally
|
|
460
|
+
// non-hashlike so a real diff can never collide with it.
|
|
461
|
+
//
|
|
462
|
+
// Run cleanup unconditionally (not gated on a prior in-process hash). The
|
|
463
|
+
// map is in-memory: after a server restart it's empty, but a persisted
|
|
464
|
+
// row from a pre-restart session can still exist. Without unconditional
|
|
465
|
+
// cleanup, the first post-restart empty-diff transition would leave a
|
|
466
|
+
// stale row that GET /api/reviews/:id/tour serves verbatim (no diff_hash
|
|
467
|
+
// check), pointing the UI at stops no longer in the diff. `deleteByReview`
|
|
468
|
+
// is idempotent; the `changes > 0` guard below suppresses the broadcast
|
|
469
|
+
// on a fresh review that never had a tour.
|
|
470
|
+
latestRequestedDiffHash.set(reviewId, '__empty__');
|
|
471
|
+
cancelActiveTourJob();
|
|
472
|
+
try {
|
|
473
|
+
const repo = new deps.TourRepository(db);
|
|
474
|
+
const result = await repo.deleteByReview(reviewId);
|
|
475
|
+
if (result && result.changes > 0) {
|
|
476
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.warn(
|
|
480
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on empty-diff cleanup: ${err.message}`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Stamp the latest requested diff hash BEFORE enqueueing so that an
|
|
487
|
+
// in-flight job (potentially started by a previous kickoff with an older
|
|
488
|
+
// diff) can observe it and decide to skip persistence.
|
|
489
|
+
const diffHash = deps.hashDiff(diffText);
|
|
490
|
+
const previousHash = latestRequestedDiffHash.get(reviewId);
|
|
491
|
+
latestRequestedDiffHash.set(reviewId, diffHash);
|
|
492
|
+
|
|
493
|
+
// Belt-and-suspenders: if a tour job is in flight with a different diff
|
|
494
|
+
// hash, cancel it now. The worker's staleness check at persistence time
|
|
495
|
+
// would discard its output anyway (the suspenders); cancelling stops the
|
|
496
|
+
// upstream provider call from burning more tokens (the belt). The order
|
|
497
|
+
// matters: the hash above is already stamped, so any not-yet-cancelled
|
|
498
|
+
// worker observing the map sees the new hash and skips persistence.
|
|
499
|
+
if (previousHash && previousHash !== diffHash) {
|
|
500
|
+
cancelActiveTourJob();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// auto_generate gate, applied here — AFTER cancellation, the empty-diff
|
|
504
|
+
// cleanup, and the latestRequestedDiffHash stamp above — so all of that
|
|
505
|
+
// stale-state hygiene runs regardless of trigger. With `auto_generate`
|
|
506
|
+
// off, an auto-triggered kickoff (e.g. a refresh/scope-change re-invoke
|
|
507
|
+
// after a manual start) still cancels the in-flight job against the old
|
|
508
|
+
// diff and stamps the new hash; it simply declines to enqueue a
|
|
509
|
+
// replacement. Manual kickoffs always proceed.
|
|
510
|
+
//
|
|
511
|
+
// Before declining, reconcile the persisted row against the new diff. GET
|
|
512
|
+
// /api/reviews/:id/tour serves the row verbatim (no diff_hash check) and
|
|
513
|
+
// the frontend treats any non-empty stops as ready, so a stale row would
|
|
514
|
+
// map the old tour onto the new diff AND block the manual-generate click
|
|
515
|
+
// path (stops aren't empty). Compare against the PERSISTED diff_hash, not
|
|
516
|
+
// `previousHash`: `latestRequestedDiffHash` is empty after a server
|
|
517
|
+
// restart, so a previousHash-based guard would let a pre-restart stale
|
|
518
|
+
// row slip through. Same shape as the empty-diff branch above.
|
|
519
|
+
if (trigger !== 'manual' && !deps.getTourAutoGenerate(config)) {
|
|
520
|
+
try {
|
|
521
|
+
const repo = new deps.TourRepository(db);
|
|
522
|
+
const row = await repo.get(reviewId);
|
|
523
|
+
if (row && row.diff_hash !== diffHash) {
|
|
524
|
+
const result = await repo.deleteByReview(reviewId);
|
|
525
|
+
if (result && result.changes > 0) {
|
|
526
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logger.warn(
|
|
531
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on auto_generate=false gate: ${err.message}`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const worker = deps.generateTourForReview || generateTourForReview;
|
|
538
|
+
return queue.enqueue(reviewId, 'tour', (signal) =>
|
|
539
|
+
worker({
|
|
540
|
+
db,
|
|
541
|
+
config,
|
|
542
|
+
reviewId,
|
|
543
|
+
diffText,
|
|
544
|
+
worktreePath,
|
|
545
|
+
reviewContext,
|
|
546
|
+
// Thread the hash we already computed through to the worker rather
|
|
547
|
+
// than relying on the (implicit) invariant that hashDiff produces
|
|
548
|
+
// the same output for the same diffText in both call sites.
|
|
549
|
+
diffHash,
|
|
550
|
+
// The BackgroundQueue calls our thunk as `fn(signal)`; pass it on so
|
|
551
|
+
// a user-initiated cancel reaches the upstream provider call.
|
|
552
|
+
abortSignal: signal,
|
|
553
|
+
_deps
|
|
554
|
+
})
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
module.exports = {
|
|
559
|
+
generateTourForReview,
|
|
560
|
+
kickOffTourJob,
|
|
561
|
+
// Exported for tests
|
|
562
|
+
parseHunkHeader,
|
|
563
|
+
buildChangedLineIndex,
|
|
564
|
+
buildScriptCommand,
|
|
565
|
+
validateStop,
|
|
566
|
+
latestRequestedDiffHash,
|
|
567
|
+
resetLatestRequestedDiffHash: () => latestRequestedDiffHash.clear()
|
|
568
|
+
};
|
package/src/config.js
CHANGED
|
@@ -23,6 +23,20 @@ const DEFAULT_CONFIG = {
|
|
|
23
23
|
theme: "light",
|
|
24
24
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
25
25
|
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
|
|
26
|
+
tours: {
|
|
27
|
+
enabled: false, // When true, the guided-tour feature is available (toolbar button visible, etc.)
|
|
28
|
+
auto_generate: true, // When true, a tour generation job is kicked off automatically on review load
|
|
29
|
+
provider: "", // Provider for agentic tour generation. Empty = falls back to summaries.provider, then default_provider
|
|
30
|
+
model: "" // Model for tour generation. Empty = falls back to summaries.model resolution
|
|
31
|
+
},
|
|
32
|
+
summaries: {
|
|
33
|
+
enabled: false, // When true, the hunk-summaries feature is available (toolbar button + per-file toggles visible)
|
|
34
|
+
auto_generate: true, // When true, a summary generation job is kicked off automatically on review load
|
|
35
|
+
provider: "", // Provider for one-shot hunk summary AI tasks. Empty = falls back to default_provider
|
|
36
|
+
model: "", // Model for hunk summary tasks. Empty = uses provider's fast-tier model, then default_model
|
|
37
|
+
max_files: 50, // Skip summary generation for reviews touching more than this many files (perf cap)
|
|
38
|
+
max_lines_added: 3000 // Skip summary generation when the diff adds more than this many lines (perf cap)
|
|
39
|
+
},
|
|
26
40
|
worktree_retention_days: 7,
|
|
27
41
|
review_retention_days: 21,
|
|
28
42
|
dev_mode: false, // When true, disables static file caching for development
|
|
@@ -121,6 +135,98 @@ function getDefaultModel(config) {
|
|
|
121
135
|
return getConfigValue(config, 'default_model', 'model') || DEFAULT_CONFIG.default_model;
|
|
122
136
|
}
|
|
123
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether the summaries feature is enabled (toolbar button visible, kickoff allowed).
|
|
140
|
+
* @param {Object} config - Configuration object
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
function getSummaryEnabled(config) {
|
|
144
|
+
return Boolean(config && config.summaries && config.summaries.enabled === true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether summaries should auto-generate on review load. Defaults to true when
|
|
149
|
+
* unset so the feature stays opt-out within the enabled flag.
|
|
150
|
+
* @param {Object} config - Configuration object
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
function getSummaryAutoGenerate(config) {
|
|
154
|
+
if (!config || !config.summaries) return true;
|
|
155
|
+
return config.summaries.auto_generate !== false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether the tours feature is enabled (toolbar button visible, kickoff allowed).
|
|
160
|
+
* @param {Object} config - Configuration object
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
function getTourEnabled(config) {
|
|
164
|
+
return Boolean(config && config.tours && config.tours.enabled === true);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether tours should auto-generate on review load. Defaults to true when
|
|
169
|
+
* unset so the feature stays opt-out within the enabled flag.
|
|
170
|
+
* @param {Object} config - Configuration object
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
function getTourAutoGenerate(config) {
|
|
174
|
+
if (!config || !config.tours) return true;
|
|
175
|
+
return config.tours.auto_generate !== false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets the summary provider for summary/tour generation
|
|
180
|
+
* Falls back to default_provider when summaries.provider is not set
|
|
181
|
+
* @param {Object} config - Configuration object
|
|
182
|
+
* @returns {string} - Provider name
|
|
183
|
+
*/
|
|
184
|
+
function getSummaryProvider(config) {
|
|
185
|
+
const explicit = config && config.summaries && config.summaries.provider;
|
|
186
|
+
return explicit || getDefaultProvider(config);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Gets the summary model for summary/tour generation
|
|
191
|
+
* Resolution order: summaries.model → providerClass fast-tier → default_model
|
|
192
|
+
* @param {Object} config - Configuration object
|
|
193
|
+
* @param {Function} [providerClass] - Optional provider class with static getModels()
|
|
194
|
+
* @returns {string} - Model name
|
|
195
|
+
*/
|
|
196
|
+
function getSummaryModel(config, providerClass = null) {
|
|
197
|
+
const explicit = config && config.summaries && config.summaries.model;
|
|
198
|
+
if (explicit) return explicit;
|
|
199
|
+
if (providerClass && typeof providerClass.getModels === 'function') {
|
|
200
|
+
const fast = providerClass.getModels().find(m => m.tier === 'fast');
|
|
201
|
+
if (fast) return fast.id;
|
|
202
|
+
}
|
|
203
|
+
return getDefaultModel(config);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Gets the provider for tour generation.
|
|
208
|
+
* Resolution order: tours.provider → summaries.provider → default_provider
|
|
209
|
+
* @param {Object} config - Configuration object
|
|
210
|
+
* @returns {string} - Provider name
|
|
211
|
+
*/
|
|
212
|
+
function getTourProvider(config) {
|
|
213
|
+
const explicit = config && config.tours && config.tours.provider;
|
|
214
|
+
return explicit || getSummaryProvider(config);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Gets the model for tour generation.
|
|
219
|
+
* Resolution order: tours.model → summaries.model → providerClass fast-tier → default_model
|
|
220
|
+
* @param {Object} config - Configuration object
|
|
221
|
+
* @param {Function} [providerClass] - Optional provider class with static getModels()
|
|
222
|
+
* @returns {string} - Model name
|
|
223
|
+
*/
|
|
224
|
+
function getTourModel(config, providerClass = null) {
|
|
225
|
+
const explicit = config && config.tours && config.tours.model;
|
|
226
|
+
if (explicit) return explicit;
|
|
227
|
+
return getSummaryModel(config, providerClass);
|
|
228
|
+
}
|
|
229
|
+
|
|
124
230
|
/**
|
|
125
231
|
* Copies the example config file to the user's config directory
|
|
126
232
|
* @returns {Promise<boolean>} True if copied successfully, false if source doesn't exist
|
|
@@ -763,6 +869,14 @@ module.exports = {
|
|
|
763
869
|
getGitHubToken,
|
|
764
870
|
getDefaultProvider,
|
|
765
871
|
getDefaultModel,
|
|
872
|
+
getSummaryProvider,
|
|
873
|
+
getSummaryModel,
|
|
874
|
+
getSummaryEnabled,
|
|
875
|
+
getSummaryAutoGenerate,
|
|
876
|
+
getTourProvider,
|
|
877
|
+
getTourModel,
|
|
878
|
+
getTourEnabled,
|
|
879
|
+
getTourAutoGenerate,
|
|
766
880
|
isRunningViaNpx,
|
|
767
881
|
showWelcomeMessage,
|
|
768
882
|
expandPath,
|