@in-the-loop-labs/pair-review 3.5.1 → 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 +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +603 -6
- package/public/index.html +90 -0
- 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/index.js +298 -25
- 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/github-collections.js +168 -90
- 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,469 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
const defaults = {
|
|
8
|
+
parseUnifiedDiffHunks: require('../utils/diff-hunks').parseUnifiedDiffHunks,
|
|
9
|
+
hashHunk: require('./hunk-hashing').hashHunk,
|
|
10
|
+
isTrivialHunk: require('./hunk-hashing').isTrivialHunk,
|
|
11
|
+
HunkSummaryRepository: require('../database').HunkSummaryRepository,
|
|
12
|
+
createProvider: require('./provider').createProvider,
|
|
13
|
+
resolveNonExecutableProviderId: require('./provider').resolveNonExecutableProviderId,
|
|
14
|
+
getSummaryProvider: require('../config').getSummaryProvider,
|
|
15
|
+
getSummaryModel: require('../config').getSummaryModel,
|
|
16
|
+
getSummaryEnabled: require('../config').getSummaryEnabled,
|
|
17
|
+
getSummaryAutoGenerate: require('../config').getSummaryAutoGenerate,
|
|
18
|
+
buildHunkSummaryPrompt: require('./prompts/hunk-summary').buildHunkSummaryPrompt,
|
|
19
|
+
extractJSON: require('../utils/json-extractor').extractJSON,
|
|
20
|
+
getGeneratedFilePatterns: require('../git/gitattributes').getGeneratedFilePatterns,
|
|
21
|
+
broadcastReviewEvent: require('../events/review-events').broadcastReviewEvent,
|
|
22
|
+
hashDiff: (diffText) => crypto.createHash('sha256').update(diffText).digest('hex').slice(0, 16),
|
|
23
|
+
backgroundQueue: null
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Count '+' lines in parsed hunks to gate summary generation by added-line volume.
|
|
28
|
+
* Hunk-header lines are not '+'-prefixed by `parseUnifiedDiffHunks`, so this is safe.
|
|
29
|
+
* @param {Map<string, Array<{header: string, lines: string[]}>>} hunksByFile
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
function countAddedLines(hunksByFile) {
|
|
33
|
+
let total = 0;
|
|
34
|
+
for (const hunks of hunksByFile.values()) {
|
|
35
|
+
for (const hunk of hunks) {
|
|
36
|
+
for (const line of hunk.lines) {
|
|
37
|
+
if (line.startsWith('+') && !line.startsWith('+++')) total++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate hunk summaries for a review's diff and persist + broadcast them.
|
|
46
|
+
* @param {Object} params
|
|
47
|
+
* @param {Object} params.db
|
|
48
|
+
* @param {Object} params.config
|
|
49
|
+
* @param {number} params.reviewId
|
|
50
|
+
* @param {string} params.diffText
|
|
51
|
+
* @param {string} params.worktreePath
|
|
52
|
+
* @param {Object} [params.reviewContext]
|
|
53
|
+
* @param {Object} [params._deps]
|
|
54
|
+
* @returns {Promise<{filesProcessed: number, hunksPersisted: number}>}
|
|
55
|
+
*/
|
|
56
|
+
async function generateSummariesForReview({
|
|
57
|
+
db,
|
|
58
|
+
config,
|
|
59
|
+
reviewId,
|
|
60
|
+
diffText,
|
|
61
|
+
worktreePath,
|
|
62
|
+
reviewContext,
|
|
63
|
+
abortSignal,
|
|
64
|
+
_deps
|
|
65
|
+
}) {
|
|
66
|
+
const deps = { ...defaults, ..._deps };
|
|
67
|
+
|
|
68
|
+
// Helper: bail between per-file iterations once the user cancels.
|
|
69
|
+
// Summaries are written file-by-file, so a mid-loop abort can leave a
|
|
70
|
+
// subset of files persisted — that is intentional ("stop burning more
|
|
71
|
+
// tokens"). The check only prevents doing MORE work, not undoing
|
|
72
|
+
// already-persisted summaries.
|
|
73
|
+
const isAborted = () => abortSignal && abortSignal.aborted;
|
|
74
|
+
|
|
75
|
+
if (!diffText || !diffText.trim()) {
|
|
76
|
+
return { filesProcessed: 0, hunksPersisted: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hunksByFile = deps.parseUnifiedDiffHunks(diffText);
|
|
80
|
+
if (!hunksByFile || hunksByFile.size === 0) {
|
|
81
|
+
return { filesProcessed: 0, hunksPersisted: 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const summariesCfg = (config && config.summaries) || {};
|
|
85
|
+
const maxFiles = (summariesCfg.max_files != null) ? summariesCfg.max_files : 50;
|
|
86
|
+
if (hunksByFile.size > maxFiles) {
|
|
87
|
+
logger.info(
|
|
88
|
+
`Skipping hunk summaries for review ${reviewId}: ${hunksByFile.size} files exceeds summaries.max_files=${maxFiles}`
|
|
89
|
+
);
|
|
90
|
+
return { filesProcessed: 0, hunksPersisted: 0, oversized: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const maxLinesAdded = (summariesCfg.max_lines_added != null) ? summariesCfg.max_lines_added : 3000;
|
|
94
|
+
const linesAdded = countAddedLines(hunksByFile);
|
|
95
|
+
if (linesAdded > maxLinesAdded) {
|
|
96
|
+
logger.info(
|
|
97
|
+
`Skipping hunk summaries for review ${reviewId}: ${linesAdded} added lines exceeds summaries.max_lines_added=${maxLinesAdded}`
|
|
98
|
+
);
|
|
99
|
+
return { filesProcessed: 0, hunksPersisted: 0, oversized: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let isGeneratedFile = () => false;
|
|
103
|
+
try {
|
|
104
|
+
const parser = await deps.getGeneratedFilePatterns(worktreePath);
|
|
105
|
+
isGeneratedFile = (filePath) => parser.isGenerated(filePath);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.warn(`Failed to load .gitattributes for review ${reviewId}: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const preferredProviderId = deps.getSummaryProvider(config);
|
|
111
|
+
const providerId = deps.resolveNonExecutableProviderId(preferredProviderId);
|
|
112
|
+
if (!providerId) {
|
|
113
|
+
logger.info(
|
|
114
|
+
`Hunk summaries skipped for review ${reviewId}: no non-executable provider available`
|
|
115
|
+
);
|
|
116
|
+
return { filesProcessed: 0, hunksPersisted: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let provider;
|
|
120
|
+
let resolvedModel;
|
|
121
|
+
try {
|
|
122
|
+
const initialProvider = deps.createProvider(providerId);
|
|
123
|
+
const ProviderClass = initialProvider.constructor;
|
|
124
|
+
resolvedModel = deps.getSummaryModel(config, ProviderClass);
|
|
125
|
+
provider = deps.createProvider(providerId, resolvedModel);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.info(
|
|
128
|
+
`Hunk summaries skipped for review ${reviewId}: summary provider unavailable (${err.message})`
|
|
129
|
+
);
|
|
130
|
+
return { filesProcessed: 0, hunksPersisted: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const repo = new deps.HunkSummaryRepository(db);
|
|
134
|
+
|
|
135
|
+
const effectiveContext = { ...(reviewContext || {}) };
|
|
136
|
+
if (!effectiveContext.changedFiles) {
|
|
137
|
+
effectiveContext.changedFiles = Array.from(hunksByFile.keys());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let filesProcessed = 0;
|
|
141
|
+
let hunksPersisted = 0;
|
|
142
|
+
|
|
143
|
+
for (const [filePath, hunks] of hunksByFile.entries()) {
|
|
144
|
+
if (isAborted()) {
|
|
145
|
+
// Cancelled mid-loop: surface as an AbortError so the queue's
|
|
146
|
+
// broadcast carries `cancelled: true`. Any per-file work already
|
|
147
|
+
// persisted stays — see comment on `isAborted` declaration.
|
|
148
|
+
const err = new Error(`Hunk summaries for review ${reviewId} cancelled`);
|
|
149
|
+
err.name = 'AbortError';
|
|
150
|
+
err.isCancellation = true;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
// Use the basename for log readability — full repo-relative paths can be
|
|
154
|
+
// long enough to clutter each log line, and within a single review the
|
|
155
|
+
// basename is almost always unique enough to identify the file.
|
|
156
|
+
const summaryPrefix = `[Summary ${path.basename(filePath)}]`;
|
|
157
|
+
try {
|
|
158
|
+
const classified = hunks.map((hunk) => {
|
|
159
|
+
const content = [hunk.header, ...hunk.lines].join('\n');
|
|
160
|
+
const contentHash = deps.hashHunk(filePath, content);
|
|
161
|
+
const triviality = deps.isTrivialHunk(hunk, filePath, { isGeneratedFile });
|
|
162
|
+
return { hunk, contentHash, triviality };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const allHashes = classified.map((c) => c.contentHash);
|
|
166
|
+
const existingRows = await repo.getByHashes(reviewId, allHashes);
|
|
167
|
+
const existingHashes = new Set(existingRows.map((row) => row.content_hash));
|
|
168
|
+
|
|
169
|
+
const trivialRowsToPersist = [];
|
|
170
|
+
const missing = [];
|
|
171
|
+
for (const item of classified) {
|
|
172
|
+
if (existingHashes.has(item.contentHash)) continue;
|
|
173
|
+
if (item.triviality.trivial) {
|
|
174
|
+
trivialRowsToPersist.push({
|
|
175
|
+
review_id: reviewId,
|
|
176
|
+
file_path: filePath,
|
|
177
|
+
content_hash: item.contentHash,
|
|
178
|
+
summary_text: null,
|
|
179
|
+
trivial_reason: item.triviality.reason,
|
|
180
|
+
provider: null,
|
|
181
|
+
model: null
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
missing.push(item);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (trivialRowsToPersist.length > 0) {
|
|
189
|
+
await repo.upsertMany(trivialRowsToPersist);
|
|
190
|
+
hunksPersisted += trivialRowsToPersist.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (missing.length > 0) {
|
|
194
|
+
const prompt = deps.buildHunkSummaryPrompt({
|
|
195
|
+
filePath,
|
|
196
|
+
hunks: missing.map((m) => m.hunk),
|
|
197
|
+
prTitle: effectiveContext.prTitle,
|
|
198
|
+
prDescription: effectiveContext.prDescription,
|
|
199
|
+
changedFiles: effectiveContext.changedFiles,
|
|
200
|
+
cwd: worktreePath
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
let result;
|
|
204
|
+
try {
|
|
205
|
+
result = await provider.execute(prompt, {
|
|
206
|
+
cwd: worktreePath,
|
|
207
|
+
logPrefix: summaryPrefix,
|
|
208
|
+
abortSignal,
|
|
209
|
+
});
|
|
210
|
+
} catch (execErr) {
|
|
211
|
+
if (execErr && (execErr.name === 'AbortError' || execErr.isCancellation)) {
|
|
212
|
+
// Propagate cancellation up so the queue marks the broadcast as
|
|
213
|
+
// cancelled instead of "completed normally with no output".
|
|
214
|
+
throw execErr;
|
|
215
|
+
}
|
|
216
|
+
// (Intentional: see retry-on-reload note below — no sentinel row.)
|
|
217
|
+
logger.error(`${summaryPrefix} Hunk summary provider error for ${filePath}: ${execErr.message}`);
|
|
218
|
+
await broadcastFile(deps, repo, reviewId, filePath);
|
|
219
|
+
filesProcessed++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let data;
|
|
224
|
+
let topLevelMalformed = false;
|
|
225
|
+
if (result && Array.isArray(result.summaries)) {
|
|
226
|
+
data = { summaries: result.summaries };
|
|
227
|
+
} else if (result && result.data && (result.parsed || result.success)) {
|
|
228
|
+
data = result.data;
|
|
229
|
+
} else {
|
|
230
|
+
const raw = (result && result.raw) || '';
|
|
231
|
+
const extracted = deps.extractJSON(raw, 'hunk-summary', summaryPrefix);
|
|
232
|
+
if (!extracted || !extracted.success) {
|
|
233
|
+
const errMsg = extracted && extracted.error ? extracted.error : 'unknown error';
|
|
234
|
+
logger.warn(`${summaryPrefix} Hunk summary JSON parse failed: ${errMsg}`);
|
|
235
|
+
topLevelMalformed = true;
|
|
236
|
+
} else {
|
|
237
|
+
data = extracted.data;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!topLevelMalformed && (!data || !Array.isArray(data.summaries))) {
|
|
242
|
+
logger.warn(`${summaryPrefix} Hunk summary response missing summaries[]`);
|
|
243
|
+
topLevelMalformed = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (topLevelMalformed) {
|
|
247
|
+
// Asymmetry vs per-slot malformed handling, intentional:
|
|
248
|
+
// - Envelope malformed (this branch): no sentinel persisted; the
|
|
249
|
+
// hunks are eligible for re-enqueue on the next reload, the same
|
|
250
|
+
// as a provider exception (see `execErr` catch above). Truncated
|
|
251
|
+
// streams, stray markdown fences, and "model rambled past the
|
|
252
|
+
// JSON" are transient failures and should not lock out hunks.
|
|
253
|
+
// - Per-slot malformed (below): the model returned a valid envelope
|
|
254
|
+
// but a specific entry was missing/wrong-type. That IS persisted
|
|
255
|
+
// as a `model_malformed` sentinel because the model spoke
|
|
256
|
+
// coherently for the file but not for that slot — re-enqueueing
|
|
257
|
+
// would just produce the same bad output.
|
|
258
|
+
await broadcastFile(deps, repo, reviewId, filePath);
|
|
259
|
+
filesProcessed++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Per-hunk classification. For each slot in `missing`, choose the best
|
|
264
|
+
// outcome from the model's entries:
|
|
265
|
+
// - valid (non-empty string) > model_skipped (null) > model_malformed
|
|
266
|
+
// Slots the model never returned for fall through to model_malformed.
|
|
267
|
+
// No truncation: the prompt sets the length budget; we trust the model.
|
|
268
|
+
const VALID = 0;
|
|
269
|
+
const SKIPPED = 1;
|
|
270
|
+
const MALFORMED = 2;
|
|
271
|
+
const slotState = new Array(missing.length).fill(null);
|
|
272
|
+
|
|
273
|
+
const setSlot = (idx, kind, summary) => {
|
|
274
|
+
const current = slotState[idx];
|
|
275
|
+
if (!current || kind < current.kind) {
|
|
276
|
+
slotState[idx] = { kind, summary };
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const item of data.summaries) {
|
|
281
|
+
if (!item || typeof item.index !== 'number') continue;
|
|
282
|
+
const idx = item.index - 1;
|
|
283
|
+
if (idx < 0 || idx >= missing.length) continue;
|
|
284
|
+
|
|
285
|
+
if (typeof item.summary === 'string' && item.summary.length > 0) {
|
|
286
|
+
setSlot(idx, VALID, item.summary);
|
|
287
|
+
} else if (item.summary === null) {
|
|
288
|
+
setSlot(idx, SKIPPED, null);
|
|
289
|
+
} else {
|
|
290
|
+
// undefined, empty string, wrong type
|
|
291
|
+
setSlot(idx, MALFORMED, null);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const rowsToPersist = [];
|
|
296
|
+
for (let i = 0; i < missing.length; i++) {
|
|
297
|
+
const state = slotState[i] || { kind: MALFORMED, summary: null };
|
|
298
|
+
if (state.kind === VALID) {
|
|
299
|
+
rowsToPersist.push({
|
|
300
|
+
review_id: reviewId,
|
|
301
|
+
file_path: filePath,
|
|
302
|
+
content_hash: missing[i].contentHash,
|
|
303
|
+
summary_text: state.summary,
|
|
304
|
+
trivial_reason: null,
|
|
305
|
+
provider: providerId,
|
|
306
|
+
model: resolvedModel
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
rowsToPersist.push({
|
|
310
|
+
review_id: reviewId,
|
|
311
|
+
file_path: filePath,
|
|
312
|
+
content_hash: missing[i].contentHash,
|
|
313
|
+
summary_text: null,
|
|
314
|
+
trivial_reason: state.kind === SKIPPED ? 'model_skipped' : 'model_malformed',
|
|
315
|
+
provider: providerId,
|
|
316
|
+
model: resolvedModel
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (rowsToPersist.length > 0) {
|
|
322
|
+
await repo.upsertMany(rowsToPersist);
|
|
323
|
+
hunksPersisted += rowsToPersist.length;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await broadcastFile(deps, repo, reviewId, filePath);
|
|
328
|
+
filesProcessed++;
|
|
329
|
+
} catch (fileErr) {
|
|
330
|
+
// AbortErrors must escape the file-level recover-and-continue path —
|
|
331
|
+
// otherwise we'd silently swallow a user cancel and move on to the
|
|
332
|
+
// next file, defeating the whole point of cancellation.
|
|
333
|
+
if (fileErr && (fileErr.name === 'AbortError' || fileErr.isCancellation)) {
|
|
334
|
+
throw fileErr;
|
|
335
|
+
}
|
|
336
|
+
logger.error(`${summaryPrefix} Hunk summary processing failed: ${fileErr.message}`);
|
|
337
|
+
await broadcastFile(deps, repo, reviewId, filePath);
|
|
338
|
+
filesProcessed++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { filesProcessed, hunksPersisted };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Fetch all summaries for a file and broadcast them on the review channel.
|
|
347
|
+
* @param {Object} deps
|
|
348
|
+
* @param {Object} repo
|
|
349
|
+
* @param {number} reviewId
|
|
350
|
+
* @param {string} filePath
|
|
351
|
+
* @returns {Promise<void>}
|
|
352
|
+
*/
|
|
353
|
+
async function broadcastFile(deps, repo, reviewId, filePath) {
|
|
354
|
+
try {
|
|
355
|
+
const fileRowsRaw = await repo.getByReviewAndFile(reviewId, filePath);
|
|
356
|
+
const fileRows = fileRowsRaw.map((r) => ({
|
|
357
|
+
file_path: r.file_path,
|
|
358
|
+
content_hash: r.content_hash,
|
|
359
|
+
summary_text: r.summary_text,
|
|
360
|
+
trivial_reason: r.trivial_reason
|
|
361
|
+
}));
|
|
362
|
+
deps.broadcastReviewEvent(reviewId, {
|
|
363
|
+
type: 'review:hunk_summaries_ready',
|
|
364
|
+
filePath,
|
|
365
|
+
summaries: fileRows
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
logger.warn(`[Summary ${path.basename(filePath)}] broadcast failed: ${err.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Gate the summary job and enqueue it on the background queue.
|
|
374
|
+
*
|
|
375
|
+
* `trigger` controls how the enabled/auto_generate config interacts with
|
|
376
|
+
* kickoff:
|
|
377
|
+
* - `'auto'` (default): requires `summaries.enabled && summaries.auto_generate`
|
|
378
|
+
* - `'manual'`: only requires `summaries.enabled` (user-initiated start)
|
|
379
|
+
*
|
|
380
|
+
* @param {Object} params
|
|
381
|
+
* @param {Object} params.db
|
|
382
|
+
* @param {Object} params.config
|
|
383
|
+
* @param {number} params.reviewId
|
|
384
|
+
* @param {string} params.diffText
|
|
385
|
+
* @param {string} params.worktreePath
|
|
386
|
+
* @param {Object} [params.reviewContext]
|
|
387
|
+
* @param {'auto'|'manual'} [params.trigger='auto']
|
|
388
|
+
* @param {Object} [params._deps]
|
|
389
|
+
* @returns {Promise<{filesProcessed: number, hunksPersisted: number}>|null}
|
|
390
|
+
*/
|
|
391
|
+
function kickOffSummaryJob({
|
|
392
|
+
db,
|
|
393
|
+
config,
|
|
394
|
+
reviewId,
|
|
395
|
+
diffText,
|
|
396
|
+
worktreePath,
|
|
397
|
+
reviewContext,
|
|
398
|
+
trigger = 'auto',
|
|
399
|
+
_deps
|
|
400
|
+
}) {
|
|
401
|
+
const deps = { ...defaults, ...(_deps || {}) };
|
|
402
|
+
|
|
403
|
+
if (!deps.getSummaryEnabled(config)) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!reviewId) {
|
|
408
|
+
logger.debug('kickOffSummaryJob skipped: missing reviewId');
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const queue = deps.backgroundQueue || require('./background-queue').backgroundQueue;
|
|
413
|
+
|
|
414
|
+
// Cancel any in-flight summaries job for this review whose digest no longer
|
|
415
|
+
// matches the new one. This is load-bearing for correctness, not just cost:
|
|
416
|
+
// generateSummariesForReview persists content_hash-keyed rows without any
|
|
417
|
+
// staleness check at upsert time (unlike tour-generator), so a stale worker
|
|
418
|
+
// that escapes cancellation will persist summaries for hunks the user has
|
|
419
|
+
// already moved past. Calling this even when the new diff is empty (refresh
|
|
420
|
+
// or scope change that removed all changes is a valid terminal snapshot)
|
|
421
|
+
// ensures the old job stops burning tokens and writing stale rows.
|
|
422
|
+
const cancelActiveSummariesJob = (excludeJobType) => {
|
|
423
|
+
if (typeof queue.findActiveJobType !== 'function') return;
|
|
424
|
+
const activeJobType = queue.findActiveJobType(reviewId, 'summaries');
|
|
425
|
+
if (activeJobType && activeJobType !== excludeJobType && typeof queue.cancel === 'function') {
|
|
426
|
+
queue.cancel(reviewId, activeJobType);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
if (!diffText || !worktreePath) {
|
|
431
|
+
const missing = [];
|
|
432
|
+
if (!diffText) missing.push('diffText');
|
|
433
|
+
if (!worktreePath) missing.push('worktreePath');
|
|
434
|
+
logger.debug(`kickOffSummaryJob skipped: missing ${missing.join(', ')}`);
|
|
435
|
+
cancelActiveSummariesJob();
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const digest = deps.hashDiff(diffText);
|
|
440
|
+
const newJobType = `summaries:${digest}`;
|
|
441
|
+
cancelActiveSummariesJob(newJobType);
|
|
442
|
+
|
|
443
|
+
// auto_generate gate, applied here — AFTER cancellation (both the empty-diff
|
|
444
|
+
// branch above and the digest-mismatch cancel on this line) — so stale-job
|
|
445
|
+
// cancellation runs regardless of trigger. This is load-bearing: the
|
|
446
|
+
// summaries worker has no pre-upsert staleness guard, so an escaped stale
|
|
447
|
+
// worker would persist content_hash-keyed rows for hunks the user moved
|
|
448
|
+
// past. With `auto_generate` off, an auto-triggered kickoff still cancels
|
|
449
|
+
// that stale job; it simply declines to enqueue a replacement. Manual
|
|
450
|
+
// kickoffs always proceed.
|
|
451
|
+
if (trigger !== 'manual' && !deps.getSummaryAutoGenerate(config)) return null;
|
|
452
|
+
|
|
453
|
+
return queue.enqueue(reviewId, newJobType, (signal) =>
|
|
454
|
+
generateSummariesForReview({
|
|
455
|
+
db,
|
|
456
|
+
config,
|
|
457
|
+
reviewId,
|
|
458
|
+
diffText,
|
|
459
|
+
worktreePath,
|
|
460
|
+
reviewContext,
|
|
461
|
+
// BackgroundQueue invokes our thunk as `fn(signal)`. Pass it through
|
|
462
|
+
// so a user cancel reaches the per-file provider.execute call.
|
|
463
|
+
abortSignal: signal,
|
|
464
|
+
_deps
|
|
465
|
+
})
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = { generateSummariesForReview, kickOffSummaryJob, countAddedLines };
|