@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,77 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Shared modal-detection helpers.
|
|
4
|
+
*
|
|
5
|
+
* Used by KeyboardShortcuts and PRManager so keyboard handlers consistently
|
|
6
|
+
* defer to whatever modal/dialog is currently open. Single source of truth
|
|
7
|
+
* for both the selector list and the visibility check — keeping them in
|
|
8
|
+
* sync across consumers was the original motivation for extracting this.
|
|
9
|
+
*
|
|
10
|
+
* The help overlay (`#keyboard-shortcuts-help`) is intentionally excluded
|
|
11
|
+
* from the selectors so the shortcuts overlay itself doesn't suppress
|
|
12
|
+
* Escape handling for its own close button.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Selectors that identify "a modal is open". Anything matched here will be
|
|
17
|
+
* treated as a blocker for unrelated keyboard shortcuts.
|
|
18
|
+
* @type {string[]}
|
|
19
|
+
*/
|
|
20
|
+
const MODAL_SELECTORS = [
|
|
21
|
+
'.modal-overlay:not(#keyboard-shortcuts-help)',
|
|
22
|
+
'.review-modal-overlay',
|
|
23
|
+
'.preview-modal-overlay',
|
|
24
|
+
'.confirm-dialog-overlay',
|
|
25
|
+
'.analysis-config-overlay',
|
|
26
|
+
'.ai-summary-modal-overlay',
|
|
27
|
+
'[role="dialog"]:not(#keyboard-shortcuts-help)'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return true when `element` is visually present — i.e. not hidden via
|
|
32
|
+
* `display`, `visibility`, or zero opacity. Mirrors the legacy
|
|
33
|
+
* KeyboardShortcuts.isElementVisible behavior so existing call sites keep
|
|
34
|
+
* the same semantics.
|
|
35
|
+
*
|
|
36
|
+
* @param {Element|null|undefined} element
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isElementVisible(element) {
|
|
40
|
+
if (!element) return false;
|
|
41
|
+
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const style = window.getComputedStyle(element);
|
|
45
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return true when at least one of the registered modal selectors matches
|
|
53
|
+
* a visible element in the document.
|
|
54
|
+
*
|
|
55
|
+
* Excludes the keyboard-shortcuts help overlay so it doesn't block its own
|
|
56
|
+
* close behavior.
|
|
57
|
+
*
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isModalOpen() {
|
|
61
|
+
if (typeof document === 'undefined') return false;
|
|
62
|
+
for (const selector of MODAL_SELECTORS) {
|
|
63
|
+
const el = document.querySelector(selector);
|
|
64
|
+
if (el && isElementVisible(el)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof window !== 'undefined') {
|
|
72
|
+
window.ModalDetection = { isModalOpen, isElementVisible, MODAL_SELECTORS };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
76
|
+
module.exports = { isModalOpen, isElementVisible, MODAL_SELECTORS };
|
|
77
|
+
}
|
package/public/local.html
CHANGED
|
@@ -448,6 +448,16 @@
|
|
|
448
448
|
</svg>
|
|
449
449
|
<span class="btn-text">Analyze</span>
|
|
450
450
|
</button>
|
|
451
|
+
<button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
|
|
452
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
453
|
+
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
|
|
454
|
+
</svg>
|
|
455
|
+
</button>
|
|
456
|
+
<button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
|
|
457
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
458
|
+
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
|
|
459
|
+
</svg>
|
|
460
|
+
</button>
|
|
451
461
|
<button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
|
|
452
462
|
<svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
453
463
|
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
@@ -597,6 +607,9 @@
|
|
|
597
607
|
<!-- Timestamp parsing utility -->
|
|
598
608
|
<script src="/js/utils/time.js"></script>
|
|
599
609
|
|
|
610
|
+
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
611
|
+
<script src="/js/utils/modal-detection.js"></script>
|
|
612
|
+
|
|
600
613
|
<!-- WebSocket client -->
|
|
601
614
|
<script src="/js/ws-client.js"></script>
|
|
602
615
|
|
|
@@ -639,6 +652,10 @@
|
|
|
639
652
|
<script src="/js/modules/analysis-history.js"></script>
|
|
640
653
|
<script src="/js/modules/diff-context.js"></script>
|
|
641
654
|
<script src="/js/modules/file-list-merger.js"></script>
|
|
655
|
+
<script src="/js/modules/hunk-summary-renderer.js"></script>
|
|
656
|
+
<script src="/js/modules/tour-renderer.js"></script>
|
|
657
|
+
<script src="/js/modules/cancel-background-job.js"></script>
|
|
658
|
+
<script src="/js/components/TourBar.js"></script>
|
|
642
659
|
|
|
643
660
|
<!-- Chat Panel Component -->
|
|
644
661
|
<script src="/js/components/ChatPanel.js"></script>
|
package/public/pr.html
CHANGED
|
@@ -244,6 +244,16 @@
|
|
|
244
244
|
</svg>
|
|
245
245
|
<span class="btn-text">Analyze</span>
|
|
246
246
|
</button>
|
|
247
|
+
<button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
|
|
248
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
249
|
+
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
|
|
250
|
+
</svg>
|
|
251
|
+
</button>
|
|
252
|
+
<button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
|
|
253
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
254
|
+
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
|
|
255
|
+
</svg>
|
|
256
|
+
</button>
|
|
247
257
|
<button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
|
|
248
258
|
<svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
249
259
|
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
@@ -400,6 +410,9 @@
|
|
|
400
410
|
<!-- Timestamp parsing utility -->
|
|
401
411
|
<script src="/js/utils/time.js"></script>
|
|
402
412
|
|
|
413
|
+
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
414
|
+
<script src="/js/utils/modal-detection.js"></script>
|
|
415
|
+
|
|
403
416
|
<!-- WebSocket client -->
|
|
404
417
|
<script src="/js/ws-client.js"></script>
|
|
405
418
|
|
|
@@ -443,6 +456,10 @@
|
|
|
443
456
|
<script src="/js/modules/analysis-history.js"></script>
|
|
444
457
|
<script src="/js/modules/diff-context.js"></script>
|
|
445
458
|
<script src="/js/modules/file-list-merger.js"></script>
|
|
459
|
+
<script src="/js/modules/hunk-summary-renderer.js"></script>
|
|
460
|
+
<script src="/js/modules/tour-renderer.js"></script>
|
|
461
|
+
<script src="/js/modules/cancel-background-job.js"></script>
|
|
462
|
+
<script src="/js/components/TourBar.js"></script>
|
|
446
463
|
|
|
447
464
|
<!-- Chat Panel Component -->
|
|
448
465
|
<script src="/js/components/ChatPanel.js"></script>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attach an `AbortSignal` to a spawned child process so that aborting the
|
|
8
|
+
* signal kills the child with `SIGTERM`. Returns a cleanup function that
|
|
9
|
+
* detaches the abort listener — call it from the `close` / `error` /
|
|
10
|
+
* `settle` handler so the listener never outlives the process.
|
|
11
|
+
*
|
|
12
|
+
* Pattern: every provider that spawns an upstream CLI for tour/summary
|
|
13
|
+
* generation calls this once right after `spawn(...)`. The returned
|
|
14
|
+
* `cancelled` getter is included so the post-exit path can distinguish a
|
|
15
|
+
* user-initiated cancel (exit due to SIGTERM we sent) from a real failure.
|
|
16
|
+
*
|
|
17
|
+
* If `signal` is already aborted at the time of wiring, the child is
|
|
18
|
+
* killed immediately and `cancelled` is set to true. Callers should still
|
|
19
|
+
* check `cancelled` before treating the eventual exit as a "real" error.
|
|
20
|
+
*
|
|
21
|
+
* Shell-mode caveat: when the caller spawned with `shell: true`, the
|
|
22
|
+
* `child` we hold is the shell, not the underlying CLI. `child.kill()`
|
|
23
|
+
* only terminates the shell; the grandchild CLI keeps burning tokens.
|
|
24
|
+
* Pass `{ shell: true }` here so we signal the whole process group via
|
|
25
|
+
* `process.kill(-pid, 'SIGTERM')` instead. On Windows we fall back to
|
|
26
|
+
* `taskkill /T /F /PID`. Prefer `shell: false` invocation when an
|
|
27
|
+
* abortSignal is in play — fewer moving parts.
|
|
28
|
+
*
|
|
29
|
+
* @param {import('child_process').ChildProcess} child - Spawned process.
|
|
30
|
+
* @param {AbortSignal | null | undefined} signal - Signal to listen on.
|
|
31
|
+
* @param {Object} [opts]
|
|
32
|
+
* @param {string} [opts.logPrefix] - Log prefix for diagnostics.
|
|
33
|
+
* @param {boolean} [opts.shell=false] - True when the child was spawned
|
|
34
|
+
* with `shell: true`. Causes group-kill semantics so the grandchild CLI
|
|
35
|
+
* dies along with the shell wrapper.
|
|
36
|
+
* @returns {{cancelled: () => boolean, detach: () => void}}
|
|
37
|
+
*/
|
|
38
|
+
function wireAbortToChild(child, signal, opts = {}) {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
if (!signal) {
|
|
41
|
+
return { cancelled: () => cancelled, detach: () => {} };
|
|
42
|
+
}
|
|
43
|
+
const prefix = opts.logPrefix || '';
|
|
44
|
+
const isShell = opts.shell === true;
|
|
45
|
+
|
|
46
|
+
const killChild = () => {
|
|
47
|
+
// `kill` / process group signaling returns false (or throws ESRCH) if
|
|
48
|
+
// the process is already gone, which is fine — we just need the side
|
|
49
|
+
// effect when it IS still alive.
|
|
50
|
+
if (isShell && child.pid && process.platform !== 'win32') {
|
|
51
|
+
// Group-kill the shell AND its CLI descendant. Requires the caller
|
|
52
|
+
// to have spawned with `detached: true` so the child became a
|
|
53
|
+
// process-group leader (`-pid` targets the group).
|
|
54
|
+
try {
|
|
55
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
56
|
+
return;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err && err.code === 'ESRCH') {
|
|
59
|
+
// Group already gone — nothing to kill.
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Fall through to single-process kill as a best effort.
|
|
63
|
+
logger.warn(
|
|
64
|
+
`${prefix} process.kill(-pid) failed (${err.message}); falling back to child.kill`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (isShell && child.pid && process.platform === 'win32') {
|
|
69
|
+
// Windows has no process groups: spawn taskkill /T /F to wipe the
|
|
70
|
+
// tree rooted at our shell pid.
|
|
71
|
+
try {
|
|
72
|
+
spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' })
|
|
73
|
+
.on('error', (err) => {
|
|
74
|
+
logger.warn(`${prefix} taskkill failed: ${err.message}`);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
logger.warn(
|
|
79
|
+
`${prefix} spawn(taskkill) failed (${err.message}); falling back to child.kill`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
child.kill('SIGTERM');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
try {
|
|
89
|
+
killChild();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
logger.warn(`${prefix} child.kill on abort failed: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
// Pre-aborted: trigger the kill immediately. The eventual `close`
|
|
97
|
+
// handler will see `cancelled === true` and short-circuit.
|
|
98
|
+
onAbort();
|
|
99
|
+
} else {
|
|
100
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
cancelled: () => cancelled,
|
|
105
|
+
detach: () => {
|
|
106
|
+
try {
|
|
107
|
+
signal.removeEventListener('abort', onAbort);
|
|
108
|
+
} catch {
|
|
109
|
+
// Older AbortSignal polyfills may lack removeEventListener; safe to ignore.
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a standardized cancellation error. Providers should throw this
|
|
117
|
+
* (or reject with it) when they detect the abort wiring fired, so the
|
|
118
|
+
* BackgroundQueue's broadcast can mark the job as `cancelled: true`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} [message] - Human-readable context (defaults to 'cancelled').
|
|
121
|
+
* @returns {Error}
|
|
122
|
+
*/
|
|
123
|
+
function makeAbortError(message) {
|
|
124
|
+
const err = new Error(message || 'cancelled');
|
|
125
|
+
err.name = 'AbortError';
|
|
126
|
+
err.isCancellation = true;
|
|
127
|
+
return err;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { wireAbortToChild, makeAbortError };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const { broadcastReviewEvent } = require('../events/review-events');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
const { makeAbortError } = require('./abort-signal-wiring');
|
|
6
|
+
|
|
7
|
+
const BACKGROUND_QUEUE_CONCURRENCY = 2;
|
|
8
|
+
|
|
9
|
+
const defaults = {
|
|
10
|
+
broadcast: broadcastReviewEvent,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded-concurrency in-process queue with per-key dedup.
|
|
15
|
+
*
|
|
16
|
+
* Jobs are keyed by `${reviewId}:${jobType}`; concurrent enqueues
|
|
17
|
+
* for the same key share a single execution and a single promise.
|
|
18
|
+
*
|
|
19
|
+
* Cancellation: each enqueued job is associated with an `AbortController`.
|
|
20
|
+
* The worker thunk is invoked as `fn(signal)` so it can plumb the signal
|
|
21
|
+
* into downstream provider calls / `fetch` / `child_process.spawn`.
|
|
22
|
+
* Callers cancel via `cancel(reviewId, jobKey)`, which aborts the
|
|
23
|
+
* signal and removes the controller; the worker is expected to react
|
|
24
|
+
* to the abort and settle (typically by rejecting with an AbortError).
|
|
25
|
+
*/
|
|
26
|
+
class BackgroundQueue {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {number} [options.concurrency] - Max concurrent jobs.
|
|
30
|
+
* @param {Object} [options._deps] - Override dependencies (testing).
|
|
31
|
+
* @param {Function} [options._deps.broadcast] - Broadcast hook.
|
|
32
|
+
*/
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
const { concurrency = BACKGROUND_QUEUE_CONCURRENCY, _deps = {} } = options;
|
|
35
|
+
this.concurrency = concurrency;
|
|
36
|
+
this.active = 0;
|
|
37
|
+
this.queue = [];
|
|
38
|
+
this.inFlight = new Map();
|
|
39
|
+
// Per-key AbortController covers both queued and running jobs so a
|
|
40
|
+
// single lookup can resolve cancellation against either state.
|
|
41
|
+
this.controllers = new Map();
|
|
42
|
+
this._deps = { ...defaults, ..._deps };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enqueue a job for execution.
|
|
47
|
+
*
|
|
48
|
+
* Dedup contract: if a job for the same `(reviewId, jobType)` key is
|
|
49
|
+
* already queued or running, this returns the existing promise without
|
|
50
|
+
* invoking `fn`. The duplicate `fn` is silently dropped.
|
|
51
|
+
*
|
|
52
|
+
* The thunk is called as `fn(signal)` where `signal` is the `AbortSignal`
|
|
53
|
+
* for this job. Workers that touch the network, spawn processes, or
|
|
54
|
+
* otherwise burn upstream resources should thread the signal through so
|
|
55
|
+
* cancellation actually frees those resources.
|
|
56
|
+
*
|
|
57
|
+
* @param {string|number} reviewId - Review identifier.
|
|
58
|
+
* @param {string} jobType - Job category (e.g. 'summaries', 'tour').
|
|
59
|
+
* @param {Function} fn - Thunk `(signal) => value|Promise<value>`.
|
|
60
|
+
* @returns {Promise} Resolves/rejects with the job result.
|
|
61
|
+
*/
|
|
62
|
+
enqueue(reviewId, jobType, fn) {
|
|
63
|
+
const key = `${reviewId}:${jobType}`;
|
|
64
|
+
if (this.inFlight.has(key)) {
|
|
65
|
+
return this.inFlight.get(key);
|
|
66
|
+
}
|
|
67
|
+
let resolve;
|
|
68
|
+
let reject;
|
|
69
|
+
const p = new Promise((res, rej) => {
|
|
70
|
+
resolve = res;
|
|
71
|
+
reject = rej;
|
|
72
|
+
});
|
|
73
|
+
this.inFlight.set(key, p);
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
this.controllers.set(key, controller);
|
|
76
|
+
this.queue.push({
|
|
77
|
+
key,
|
|
78
|
+
run: fn,
|
|
79
|
+
resolve,
|
|
80
|
+
reject,
|
|
81
|
+
reviewId,
|
|
82
|
+
jobType,
|
|
83
|
+
controller,
|
|
84
|
+
promise: p,
|
|
85
|
+
});
|
|
86
|
+
this._drain();
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cancel an in-flight or queued job. Aborts its `AbortSignal` so the
|
|
92
|
+
* worker can tear down upstream resources, then drops the controller.
|
|
93
|
+
*
|
|
94
|
+
* Matching is exact on `(reviewId, jobKey)`. For composite jobTypes
|
|
95
|
+
* like `summaries:${digest}`, callers may also pass a bare prefix
|
|
96
|
+
* (`summaries`) — this cancels ALL matching `summaries:*` jobs for the
|
|
97
|
+
* review. This is what the toolbar "Cancel Summaries" button needs:
|
|
98
|
+
* users don't know about digests, they just want the pulse to stop.
|
|
99
|
+
*
|
|
100
|
+
* @param {string|number} reviewId
|
|
101
|
+
* @param {string} jobKey - bare `jobType` (e.g. `tour`) or full key
|
|
102
|
+
* suffix (e.g. `summaries:abc123`).
|
|
103
|
+
* @returns {{cancelled: number}} number of jobs aborted.
|
|
104
|
+
*/
|
|
105
|
+
cancel(reviewId, jobKey) {
|
|
106
|
+
if (jobKey === undefined || jobKey === null || jobKey === '') {
|
|
107
|
+
return { cancelled: 0 };
|
|
108
|
+
}
|
|
109
|
+
const exact = `${reviewId}:${jobKey}`;
|
|
110
|
+
const prefix = `${exact}:`;
|
|
111
|
+
let cancelled = 0;
|
|
112
|
+
// Snapshot keys before aborting — settling a worker mid-iteration would
|
|
113
|
+
// mutate this.controllers (via _settle).
|
|
114
|
+
const keys = Array.from(this.controllers.keys());
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (key !== exact && !key.startsWith(prefix)) continue;
|
|
117
|
+
const controller = this.controllers.get(key);
|
|
118
|
+
if (!controller) continue;
|
|
119
|
+
try {
|
|
120
|
+
controller.abort();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger.warn(`BackgroundQueue controller.abort() failed for ${key}: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
// Eagerly evict the cancelled key from the dedup/controller maps and
|
|
125
|
+
// splice any not-yet-started descriptors out of the queue. Without
|
|
126
|
+
// this, a follow-up enqueue() for the same key would hit the dedup
|
|
127
|
+
// guard and inherit the about-to-reject promise, and _drain() could
|
|
128
|
+
// hand the worker an already-aborted signal. _settle()'s deletes are
|
|
129
|
+
// identity-guarded, so when the cancelled worker eventually rejects
|
|
130
|
+
// it won't clobber a replacement job installed under the same key.
|
|
131
|
+
this._evictKey(key);
|
|
132
|
+
cancelled++;
|
|
133
|
+
}
|
|
134
|
+
return { cancelled };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove a key from the dedup/controller maps and reject any queued (not
|
|
139
|
+
* yet started) descriptor with an AbortError. Safe to call when the key
|
|
140
|
+
* has already been cleaned up — Map.delete and Array.splice both no-op.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} key - Composite `${reviewId}:${jobType}` key.
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_evictKey(key) {
|
|
146
|
+
// Splice queued descriptors and reject their promises so the dedup'd
|
|
147
|
+
// caller (if any) sees a clean cancellation rather than a hung promise.
|
|
148
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
149
|
+
if (this.queue[i].key !== key) continue;
|
|
150
|
+
const [descriptor] = this.queue.splice(i, 1);
|
|
151
|
+
try {
|
|
152
|
+
descriptor.reject(makeAbortError('Job cancelled before start'));
|
|
153
|
+
} catch (rejectErr) {
|
|
154
|
+
logger.warn(
|
|
155
|
+
`BackgroundQueue descriptor.reject failed for ${key}: ${rejectErr.message}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
this.inFlight.delete(key);
|
|
160
|
+
this.controllers.delete(key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Start as many queued jobs as concurrency allows. */
|
|
164
|
+
_drain() {
|
|
165
|
+
while (this.active < this.concurrency && this.queue.length > 0) {
|
|
166
|
+
const descriptor = this.queue.shift();
|
|
167
|
+
this.active++;
|
|
168
|
+
Promise.resolve()
|
|
169
|
+
.then(() => descriptor.run(descriptor.controller.signal))
|
|
170
|
+
.then(
|
|
171
|
+
(result) => this._settle(descriptor, null, result),
|
|
172
|
+
(error) => this._settle(descriptor, error, undefined)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Finalize a job: free its key, broadcast, settle, and drain. */
|
|
178
|
+
_settle(descriptor, error, result) {
|
|
179
|
+
// Identity-guarded cleanup: if cancel() evicted this descriptor and a
|
|
180
|
+
// replacement was enqueued under the same key, the maps now point at
|
|
181
|
+
// the new descriptor's controller/promise — unconditional deletes would
|
|
182
|
+
// wipe the replacement's bookkeeping (invisible to hasActiveForReview,
|
|
183
|
+
// immune to cancel, vulnerable to duplicate enqueue).
|
|
184
|
+
if (this.controllers.get(descriptor.key) === descriptor.controller) {
|
|
185
|
+
this.controllers.delete(descriptor.key);
|
|
186
|
+
}
|
|
187
|
+
if (this.inFlight.get(descriptor.key) === descriptor.promise) {
|
|
188
|
+
this.inFlight.delete(descriptor.key);
|
|
189
|
+
}
|
|
190
|
+
this.active--;
|
|
191
|
+
this._onComplete(descriptor.reviewId, descriptor.jobType, error);
|
|
192
|
+
if (error === null) {
|
|
193
|
+
descriptor.resolve(result);
|
|
194
|
+
} else {
|
|
195
|
+
descriptor.reject(error);
|
|
196
|
+
}
|
|
197
|
+
this._drain();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Is there an in-flight or queued job for this review whose jobType
|
|
202
|
+
* starts with the given prefix? Useful for surfacing a "generating"
|
|
203
|
+
* indicator on the frontend (`hasActiveForReview(id, 'summaries')`).
|
|
204
|
+
*
|
|
205
|
+
* Job keys are stored as `${reviewId}:${jobType}`; `summaries` jobs use
|
|
206
|
+
* the form `summaries:${digest}`, so a prefix match on
|
|
207
|
+
* `${reviewId}:summaries` catches every digest variant.
|
|
208
|
+
*
|
|
209
|
+
* @param {string|number} reviewId
|
|
210
|
+
* @param {string} jobTypePrefix
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
hasActiveForReview(reviewId, jobTypePrefix) {
|
|
214
|
+
if (!jobTypePrefix) return false;
|
|
215
|
+
const prefix = `${reviewId}:${jobTypePrefix}`;
|
|
216
|
+
for (const key of this.inFlight.keys()) {
|
|
217
|
+
if (key === prefix || key.startsWith(prefix + ':')) return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Like `hasActiveForReview` but returns the in-flight/queued jobType string
|
|
224
|
+
* (without the `${reviewId}:` prefix), or null. Useful when callers track a
|
|
225
|
+
* versioned key like `summaries:${digest}` and need to know the *exact* key
|
|
226
|
+
* to cancel before re-enqueueing under a new digest.
|
|
227
|
+
*
|
|
228
|
+
* @param {string|number} reviewId
|
|
229
|
+
* @param {string} jobTypePrefix
|
|
230
|
+
* @returns {string|null}
|
|
231
|
+
*/
|
|
232
|
+
findActiveJobType(reviewId, jobTypePrefix) {
|
|
233
|
+
if (!jobTypePrefix) return null;
|
|
234
|
+
const prefix = `${reviewId}:${jobTypePrefix}`;
|
|
235
|
+
for (const key of this.inFlight.keys()) {
|
|
236
|
+
if (key === prefix || key.startsWith(prefix + ':')) {
|
|
237
|
+
return key.slice(String(reviewId).length + 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Broadcast job completion; broadcast failures are logged, not thrown. */
|
|
244
|
+
_onComplete(reviewId, jobType, error) {
|
|
245
|
+
try {
|
|
246
|
+
// Include whether more jobs of the same type-prefix remain queued or
|
|
247
|
+
// in-flight so listeners (e.g. the summaries toolbar pulse) don't
|
|
248
|
+
// clear their "generating" state when a sibling job is still running.
|
|
249
|
+
// For composite types like `summaries:${digest}`, we strip the suffix
|
|
250
|
+
// so the prefix match catches every digest variant.
|
|
251
|
+
const colonIdx = jobType.indexOf(':');
|
|
252
|
+
const prefix = colonIdx >= 0 ? jobType.slice(0, colonIdx) : jobType;
|
|
253
|
+
const hasActiveForType = this.hasActiveForReview(reviewId, prefix);
|
|
254
|
+
this._deps.broadcast(reviewId, {
|
|
255
|
+
type: 'review:background_job_finished',
|
|
256
|
+
jobType,
|
|
257
|
+
ok: error === null,
|
|
258
|
+
hasActiveForType,
|
|
259
|
+
cancelled: isAbortError(error),
|
|
260
|
+
});
|
|
261
|
+
} catch (broadcastError) {
|
|
262
|
+
logger.warn(
|
|
263
|
+
`BackgroundQueue broadcast failed for ${reviewId}:${jobType}: ${broadcastError.message}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Recognize errors that originated from `AbortController.abort()`.
|
|
271
|
+
* Node sets `name === 'AbortError'` on the DOMException for AbortSignal,
|
|
272
|
+
* but providers that wrap the abort in a custom Error may instead set
|
|
273
|
+
* `code === 'ABORT_ERR'` or surface `signal.aborted` themselves. Check
|
|
274
|
+
* the common shapes so the broadcast payload is honest about cancels.
|
|
275
|
+
*
|
|
276
|
+
* @param {unknown} err
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
function isAbortError(err) {
|
|
280
|
+
if (!err) return false;
|
|
281
|
+
if (typeof err !== 'object') return false;
|
|
282
|
+
if (err.name === 'AbortError') return true;
|
|
283
|
+
if (err.code === 'ABORT_ERR') return true;
|
|
284
|
+
if (err.isCancellation === true) return true;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const backgroundQueue = new BackgroundQueue();
|
|
289
|
+
|
|
290
|
+
module.exports = { BackgroundQueue, backgroundQueue, BACKGROUND_QUEUE_CONCURRENCY, isAbortError };
|
package/src/ai/claude-cli.js
CHANGED
|
@@ -100,7 +100,7 @@ class ClaudeCLI {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Extract JSON from the text response using robust extraction strategies
|
|
103
|
-
const extracted = extractJSON(stdout, level);
|
|
103
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
104
104
|
if (extracted.success) {
|
|
105
105
|
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
106
106
|
resolve(extracted.data);
|