mstro-app 0.5.1 → 0.5.5
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/PRIVACY.md +9 -9
- package/README.md +71 -28
- package/bin/commands/config.js +1 -1
- package/bin/mstro.js +55 -4
- package/dist/server/cli/eta-estimator.d.ts +55 -0
- package/dist/server/cli/eta-estimator.d.ts.map +1 -0
- package/dist/server/cli/eta-estimator.js +222 -0
- package/dist/server/cli/eta-estimator.js.map +1 -0
- package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +64 -9
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +19 -12
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.js +5 -1
- package/dist/server/cli/improvisation-history-store.js.map +1 -1
- package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
- package/dist/server/cli/improvisation-output-queue.js +30 -7
- package/dist/server/cli/improvisation-output-queue.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +29 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +50 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +2 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/engines/EngineEvent.d.ts +126 -0
- package/dist/server/engines/EngineEvent.d.ts.map +1 -0
- package/dist/server/engines/EngineEvent.js +11 -0
- package/dist/server/engines/EngineEvent.js.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
- package/dist/server/engines/factory.d.ts +21 -0
- package/dist/server/engines/factory.d.ts.map +1 -0
- package/dist/server/engines/factory.js +152 -0
- package/dist/server/engines/factory.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
- package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
- package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
- package/dist/server/engines/opencode/model-catalog.js +141 -0
- package/dist/server/engines/opencode/model-catalog.js.map +1 -0
- package/dist/server/engines/types.d.ts +146 -0
- package/dist/server/engines/types.d.ts.map +1 -0
- package/dist/server/engines/types.js +4 -0
- package/dist/server/engines/types.js.map +1 -0
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +8 -124
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +45 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +69 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/factory.d.ts +70 -0
- package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
- package/dist/server/mcp/classifier/factory.js +155 -0
- package/dist/server/mcp/classifier/factory.js.map +1 -0
- package/dist/server/services/plan/agent-resolver.d.ts +26 -0
- package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/agent-resolver.js +102 -0
- package/dist/server/services/plan/agent-resolver.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +59 -11
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +3 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +33 -1
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/settings.d.ts +76 -2
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +127 -4
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +19 -6
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +17 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +54 -2
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +78 -26
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-eta.d.ts +47 -0
- package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-eta.js +110 -0
- package/dist/server/services/websocket/quality-eta.js.map +1 -0
- package/dist/server/services/websocket/quality-grading.d.ts +27 -4
- package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-grading.js +369 -201
- package/dist/server/services/websocket/quality-grading.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +145 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-operations.d.ts +34 -0
- package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-operations.js +47 -0
- package/dist/server/services/websocket/quality-operations.js.map +1 -0
- package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +10 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +105 -56
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +9 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +334 -14
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +21 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +49 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +35 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +57 -9
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.js +3 -0
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +158 -42
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +25 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +19 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +35 -4
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.js +10 -2
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.js +138 -12
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.js +55 -2
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +47 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +28 -5
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +10 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +5 -3
- package/server/cli/eta-estimator.ts +249 -0
- package/server/cli/headless/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- package/server/cli/improvisation-history-store.ts +4 -1
- package/server/cli/improvisation-output-queue.ts +29 -7
- package/server/cli/improvisation-session-manager.ts +54 -1
- package/server/cli/improvisation-types.ts +2 -0
- package/server/engines/EngineEvent.ts +156 -0
- package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
- package/server/engines/factory.ts +176 -0
- package/server/engines/opencode/OpenCodeEngine.ts +786 -0
- package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
- package/server/engines/opencode/model-catalog.ts +217 -0
- package/server/engines/types.ts +173 -0
- package/server/index.ts +1 -1
- package/server/mcp/bouncer-haiku.ts +21 -145
- package/server/mcp/bouncer-integration.ts +107 -5
- package/server/mcp/classifier/BouncerClassifier.ts +40 -0
- package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
- package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
- package/server/mcp/classifier/factory.ts +195 -0
- package/server/services/plan/agent-resolver.ts +115 -0
- package/server/services/plan/agents/code-review.md +38 -8
- package/server/services/plan/composer.ts +63 -11
- package/server/services/plan/executor.ts +3 -1
- package/server/services/plan/issue-prompt-builder.ts +39 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/types.ts +4 -0
- package/server/services/settings.ts +161 -4
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +59 -2
- package/server/services/websocket/quality-complexity.ts +80 -26
- package/server/services/websocket/quality-eta.ts +155 -0
- package/server/services/websocket/quality-grading.ts +445 -222
- package/server/services/websocket/quality-handlers.ts +153 -7
- package/server/services/websocket/quality-operations.ts +72 -0
- package/server/services/websocket/quality-persistence.ts +17 -0
- package/server/services/websocket/quality-review-agent.ts +154 -64
- package/server/services/websocket/quality-service.ts +361 -13
- package/server/services/websocket/quality-tools.ts +51 -0
- package/server/services/websocket/quality-types.ts +41 -2
- package/server/services/websocket/session-handlers.ts +64 -10
- package/server/services/websocket/session-history.ts +3 -0
- package/server/services/websocket/session-initialization.ts +189 -46
- package/server/services/websocket/session-registry.ts +37 -0
- package/server/services/websocket/settings-handlers.ts +41 -4
- package/server/services/websocket/tab-broadcast.ts +10 -2
- package/server/services/websocket/tab-event-buffer.ts +143 -11
- package/server/services/websocket/tab-event-replay.ts +70 -3
- package/server/services/websocket/tab-handlers.ts +53 -5
- package/server/services/websocket/types.ts +37 -5
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
import { join, resolve } from 'node:path';
|
|
14
14
|
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
15
15
|
import type { HandlerContext } from './handler-context.js';
|
|
16
|
+
import { estimateCodebaseSize, estimateScanMs } from './quality-eta.js';
|
|
17
|
+
import { operationRegistry } from './quality-operations.js';
|
|
16
18
|
import { QualityPersistence } from './quality-persistence.js';
|
|
17
19
|
import { handleCodeReview } from './quality-review-agent.js';
|
|
18
|
-
import { detectTools, installTools, runQualityScan } from './quality-service.js';
|
|
20
|
+
import { detectTools, installTools, QualityScanAbortedError, runQualityScan } from './quality-service.js';
|
|
19
21
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
20
22
|
|
|
21
23
|
// ── Shared state ──────────────────────────────────────────────
|
|
22
24
|
|
|
23
25
|
const persistenceCache = new Map<string, QualityPersistence>();
|
|
24
|
-
const activeReviews = new Set<string>();
|
|
25
26
|
|
|
26
27
|
function getPersistence(workingDir: string): QualityPersistence {
|
|
27
28
|
let persistence = persistenceCache.get(workingDir);
|
|
@@ -86,10 +87,32 @@ export function handleQualityMessage(
|
|
|
86
87
|
if (error) { sendPathError(msg.data?.path || '.', error); return; }
|
|
87
88
|
const reportPath = msg.data?.path || '.';
|
|
88
89
|
const persistence = getPersistence(workingDir);
|
|
90
|
+
let controller: AbortController;
|
|
91
|
+
try {
|
|
92
|
+
controller = operationRegistry.start(workingDir, reportPath, 'reviewing');
|
|
93
|
+
} catch {
|
|
94
|
+
// Look up what's actually running so the user sees the right message
|
|
95
|
+
// — clicking "Run checks" during an in-flight AI review used to surface
|
|
96
|
+
// "A scan is already running" because the error was hardcoded to the
|
|
97
|
+
// *new* op kind rather than the existing one.
|
|
98
|
+
const runningKind = operationRegistry.getKind(workingDir, reportPath);
|
|
99
|
+
const error = runningKind === 'scanning'
|
|
100
|
+
? 'A scan is already running for this directory.'
|
|
101
|
+
: 'An AI review is already running for this directory.';
|
|
102
|
+
ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error } });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
89
105
|
persistence.setActiveOperation(reportPath, 'reviewing');
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
// The review agent is responsible for emitting the first progress
|
|
107
|
+
// message with an ETA; the handler just wires up the controller +
|
|
108
|
+
// persistence cleanup.
|
|
109
|
+
handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, getPersistence, controller.signal)
|
|
110
|
+
.finally(() => {
|
|
111
|
+
operationRegistry.finish(workingDir, reportPath);
|
|
112
|
+
persistence.clearActiveOperation(reportPath);
|
|
113
|
+
});
|
|
92
114
|
},
|
|
115
|
+
qualityCancel: () => handleCancel(ctx, msg, workingDir),
|
|
93
116
|
qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
|
|
94
117
|
qualityClearPending: () => {
|
|
95
118
|
const persistence = getPersistence(workingDir);
|
|
@@ -137,6 +160,27 @@ async function handleLoadState(
|
|
|
137
160
|
persistence.clearPendingResults();
|
|
138
161
|
}
|
|
139
162
|
|
|
163
|
+
// Reconcile orphaned active operations: anything persisted to disk that
|
|
164
|
+
// has no live `AbortController` in the registry was interrupted by a CLI
|
|
165
|
+
// restart or crash. Surface as an error so the UI clears the spinner and
|
|
166
|
+
// remove from disk so the same op doesn't keep haunting future reconnects.
|
|
167
|
+
const orphans = state.activeOperations.filter(
|
|
168
|
+
(op) => !operationRegistry.has(workingDir, op.path),
|
|
169
|
+
);
|
|
170
|
+
if (orphans.length > 0) {
|
|
171
|
+
for (const op of orphans) {
|
|
172
|
+
persistence.clearActiveOperation(op.path);
|
|
173
|
+
ctx.send(ws, {
|
|
174
|
+
type: 'qualityError',
|
|
175
|
+
data: { path: op.path, error: 'Operation interrupted — please run again.' },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// Reload so the response reflects the cleared ops.
|
|
179
|
+
const refreshed = persistence.loadState();
|
|
180
|
+
ctx.send(ws, { type: 'qualityStateLoaded', data: refreshed });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
140
184
|
ctx.send(ws, { type: 'qualityStateLoaded', data: state });
|
|
141
185
|
} catch (error) {
|
|
142
186
|
ctx.send(ws, {
|
|
@@ -146,6 +190,30 @@ async function handleLoadState(
|
|
|
146
190
|
}
|
|
147
191
|
}
|
|
148
192
|
|
|
193
|
+
function handleCancel(
|
|
194
|
+
ctx: HandlerContext,
|
|
195
|
+
msg: WebSocketMessage,
|
|
196
|
+
workingDir: string,
|
|
197
|
+
): void {
|
|
198
|
+
const reportPath = msg.data?.path || '.';
|
|
199
|
+
const persistence = getPersistence(workingDir);
|
|
200
|
+
const wasRunning = operationRegistry.cancel(workingDir, reportPath);
|
|
201
|
+
// Always clear persistence — cancel for an orphan should still leave the
|
|
202
|
+
// disk clean so future reconnects don't re-emit the orphan reconciliation
|
|
203
|
+
// error.
|
|
204
|
+
persistence.clearActiveOperation(reportPath);
|
|
205
|
+
// Broadcast so every paired device sees the operation end (multi-device
|
|
206
|
+
// sync). If nothing was running we still emit so a stale spinner on a
|
|
207
|
+
// second device gets cleared by the `qualityError` handler.
|
|
208
|
+
ctx.broadcastToAll({
|
|
209
|
+
type: 'qualityError',
|
|
210
|
+
data: {
|
|
211
|
+
path: reportPath,
|
|
212
|
+
error: wasRunning ? 'Cancelled by user' : 'Operation already finished',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
149
217
|
async function handleSaveDirectories(
|
|
150
218
|
ctx: HandlerContext,
|
|
151
219
|
ws: WSContext,
|
|
@@ -230,18 +298,90 @@ async function handleScan(
|
|
|
230
298
|
const reportPath = msg.data?.path || '.';
|
|
231
299
|
const persistence = getPersistence(workingDir);
|
|
232
300
|
|
|
301
|
+
let controller: AbortController;
|
|
302
|
+
try {
|
|
303
|
+
controller = operationRegistry.start(workingDir, reportPath, 'scanning');
|
|
304
|
+
} catch {
|
|
305
|
+
// Same reasoning as the qualityCodeReview handler above — surface the
|
|
306
|
+
// *running* op kind, not the requested one.
|
|
307
|
+
const runningKind = operationRegistry.getKind(workingDir, reportPath);
|
|
308
|
+
const error = runningKind === 'reviewing'
|
|
309
|
+
? 'An AI review is already running for this directory.'
|
|
310
|
+
: 'A scan is already running for this directory.';
|
|
311
|
+
ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error } });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const scanStartedAt = Date.now();
|
|
316
|
+
// Pre-compute a size + ETA so the very first progress event carries an
|
|
317
|
+
// estimate. We deliberately do this *before* `setActiveOperation` so a
|
|
318
|
+
// freshly-clicked scan shows numbers immediately rather than after a
|
|
319
|
+
// file-collection round-trip.
|
|
320
|
+
let etaMs: number | undefined;
|
|
321
|
+
try {
|
|
322
|
+
const size = await estimateCodebaseSize(dirPath);
|
|
323
|
+
etaMs = estimateScanMs(size, persistence.loadHistory(), reportPath);
|
|
324
|
+
} catch {
|
|
325
|
+
// Falling back to no ETA is fine — the UI will simply hide the remaining-time chip.
|
|
326
|
+
}
|
|
327
|
+
|
|
233
328
|
try {
|
|
234
329
|
persistence.setActiveOperation(reportPath, 'scanning');
|
|
235
330
|
|
|
331
|
+
// Emit a "Detecting tools" frame *before* the long detect call so the
|
|
332
|
+
// user sees motion immediately on click — `detectTools` spawns one
|
|
333
|
+
// child process per ecosystem tool and can sit silent for several
|
|
334
|
+
// seconds on a cold cache.
|
|
335
|
+
ctx.send(ws, {
|
|
336
|
+
type: 'qualityScanProgress',
|
|
337
|
+
data: {
|
|
338
|
+
path: reportPath,
|
|
339
|
+
progress: { step: 'Detecting tools', current: 0, total: 8, etaMs, startedAt: scanStartedAt },
|
|
340
|
+
},
|
|
341
|
+
});
|
|
236
342
|
const { tools: detectedTools } = await detectTools(dirPath);
|
|
237
343
|
const installedToolNames = detectedTools.filter((t) => t.installed).map((t) => t.name);
|
|
238
344
|
|
|
239
|
-
|
|
345
|
+
// Heartbeat — keeps the progress UI showing elapsed time during long
|
|
346
|
+
// sub-steps (lint/format/build) where `runQualityScan` doesn't get a
|
|
347
|
+
// chance to call the progress callback again.
|
|
348
|
+
let lastProgress: { step: string; current: number; total: number } = {
|
|
349
|
+
step: 'Detecting tools',
|
|
350
|
+
current: 0,
|
|
351
|
+
total: 8,
|
|
352
|
+
};
|
|
353
|
+
const heartbeat = setInterval(() => {
|
|
354
|
+
const elapsedSec = Math.round((Date.now() - scanStartedAt) / 1000);
|
|
240
355
|
ctx.send(ws, {
|
|
241
356
|
type: 'qualityScanProgress',
|
|
242
|
-
data: {
|
|
357
|
+
data: {
|
|
358
|
+
path: reportPath,
|
|
359
|
+
progress: { ...lastProgress, etaMs, startedAt: scanStartedAt, detail: `${elapsedSec}s elapsed` },
|
|
360
|
+
},
|
|
243
361
|
});
|
|
244
|
-
},
|
|
362
|
+
}, 5_000);
|
|
363
|
+
|
|
364
|
+
let results: Awaited<ReturnType<typeof runQualityScan>>;
|
|
365
|
+
try {
|
|
366
|
+
results = await runQualityScan(dirPath, (progress) => {
|
|
367
|
+
lastProgress = progress;
|
|
368
|
+
ctx.send(ws, {
|
|
369
|
+
type: 'qualityScanProgress',
|
|
370
|
+
data: {
|
|
371
|
+
path: reportPath,
|
|
372
|
+
progress: { ...progress, etaMs, startedAt: scanStartedAt },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}, installedToolNames, controller.signal);
|
|
376
|
+
} finally {
|
|
377
|
+
clearInterval(heartbeat);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Annotate the report with the wall-clock duration so subsequent scans
|
|
381
|
+
// of this directory have real history to base their ETA on. Same pattern
|
|
382
|
+
// applies for the AI review duration written by the review agent.
|
|
383
|
+
const scanDurationMs = Date.now() - scanStartedAt;
|
|
384
|
+
results.scanDurationMs = scanDurationMs;
|
|
245
385
|
|
|
246
386
|
// Persist before sending — results survive if WebSocket drops
|
|
247
387
|
try {
|
|
@@ -266,11 +406,17 @@ async function handleScan(
|
|
|
266
406
|
});
|
|
267
407
|
}
|
|
268
408
|
} catch (error) {
|
|
409
|
+
if (error instanceof QualityScanAbortedError) {
|
|
410
|
+
// Cancellation already broadcast a `qualityError` from `handleCancel`.
|
|
411
|
+
// Don't send a second error message.
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
269
414
|
ctx.send(ws, {
|
|
270
415
|
type: 'qualityError',
|
|
271
416
|
data: { path: reportPath, error: error instanceof Error ? error.message : String(error) },
|
|
272
417
|
});
|
|
273
418
|
} finally {
|
|
419
|
+
operationRegistry.finish(workingDir, reportPath);
|
|
274
420
|
persistence.clearActiveOperation(reportPath);
|
|
275
421
|
}
|
|
276
422
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quality Operation Registry — in-process tracking of in-flight scans and
|
|
5
|
+
* code reviews so they can be cancelled (or detected as orphaned) without
|
|
6
|
+
* depending on durable state.
|
|
7
|
+
*
|
|
8
|
+
* Persistence (`.mstro/quality/active-ops.json`) survives a CLI restart but
|
|
9
|
+
* has no live `AbortController` to cancel; this registry holds the controllers
|
|
10
|
+
* for the current process. The two layers are deliberately decoupled — when a
|
|
11
|
+
* persisted op exists with no registry entry, callers know the op is orphaned.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type OperationKind = 'scanning' | 'reviewing';
|
|
15
|
+
|
|
16
|
+
interface RegisteredOperation {
|
|
17
|
+
controller: AbortController;
|
|
18
|
+
kind: OperationKind;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class OperationRegistry {
|
|
23
|
+
private readonly ops = new Map<string, RegisteredOperation>();
|
|
24
|
+
|
|
25
|
+
private key(workingDir: string, path: string): string {
|
|
26
|
+
return `${workingDir}::${path}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reserve an operation slot and return its `AbortController`. Throws when
|
|
31
|
+
* the same `(workingDir, path)` pair is already in flight — callers should
|
|
32
|
+
* surface this to the user as "already running" rather than silently
|
|
33
|
+
* starting a second worker.
|
|
34
|
+
*/
|
|
35
|
+
start(workingDir: string, path: string, kind: OperationKind): AbortController {
|
|
36
|
+
const k = this.key(workingDir, path);
|
|
37
|
+
if (this.ops.has(k)) {
|
|
38
|
+
throw new Error(`Operation already in flight: ${kind} ${path}`);
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
this.ops.set(k, { controller, kind, startedAt: Date.now() });
|
|
42
|
+
return controller;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cancel an in-flight operation. Returns `true` when an op was found and
|
|
47
|
+
* aborted, `false` when nothing was registered (the caller should still
|
|
48
|
+
* clear persistence — the op is either already finished or was orphaned).
|
|
49
|
+
*/
|
|
50
|
+
cancel(workingDir: string, path: string): boolean {
|
|
51
|
+
const k = this.key(workingDir, path);
|
|
52
|
+
const op = this.ops.get(k);
|
|
53
|
+
if (!op) return false;
|
|
54
|
+
op.controller.abort();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
finish(workingDir: string, path: string): void {
|
|
59
|
+
this.ops.delete(this.key(workingDir, path));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
has(workingDir: string, path: string): boolean {
|
|
63
|
+
return this.ops.has(this.key(workingDir, path));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Kind of the live operation, or null when nothing is registered. */
|
|
67
|
+
getKind(workingDir: string, path: string): OperationKind | null {
|
|
68
|
+
return this.ops.get(this.key(workingDir, path))?.kind ?? null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const operationRegistry = new OperationRegistry();
|
|
@@ -52,6 +52,15 @@ export interface QualityHistoryEntry {
|
|
|
52
52
|
maintainability: { score: number; grade: string };
|
|
53
53
|
};
|
|
54
54
|
directories: HistoryDirectoryEntry[];
|
|
55
|
+
/**
|
|
56
|
+
* Wall-clock duration of the CLI scan that produced this entry, in
|
|
57
|
+
* milliseconds. Older entries persisted before the ETA rollout will not
|
|
58
|
+
* include this field, in which case the ETA estimator falls back to the
|
|
59
|
+
* heuristic until enough samples accumulate.
|
|
60
|
+
*/
|
|
61
|
+
scanDurationMs?: number;
|
|
62
|
+
/** Wall-clock duration of the AI review pass, when one ran for this entry. */
|
|
63
|
+
reviewDurationMs?: number;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
interface QualityHistory {
|
|
@@ -257,6 +266,12 @@ export class QualityPersistence {
|
|
|
257
266
|
if (categoryScores) lastEntry.categoryScores = categoryScores;
|
|
258
267
|
if (issueDensity !== undefined) lastEntry.issueDensity = issueDensity;
|
|
259
268
|
if (dimensionScores) lastEntry.dimensionScores = dimensionScores;
|
|
269
|
+
// Carry through whichever duration is present on this report. We
|
|
270
|
+
// overwrite — the latest scan/review is the most accurate sample for
|
|
271
|
+
// this directory. (Multi-dir merges within the 60s window write each
|
|
272
|
+
// dir's duration in turn; the final entry reflects the last write.)
|
|
273
|
+
if (typeof results.scanDurationMs === 'number') lastEntry.scanDurationMs = results.scanDurationMs;
|
|
274
|
+
if (typeof results.reviewDurationMs === 'number') lastEntry.reviewDurationMs = results.reviewDurationMs;
|
|
260
275
|
} else {
|
|
261
276
|
history.push({
|
|
262
277
|
timestamp: now.toISOString(),
|
|
@@ -266,6 +281,8 @@ export class QualityPersistence {
|
|
|
266
281
|
categoryScores,
|
|
267
282
|
dimensionScores,
|
|
268
283
|
directories: [dirEntry],
|
|
284
|
+
scanDurationMs: results.scanDurationMs,
|
|
285
|
+
reviewDurationMs: results.reviewDurationMs,
|
|
269
286
|
});
|
|
270
287
|
}
|
|
271
288
|
|
|
@@ -12,6 +12,7 @@ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
|
|
|
12
12
|
import type { ToolUseEvent } from '../../cli/headless/types.js';
|
|
13
13
|
import { loadSkillPrompt } from '../plan/agent-loader.js';
|
|
14
14
|
import type { HandlerContext } from './handler-context.js';
|
|
15
|
+
import { estimateCodebaseSize, estimateReviewMs } from './quality-eta.js';
|
|
15
16
|
import type { QualityPersistence } from './quality-persistence.js';
|
|
16
17
|
import { recomputeWithAiReview } from './quality-service.js';
|
|
17
18
|
import type { WSContext } from './types.js';
|
|
@@ -368,10 +369,37 @@ function createCodeReviewProgressTracker() {
|
|
|
368
369
|
|
|
369
370
|
type ProgressSender = (message: string) => void;
|
|
370
371
|
|
|
371
|
-
|
|
372
|
+
interface ProgressMeta {
|
|
373
|
+
/** Total estimated wall-clock duration for the review, in ms. */
|
|
374
|
+
etaMs?: number;
|
|
375
|
+
/** Server-side timestamp of when the review started, ms since epoch. */
|
|
376
|
+
startedAt?: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build a progress sender that reads its meta lazily — `etaMs` lands a few
|
|
381
|
+
* hundred ms after the review starts (we do file collection in parallel),
|
|
382
|
+
* and we want the first "Starting AI code review..." event to fire
|
|
383
|
+
* immediately rather than waiting on it. So progress events fire with
|
|
384
|
+
* whatever meta is set at *send* time, not at *closure* time.
|
|
385
|
+
*/
|
|
386
|
+
function makeProgressSender(
|
|
387
|
+
ctx: HandlerContext,
|
|
388
|
+
ws: WSContext,
|
|
389
|
+
reportPath: string,
|
|
390
|
+
metaRef: { current: ProgressMeta },
|
|
391
|
+
): ProgressSender {
|
|
372
392
|
return (message: string) => {
|
|
373
393
|
try {
|
|
374
|
-
ctx.send(ws, {
|
|
394
|
+
ctx.send(ws, {
|
|
395
|
+
type: 'qualityCodeReviewProgress',
|
|
396
|
+
data: {
|
|
397
|
+
path: reportPath,
|
|
398
|
+
message,
|
|
399
|
+
etaMs: metaRef.current.etaMs,
|
|
400
|
+
startedAt: metaRef.current.startedAt,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
375
403
|
} catch {
|
|
376
404
|
// WebSocket closed — progress lost but operation continues
|
|
377
405
|
}
|
|
@@ -416,6 +444,7 @@ async function runVerificationPass(
|
|
|
416
444
|
dirPath: string,
|
|
417
445
|
findings: CodeReviewFinding[],
|
|
418
446
|
send: ProgressSender,
|
|
447
|
+
signal?: AbortSignal,
|
|
419
448
|
): Promise<CodeReviewFinding[]> {
|
|
420
449
|
send(`Verifying ${findings.length} findings against actual code...`);
|
|
421
450
|
const stopHeartbeat = startHeartbeat(send, 'Verification');
|
|
@@ -430,9 +459,11 @@ async function runVerificationPass(
|
|
|
430
459
|
stallHardCapMs: 3_600_000,
|
|
431
460
|
toolUseCallback: makeToolCallback(send, 'Verifying: '),
|
|
432
461
|
logLabel: 'code-review-verify',
|
|
462
|
+
abortSignal: signal,
|
|
433
463
|
});
|
|
434
464
|
|
|
435
465
|
const verifyResult = await verificationRunner.run();
|
|
466
|
+
if (signal?.aborted) return findings;
|
|
436
467
|
const verdicts = parseVerificationResponse(verifyResult.assistantResponse || '');
|
|
437
468
|
|
|
438
469
|
if (verdicts.length === 0) return findings;
|
|
@@ -452,6 +483,7 @@ function persistReviewResults(
|
|
|
452
483
|
reportPath: string,
|
|
453
484
|
getPersistence: (dir: string) => QualityPersistence,
|
|
454
485
|
workingDir: string,
|
|
486
|
+
reviewDurationMs: number,
|
|
455
487
|
): import('./quality-service.js').QualityResults | null {
|
|
456
488
|
const persistence = getPersistence(workingDir);
|
|
457
489
|
const existingReport = persistence.loadReport(reportPath);
|
|
@@ -465,7 +497,7 @@ function persistReviewResults(
|
|
|
465
497
|
|
|
466
498
|
let updatedResults: import('./quality-service.js').QualityResults;
|
|
467
499
|
updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
|
|
468
|
-
updatedResults = { ...updatedResults, codeReview: findings };
|
|
500
|
+
updatedResults = { ...updatedResults, codeReview: findings, reviewDurationMs };
|
|
469
501
|
|
|
470
502
|
persistence.saveReport(reportPath, updatedResults);
|
|
471
503
|
persistence.appendHistory(updatedResults, reportPath);
|
|
@@ -475,29 +507,14 @@ function persistReviewResults(
|
|
|
475
507
|
|
|
476
508
|
// ── Handler ───────────────────────────────────────────────────
|
|
477
509
|
|
|
478
|
-
|
|
479
|
-
ctx: HandlerContext,
|
|
480
|
-
ws: WSContext,
|
|
481
|
-
reportPath: string,
|
|
510
|
+
async function runInitialReview(
|
|
482
511
|
dirPath: string,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
): Promise<
|
|
487
|
-
|
|
488
|
-
ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: 'A code review is already running for this directory.' } });
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
activeReviews.add(dirPath);
|
|
493
|
-
const send = makeProgressSender(ctx, ws, reportPath);
|
|
494
|
-
|
|
512
|
+
cliFindings: ReturnType<typeof loadCliFindings>,
|
|
513
|
+
send: ProgressSender,
|
|
514
|
+
signal: AbortSignal,
|
|
515
|
+
): Promise<CodeReviewResult> {
|
|
516
|
+
const stopReviewHeartbeat = startHeartbeat(send, 'AI code review');
|
|
495
517
|
try {
|
|
496
|
-
send('Starting AI code review...');
|
|
497
|
-
const cliFindings = loadCliFindings(getPersistence, workingDir, reportPath);
|
|
498
|
-
|
|
499
|
-
// ── Pass 1: Initial AI code review ──────────────────────
|
|
500
|
-
const stopReviewHeartbeat = startHeartbeat(send, 'AI code review');
|
|
501
518
|
const runner = new ResilientRunner({
|
|
502
519
|
workingDir: dirPath,
|
|
503
520
|
prompt: buildCodeReviewPrompt(dirPath, cliFindings),
|
|
@@ -507,61 +524,134 @@ export async function handleCodeReview(
|
|
|
507
524
|
stallHardCapMs: 7_200_000,
|
|
508
525
|
toolUseCallback: makeToolCallback(send),
|
|
509
526
|
logLabel: 'code-review',
|
|
527
|
+
abortSignal: signal,
|
|
510
528
|
});
|
|
511
|
-
|
|
512
529
|
send('Claude is analyzing your codebase...');
|
|
513
530
|
const result = await runner.run();
|
|
531
|
+
return parseCodeReviewResponse(result.assistantResponse || '');
|
|
532
|
+
} finally {
|
|
514
533
|
stopReviewHeartbeat();
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// ── Phase 3: Deterministic post-validation ──────────────
|
|
518
|
-
send(`Validating ${reviewResult.findings.length} findings against codebase...`);
|
|
519
|
-
const validation = validateFindings(reviewResult.findings, dirPath);
|
|
520
|
-
if (validation.stats.failed > 0) {
|
|
521
|
-
send(`Filtered ${validation.stats.failed} finding(s) with invalid references`);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// ── Phase 2: LLM verification pass ──────────────────────
|
|
525
|
-
let finalFindings = validation.validated;
|
|
526
|
-
if (finalFindings.length > 0) {
|
|
527
|
-
try {
|
|
528
|
-
finalFindings = await runVerificationPass(dirPath, finalFindings, send);
|
|
529
|
-
} catch {
|
|
530
|
-
send('Verification pass skipped (timeout or error)');
|
|
531
|
-
}
|
|
532
|
-
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
533
536
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
+
async function refineFindings(
|
|
538
|
+
reviewResult: CodeReviewResult,
|
|
539
|
+
dirPath: string,
|
|
540
|
+
send: ProgressSender,
|
|
541
|
+
signal: AbortSignal,
|
|
542
|
+
): Promise<CodeReviewFinding[]> {
|
|
543
|
+
send(`Validating ${reviewResult.findings.length} findings against codebase...`);
|
|
544
|
+
const validation = validateFindings(reviewResult.findings, dirPath);
|
|
545
|
+
if (validation.stats.failed > 0) {
|
|
546
|
+
send(`Filtered ${validation.stats.failed} finding(s) with invalid references`);
|
|
547
|
+
}
|
|
537
548
|
|
|
538
|
-
|
|
549
|
+
let finalFindings = validation.validated;
|
|
550
|
+
if (finalFindings.length > 0 && !signal.aborted) {
|
|
539
551
|
try {
|
|
540
|
-
|
|
552
|
+
finalFindings = await runVerificationPass(dirPath, finalFindings, send, signal);
|
|
541
553
|
} catch {
|
|
542
|
-
|
|
554
|
+
send('Verification pass skipped (timeout or error)');
|
|
543
555
|
}
|
|
556
|
+
}
|
|
557
|
+
return finalFindings;
|
|
558
|
+
}
|
|
544
559
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
560
|
+
function emitReviewResult(
|
|
561
|
+
ctx: HandlerContext,
|
|
562
|
+
ws: WSContext,
|
|
563
|
+
reportPath: string,
|
|
564
|
+
workingDir: string,
|
|
565
|
+
verifiedReviewResult: CodeReviewResult,
|
|
566
|
+
getPersistence: (dir: string) => QualityPersistence,
|
|
567
|
+
reviewDurationMs: number,
|
|
568
|
+
): void {
|
|
569
|
+
let updatedResults: import('./quality-service.js').QualityResults | null = null;
|
|
570
|
+
try {
|
|
571
|
+
updatedResults = persistReviewResults(verifiedReviewResult, reportPath, getPersistence, workingDir, reviewDurationMs);
|
|
572
|
+
} catch {
|
|
573
|
+
// Persistence failure should not break the review flow
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const resultData = { path: reportPath, findings: verifiedReviewResult.findings, summary: verifiedReviewResult.summary, results: updatedResults };
|
|
577
|
+
try {
|
|
578
|
+
ctx.send(ws, { type: 'qualityCodeReview', data: resultData });
|
|
579
|
+
} catch {
|
|
580
|
+
// WebSocket closed — save as pending for delivery on reconnect
|
|
581
|
+
getPersistence(workingDir).addPendingResult({
|
|
582
|
+
type: 'codeReview',
|
|
583
|
+
path: reportPath,
|
|
584
|
+
data: resultData as unknown as Record<string, unknown>,
|
|
585
|
+
completedAt: new Date().toISOString(),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function computeReviewEta(
|
|
591
|
+
dirPath: string,
|
|
592
|
+
getPersistence: (dir: string) => QualityPersistence,
|
|
593
|
+
workingDir: string,
|
|
594
|
+
reportPath: string,
|
|
595
|
+
): Promise<number | undefined> {
|
|
596
|
+
try {
|
|
597
|
+
const size = await estimateCodebaseSize(dirPath);
|
|
598
|
+
return estimateReviewMs(size, getPersistence(workingDir).loadHistory(), reportPath);
|
|
599
|
+
} catch {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export async function handleCodeReview(
|
|
605
|
+
ctx: HandlerContext,
|
|
606
|
+
ws: WSContext,
|
|
607
|
+
reportPath: string,
|
|
608
|
+
dirPath: string,
|
|
609
|
+
workingDir: string,
|
|
610
|
+
getPersistence: (dir: string) => QualityPersistence,
|
|
611
|
+
signal: AbortSignal,
|
|
612
|
+
): Promise<void> {
|
|
613
|
+
const startedAt = Date.now();
|
|
614
|
+
// Mutable meta so progress messages can fire immediately, even before the
|
|
615
|
+
// ETA is computed in the background. This is what makes "AI is reviewing"
|
|
616
|
+
// appear instantly when scan completes — we don't want to wait on a
|
|
617
|
+
// file-collection round-trip before the first progress event lands.
|
|
618
|
+
const metaRef: { current: ProgressMeta } = { current: { startedAt } };
|
|
619
|
+
const send = makeProgressSender(ctx, ws, reportPath, metaRef);
|
|
620
|
+
|
|
621
|
+
// Kick the ETA computation off in parallel — it'll patch metaRef when it
|
|
622
|
+
// finishes, and any later progress event will pick up the value.
|
|
623
|
+
computeReviewEta(dirPath, getPersistence, workingDir, reportPath)
|
|
624
|
+
.then((etaMs) => { if (etaMs !== undefined) metaRef.current = { ...metaRef.current, etaMs }; })
|
|
625
|
+
.catch(() => { /* leave etaMs unset — UI just hides the chip */ });
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
send('Starting AI code review...');
|
|
629
|
+
const cliFindings = loadCliFindings(getPersistence, workingDir, reportPath);
|
|
630
|
+
|
|
631
|
+
const reviewResult = await runInitialReview(dirPath, cliFindings, send, signal);
|
|
632
|
+
if (signal.aborted) return;
|
|
633
|
+
|
|
634
|
+
const finalFindings = await refineFindings(reviewResult, dirPath, send, signal);
|
|
635
|
+
if (signal.aborted) return;
|
|
636
|
+
|
|
637
|
+
send('Generating review report...');
|
|
638
|
+
emitReviewResult(
|
|
639
|
+
ctx,
|
|
640
|
+
ws,
|
|
641
|
+
reportPath,
|
|
642
|
+
workingDir,
|
|
643
|
+
{ ...reviewResult, findings: finalFindings },
|
|
644
|
+
getPersistence,
|
|
645
|
+
Date.now() - startedAt,
|
|
646
|
+
);
|
|
558
647
|
} catch (error) {
|
|
648
|
+
// Suppress error emission for cancellations — `handleCancel` already
|
|
649
|
+
// broadcast a `qualityError` with the user-facing reason.
|
|
650
|
+
if (signal.aborted) return;
|
|
559
651
|
try {
|
|
560
652
|
ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: error instanceof Error ? error.message : String(error) } });
|
|
561
653
|
} catch {
|
|
562
654
|
// WebSocket closed — error lost but operation tracked via activeOps
|
|
563
655
|
}
|
|
564
|
-
} finally {
|
|
565
|
-
activeReviews.delete(dirPath);
|
|
566
656
|
}
|
|
567
657
|
}
|