@debugg-ai/debugg-ai-mcp 3.7.3 → 3.8.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/dist/handlers/probePageHandler.js +10 -1
- package/dist/handlers/testPageChangesHandler.js +10 -3
- package/dist/handlers/triggerCrawlHandler.js +23 -1
- package/dist/services/ngrok/tunnelManager.js +36 -0
- package/dist/utils/gitContext.js +26 -0
- package/dist/utils/projectAnalyzer.js +23 -0
- package/package.json +1 -1
|
@@ -149,7 +149,16 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
149
149
|
},
|
|
150
150
|
};
|
|
151
151
|
if (tunneled.tunnelId) {
|
|
152
|
-
|
|
152
|
+
const deadPort = extractLocalhostPort(tunneled.originalUrl);
|
|
153
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
154
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
|
|
155
|
+
// so the next call re-provisions instead of re-borrowing the corpse
|
|
156
|
+
// (bead k34o) — plain stopTunnel leaves a borrowed entry to re-poison.
|
|
157
|
+
tunnelManager.markTunnelDead(deadPort, tunneled.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunneled.tunnelId}: ${err}`));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
|
|
161
|
+
}
|
|
153
162
|
}
|
|
154
163
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
155
164
|
}
|
|
@@ -256,10 +256,17 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
256
256
|
};
|
|
257
257
|
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
258
258
|
// Tear down the broken tunnel so a subsequent call doesn't reuse it.
|
|
259
|
-
// stopTunnel handles both owned (ngrok disconnect + key revoke) and
|
|
260
|
-
// borrowed (just drops local ref) cases.
|
|
261
259
|
if (ctx.tunnelId) {
|
|
262
|
-
|
|
260
|
+
const deadPort = extractLocalhostPort(ctx.originalUrl);
|
|
261
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
262
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
|
|
263
|
+
// so the next call re-provisions instead of re-borrowing the corpse.
|
|
264
|
+
// Plain stopTunnel leaves a borrowed entry to re-poison (bead k34o).
|
|
265
|
+
tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
269
|
+
}
|
|
263
270
|
}
|
|
264
271
|
// keyId is consumed by stopTunnel's revoke path; clear so the
|
|
265
272
|
// outer finally block doesn't double-revoke.
|
|
@@ -20,6 +20,7 @@ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js
|
|
|
20
20
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
21
21
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
22
22
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
23
|
+
import { detectLocalGitRef } from '../utils/gitContext.js';
|
|
23
24
|
import { getCrawlTemplateSlug } from '../services/workflows.js';
|
|
24
25
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
25
26
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
@@ -138,7 +139,16 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
138
139
|
};
|
|
139
140
|
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
140
141
|
if (ctx.tunnelId) {
|
|
141
|
-
|
|
142
|
+
const deadPort = extractLocalhostPort(ctx.originalUrl);
|
|
143
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
144
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry so
|
|
145
|
+
// the next call re-provisions instead of re-borrowing the corpse — plain
|
|
146
|
+
// stopTunnel leaves a borrowed entry to re-poison (bead k34o).
|
|
147
|
+
tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
151
|
+
}
|
|
142
152
|
}
|
|
143
153
|
keyId = undefined;
|
|
144
154
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
@@ -170,6 +180,18 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
170
180
|
contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
|
|
171
181
|
if (typeof input.timeoutSeconds === 'number')
|
|
172
182
|
contextData.timeoutSeconds = input.timeoutSeconds;
|
|
183
|
+
// sentinal-lwtaw.13 (MCP side): the TRIGGER POINT owns the git fact. Attach
|
|
184
|
+
// the LOCAL checkout's branch + commit so the backend mints a git-backed
|
|
185
|
+
// Atlas version for this crawl — SAME snake_case contextData keys the
|
|
186
|
+
// PR-webhook path reads (commit_sha, branch). Best-effort + non-fatal:
|
|
187
|
+
// detectLocalGitRef never throws and a non-git target attaches nothing
|
|
188
|
+
// (honest no-git) and still crawls. Keys are set ONLY when present so we
|
|
189
|
+
// never fabricate a ref.
|
|
190
|
+
const gitRef = await detectLocalGitRef();
|
|
191
|
+
if (gitRef.commitSha)
|
|
192
|
+
contextData.commit_sha = gitRef.commitSha;
|
|
193
|
+
if (gitRef.branch)
|
|
194
|
+
contextData.branch = gitRef.branch;
|
|
173
195
|
const env = {};
|
|
174
196
|
if (input.environmentId)
|
|
175
197
|
env.environmentId = input.environmentId;
|
|
@@ -148,6 +148,42 @@ class TunnelManager {
|
|
|
148
148
|
}
|
|
149
149
|
return existing;
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) so no
|
|
153
|
+
* session borrows the corpse again (bead k34o).
|
|
154
|
+
*
|
|
155
|
+
* OWNED: delegate to stopTunnel — it already removes the registry entry,
|
|
156
|
+
* disconnects, revokes the key, and resets the agent. (Self-heals after one
|
|
157
|
+
* failure, which already worked.)
|
|
158
|
+
*
|
|
159
|
+
* BORROWED (the actual gap): stopTunnel only drops our local ref and leaves the
|
|
160
|
+
* SHARED registry entry, so every other session keeps re-borrowing the dead
|
|
161
|
+
* tunnel for up to the 30-min freshness TTL. Here we also evict the shared
|
|
162
|
+
* entry — guarded by tunnelId so a replacement another session just provisioned
|
|
163
|
+
* for the same port is never removed. Best-effort, never throws.
|
|
164
|
+
*/
|
|
165
|
+
async markTunnelDead(port, tunnelId) {
|
|
166
|
+
const local = this.activeTunnels.get(tunnelId);
|
|
167
|
+
if (local?.isOwned) {
|
|
168
|
+
await this.stopTunnel(tunnelId).catch(() => { });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Borrowed or no longer local — drop any local ref, then evict the shared entry.
|
|
172
|
+
if (local?.autoShutoffTimer)
|
|
173
|
+
clearTimeout(local.autoShutoffTimer);
|
|
174
|
+
this.activeTunnels.delete(tunnelId);
|
|
175
|
+
try {
|
|
176
|
+
const registry = this.reg.read();
|
|
177
|
+
if (registry[String(port)]?.tunnelId === tunnelId) {
|
|
178
|
+
delete registry[String(port)];
|
|
179
|
+
this.reg.write(registry);
|
|
180
|
+
logger.info(`Evicted dead borrowed tunnel ${tunnelId} for port ${port} from shared registry`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// best-effort — a failed eviction just means the next call re-probes and re-evicts
|
|
185
|
+
}
|
|
186
|
+
}
|
|
151
187
|
touchTunnel(tunnelId) {
|
|
152
188
|
const tunnelInfo = this.activeTunnels.get(tunnelId);
|
|
153
189
|
if (!tunnelInfo)
|
package/dist/utils/gitContext.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* Parses the origin remote URL into "owner/repo" format.
|
|
4
4
|
*/
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
+
import { ProjectAnalyzer } from './projectAnalyzer.js';
|
|
7
|
+
import { Logger } from './logger.js';
|
|
8
|
+
const logger = new Logger({ module: 'gitContext' });
|
|
6
9
|
let cached; // undefined = not yet checked
|
|
7
10
|
/**
|
|
8
11
|
* Detect the repo name (e.g. "debugg-ai/debugg-ai-frontend") from git remote origin.
|
|
@@ -25,6 +28,29 @@ export function detectRepoName() {
|
|
|
25
28
|
}
|
|
26
29
|
return cached;
|
|
27
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Detect the LOCAL git ref (branch + commit sha) from the current working
|
|
33
|
+
* directory — the repo whose dev server the caller is crawling.
|
|
34
|
+
*
|
|
35
|
+
* sentinal-lwtaw.13 (MCP side): the TRIGGER POINT owns the git fact. The
|
|
36
|
+
* environment says WHERE to crawl; the caller supplies the ref it's running so
|
|
37
|
+
* the backend can mint a git-backed Atlas version. Delegates to
|
|
38
|
+
* ProjectAnalyzer's existing `.git/HEAD` readers — no new git parsing.
|
|
39
|
+
*
|
|
40
|
+
* Best-effort by contract: returns `{}` when cwd isn't a git repo (or the read
|
|
41
|
+
* fails). NEVER throws and NEVER fabricates a branch/sha — a git-less target
|
|
42
|
+
* must still crawl (honest no-git). NOT cached (unlike detectRepoName): the
|
|
43
|
+
* branch/commit change under a long-lived MCP process, so each crawl re-reads.
|
|
44
|
+
*/
|
|
45
|
+
export async function detectLocalGitRef() {
|
|
46
|
+
try {
|
|
47
|
+
return await new ProjectAnalyzer().getGitRef(process.cwd());
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
logger.debug('Could not determine local git ref', err);
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
28
54
|
/**
|
|
29
55
|
* Parse an origin URL into "owner/repo" format.
|
|
30
56
|
* Handles SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git).
|
|
@@ -438,6 +438,29 @@ export class ProjectAnalyzer {
|
|
|
438
438
|
extractRepoName(projectPath) {
|
|
439
439
|
return projectPath.split('/').pop() || 'unknown';
|
|
440
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Best-effort local git ref (branch + full commit sha) for a project dir.
|
|
443
|
+
*
|
|
444
|
+
* Reuses the existing `.git/HEAD` readers (getCurrentBranch /
|
|
445
|
+
* getCurrentCommitHash) — no new git parsing. NEVER throws and NEVER
|
|
446
|
+
* fabricates: a non-git dir (or any read failure) yields `{}` so a git-less
|
|
447
|
+
* crawl target still crawls (honest no-git). Not cached — a long-lived MCP
|
|
448
|
+
* process must re-read after the caller checks out a branch or commits.
|
|
449
|
+
*/
|
|
450
|
+
async getGitRef(repoPath) {
|
|
451
|
+
const projectPath = repoPath || process.cwd();
|
|
452
|
+
try {
|
|
453
|
+
const [branch, commitSha] = await Promise.all([
|
|
454
|
+
this.getCurrentBranch(projectPath),
|
|
455
|
+
this.getCurrentCommitHash(projectPath),
|
|
456
|
+
]);
|
|
457
|
+
return { branch, commitSha };
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
logger.debug('Could not determine local git ref', error);
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
441
464
|
/**
|
|
442
465
|
* Get current git branch (simplified)
|
|
443
466
|
*/
|