@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. 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
+ };