@debugg-ai/debugg-ai-mcp 2.4.1 → 2.5.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/README.md +21 -1
- package/dist/handlers/index.js +1 -0
- package/dist/handlers/probePageHandler.js +275 -0
- package/dist/handlers/searchEnvironmentsHandler.js +12 -2
- package/dist/handlers/testPageChangesHandler.js +149 -70
- package/dist/handlers/triggerCrawlHandler.js +65 -21
- package/dist/services/ngrok/tunnelManager.js +46 -7
- package/dist/services/ngrok/tunnelRegistry.js +39 -5
- package/dist/services/ngrok/types.js +0 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/probePage.js +89 -0
- package/dist/types/index.js +17 -0
- package/dist/utils/errors.js +0 -1
- package/dist/utils/harSummarizer.js +105 -0
- package/dist/utils/projectAnalyzer.js +2 -2
- package/dist/utils/telemetry.js +1 -0
- package/dist/utils/transientErrors.js +82 -0
- package/dist/utils/urlParser.js +1 -1
- package/dist/utils/validation.js +1 -1
- package/package.json +1 -1
|
@@ -20,8 +20,20 @@ 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 { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
24
|
+
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
23
25
|
const logger = new Logger({ module: 'triggerCrawlHandler' });
|
|
24
26
|
const TEMPLATE_KEYWORD = 'raw crawl';
|
|
27
|
+
// Bead kbo9: same env-driven retry budget as testPageChangesHandler (kbxy).
|
|
28
|
+
function getMaxTransientRetries() {
|
|
29
|
+
const raw = process.env.DEBUGGAI_TRANSIENT_RETRIES;
|
|
30
|
+
if (raw === undefined || raw === '')
|
|
31
|
+
return 1;
|
|
32
|
+
const n = parseInt(raw, 10);
|
|
33
|
+
if (!Number.isFinite(n) || n < 0)
|
|
34
|
+
return 1;
|
|
35
|
+
return Math.min(n, 3);
|
|
36
|
+
}
|
|
25
37
|
export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
26
38
|
const startTime = Date.now();
|
|
27
39
|
logger.toolStart('trigger_crawl', input);
|
|
@@ -151,32 +163,64 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
151
163
|
if (progressCallback) {
|
|
152
164
|
await progressCallback({ progress: 3, total: 4, message: 'Queuing crawl execution...' });
|
|
153
165
|
}
|
|
154
|
-
|
|
155
|
-
const executionUuid = executeResponse.executionUuid;
|
|
156
|
-
logger.info(`Crawl execution queued: ${executionUuid}`);
|
|
157
|
-
// --- Poll ---
|
|
158
|
-
// Bead 0bq: emit the final progress (4/4 "Complete:...") INSIDE onUpdate
|
|
159
|
-
// when terminal status detected, so there's no post-resolve emission that
|
|
160
|
-
// could race the response and cause stale-progressToken transport tear-down.
|
|
166
|
+
// --- Execute + Poll (with bounded retry on transient errors, bead kbo9) ---
|
|
161
167
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
const MAX_RETRIES = getMaxTransientRetries();
|
|
169
|
+
let executeResponse;
|
|
170
|
+
let executionUuid = '';
|
|
171
|
+
let finalExecution;
|
|
172
|
+
let attempt = 0;
|
|
173
|
+
while (true) {
|
|
174
|
+
attempt++;
|
|
175
|
+
if (attempt > 1) {
|
|
176
|
+
Telemetry.capture(TelemetryEvents.WORKFLOW_TRANSIENT_RETRY, {
|
|
177
|
+
tool: 'trigger_crawl',
|
|
178
|
+
attempt,
|
|
179
|
+
reason: transientReasonTag(finalExecution),
|
|
180
|
+
previousExecutionId: executionUuid,
|
|
181
|
+
previousErrorMessage: finalExecution?.errorMessage?.slice(0, 200),
|
|
182
|
+
previousStateError: finalExecution?.state?.error?.slice(0, 200),
|
|
183
|
+
});
|
|
184
|
+
if (progressCallback) {
|
|
185
|
+
await progressCallback({
|
|
186
|
+
progress: 3, total: 4,
|
|
187
|
+
message: `Transient backend error — retrying crawl (attempt ${attempt}/${MAX_RETRIES + 1})...`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt - 1)));
|
|
191
|
+
}
|
|
192
|
+
executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
|
|
193
|
+
executionUuid = executeResponse.executionUuid;
|
|
194
|
+
logger.info(`Crawl execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
|
|
195
|
+
// --- Poll ---
|
|
196
|
+
// Bead 0bq: emit the final progress (4/4 "Complete:...") INSIDE onUpdate
|
|
197
|
+
// when terminal status detected, so there's no post-resolve emission that
|
|
198
|
+
// could race the response and cause stale-progressToken transport tear-down.
|
|
199
|
+
finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
|
|
200
|
+
if (ctx.tunnelId)
|
|
201
|
+
touchTunnelById(ctx.tunnelId);
|
|
202
|
+
if (!progressCallback)
|
|
203
|
+
return;
|
|
204
|
+
const nodeCount = (exec.nodeExecutions ?? []).length;
|
|
205
|
+
if (TERMINAL_STATUSES.has(exec.status)) {
|
|
206
|
+
await progressCallback({
|
|
207
|
+
progress: 4, total: 4,
|
|
208
|
+
message: `Crawl ${exec.status} (${nodeCount} nodes)`,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
169
212
|
await progressCallback({
|
|
170
213
|
progress: 4, total: 4,
|
|
171
214
|
message: `Crawl ${exec.status} (${nodeCount} nodes)`,
|
|
172
215
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
})
|
|
179
|
-
|
|
216
|
+
}, abortController.signal);
|
|
217
|
+
if (attempt > MAX_RETRIES)
|
|
218
|
+
break;
|
|
219
|
+
if (!isTransientWorkflowError(finalExecution))
|
|
220
|
+
break;
|
|
221
|
+
logger.warn(`Transient backend error detected on crawl (${transientReasonTag(finalExecution) ?? 'unknown'}) — ` +
|
|
222
|
+
`retrying (attempt ${attempt + 1}/${MAX_RETRIES + 1})`);
|
|
223
|
+
}
|
|
180
224
|
const duration = Date.now() - startTime;
|
|
181
225
|
const nodes = finalExecution.nodeExecutions ?? [];
|
|
182
226
|
// --- Format response ---
|
|
@@ -49,6 +49,17 @@ class TunnelManager {
|
|
|
49
49
|
pendingTunnels = new Map();
|
|
50
50
|
initialized = false;
|
|
51
51
|
TUNNEL_TIMEOUT_MS = 55 * 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Bead `3th`: registry-entry freshness window. An entry not touched within
|
|
54
|
+
* this many ms is treated as stale even if its owner PID is alive — defends
|
|
55
|
+
* against PID-reuse (OS reassigns dead-owner's PID to a different process).
|
|
56
|
+
*/
|
|
57
|
+
REGISTRY_FRESHNESS_TTL_MS = 30 * 60 * 1000;
|
|
58
|
+
/**
|
|
59
|
+
* Bead `mdp`: prune-on-startup eviction window. Entries older than this OR
|
|
60
|
+
* with dead owner PID get swept out when TunnelManager initializes.
|
|
61
|
+
*/
|
|
62
|
+
REGISTRY_PRUNE_THRESHOLD_MS = 60 * 60 * 1000;
|
|
52
63
|
/**
|
|
53
64
|
* Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
|
|
54
65
|
* Exposed on the class so tests can override with short delays without
|
|
@@ -57,6 +68,26 @@ class TunnelManager {
|
|
|
57
68
|
connectBackoffMs = [500, 1500];
|
|
58
69
|
constructor(reg = getDefaultRegistry()) {
|
|
59
70
|
this.reg = reg;
|
|
71
|
+
// Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
|
|
72
|
+
// unboundedly across MCP processes that exited without stopAllTunnels
|
|
73
|
+
// (SIGKILL / crash). Best-effort — no-op registries don't actually prune.
|
|
74
|
+
try {
|
|
75
|
+
const result = this.reg.prune({ staleAfterMs: this.REGISTRY_PRUNE_THRESHOLD_MS });
|
|
76
|
+
if (result.pruned > 0) {
|
|
77
|
+
logger.info(`Pruned ${result.pruned} stale registry entries on startup (${result.remaining} remaining)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.warn(`Registry prune-on-startup failed (non-fatal): ${err}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bead `3th`: freshness check used at borrow sites. Returns true if the
|
|
86
|
+
* entry is BOTH owner-alive AND touched recently enough to trust.
|
|
87
|
+
*/
|
|
88
|
+
isEntryUsable(entry, nowMs = Date.now()) {
|
|
89
|
+
return (this.reg.isPidAlive(entry.ownerPid) &&
|
|
90
|
+
(nowMs - entry.lastAccessedAt) <= this.REGISTRY_FRESHNESS_TTL_MS);
|
|
60
91
|
}
|
|
61
92
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
62
93
|
async processUrl(url, authToken, specificTunnelId, keyId, revokeKey) {
|
|
@@ -82,11 +113,18 @@ class TunnelManager {
|
|
|
82
113
|
if (!existing)
|
|
83
114
|
return undefined;
|
|
84
115
|
if (!existing.isOwned) {
|
|
85
|
-
// Verify the owning process is still alive
|
|
116
|
+
// Verify the owning process is still alive AND the entry is fresh
|
|
117
|
+
// (lastAccessedAt within REGISTRY_FRESHNESS_TTL_MS — defends against
|
|
118
|
+
// PID-reuse per bead 3th).
|
|
86
119
|
const entry = this.reg.read()[String(port)];
|
|
87
|
-
if (!entry || !this.
|
|
120
|
+
if (!entry || !this.isEntryUsable(entry)) {
|
|
88
121
|
this.activeTunnels.delete(existing.tunnelId);
|
|
89
|
-
|
|
122
|
+
const reason = !entry
|
|
123
|
+
? 'no registry entry'
|
|
124
|
+
: !this.reg.isPidAlive(entry.ownerPid)
|
|
125
|
+
? `owner PID ${entry.ownerPid} dead`
|
|
126
|
+
: `entry stale (last accessed ${Math.round((Date.now() - entry.lastAccessedAt) / 1000)}s ago)`;
|
|
127
|
+
logger.info(`Evicted stale borrowed tunnel ${existing.tunnelId} (${reason})`);
|
|
90
128
|
return undefined;
|
|
91
129
|
}
|
|
92
130
|
}
|
|
@@ -223,10 +261,12 @@ class TunnelManager {
|
|
|
223
261
|
const info = await pending;
|
|
224
262
|
return { url: info.publicUrl, tunnelId: info.tunnelId, isLocalhost: true };
|
|
225
263
|
}
|
|
226
|
-
// 3. Check cross-process registry — another MCP instance may own a tunnel
|
|
264
|
+
// 3. Check cross-process registry — another MCP instance may own a tunnel.
|
|
265
|
+
// Borrow only if the entry is fresh (PID alive AND touched within
|
|
266
|
+
// REGISTRY_FRESHNESS_TTL_MS — defends against PID-reuse, bead 3th).
|
|
227
267
|
const registry = this.reg.read();
|
|
228
268
|
const regEntry = registry[String(port)];
|
|
229
|
-
if (regEntry && this.
|
|
269
|
+
if (regEntry && this.isEntryUsable(regEntry)) {
|
|
230
270
|
logger.info(`Borrowing tunnel from PID ${regEntry.ownerPid} for port ${port}: ${regEntry.publicUrl}`);
|
|
231
271
|
const now = Date.now();
|
|
232
272
|
const borrowed = {
|
|
@@ -293,7 +333,6 @@ class TunnelManager {
|
|
|
293
333
|
// (existing "agent died" recovery path)
|
|
294
334
|
// - Attempt 3: after 1500ms backoff, retry with the already-reset agent
|
|
295
335
|
// Auth-token errors short-circuit at any attempt — no point looping.
|
|
296
|
-
const self = this;
|
|
297
336
|
// Bead 42g: fault injection + trace. Only active when NODE_ENV !== 'production'
|
|
298
337
|
// AND DEBUGG_TUNNEL_FAULT_MODE env var is set. Zero overhead when disabled.
|
|
299
338
|
const faultMode = getFaultModeFromEnv();
|
|
@@ -302,7 +341,7 @@ class TunnelManager {
|
|
|
302
341
|
trace.emit('createTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
|
|
303
342
|
const connectWithRetry = async () => {
|
|
304
343
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
305
|
-
const BACKOFF_MS =
|
|
344
|
+
const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
|
|
306
345
|
const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
|
|
307
346
|
const connectOpts = {
|
|
308
347
|
proto: 'http',
|
|
@@ -14,7 +14,7 @@ import { join } from 'path';
|
|
|
14
14
|
// ── File-backed implementation (production) ───────────────────────────────────
|
|
15
15
|
const REGISTRY_FILE = join(tmpdir(), 'debugg-ai-tunnels.json');
|
|
16
16
|
export function createFileRegistry() {
|
|
17
|
-
|
|
17
|
+
const store = {
|
|
18
18
|
read() {
|
|
19
19
|
try {
|
|
20
20
|
if (!existsSync(REGISTRY_FILE))
|
|
@@ -38,22 +38,29 @@ export function createFileRegistry() {
|
|
|
38
38
|
isPidAlive(pid) {
|
|
39
39
|
return checkPid(pid);
|
|
40
40
|
},
|
|
41
|
+
prune(opts) {
|
|
42
|
+
return pruneRegistryData(store, opts);
|
|
43
|
+
},
|
|
41
44
|
};
|
|
45
|
+
return store;
|
|
42
46
|
}
|
|
43
47
|
// ── In-memory implementation (tests / injectable) ─────────────────────────────
|
|
44
48
|
export function createInMemoryRegistry(isPidAliveImpl) {
|
|
45
|
-
let
|
|
46
|
-
|
|
47
|
-
read: () => ({ ...
|
|
48
|
-
write: (
|
|
49
|
+
let data = {};
|
|
50
|
+
const store = {
|
|
51
|
+
read: () => ({ ...data }),
|
|
52
|
+
write: (next) => { data = { ...next }; },
|
|
49
53
|
isPidAlive: isPidAliveImpl ?? checkPid,
|
|
54
|
+
prune: (opts) => pruneRegistryData(store, opts),
|
|
50
55
|
};
|
|
56
|
+
return store;
|
|
51
57
|
}
|
|
52
58
|
// ── No-op implementation (tests that don't exercise registry) ─────────────────
|
|
53
59
|
export const noopRegistry = {
|
|
54
60
|
read: () => ({}),
|
|
55
61
|
write: () => { },
|
|
56
62
|
isPidAlive: () => false,
|
|
63
|
+
prune: () => ({ pruned: 0, remaining: 0 }),
|
|
57
64
|
};
|
|
58
65
|
// ── Default selection ─────────────────────────────────────────────────────────
|
|
59
66
|
/**
|
|
@@ -73,3 +80,30 @@ function checkPid(pid) {
|
|
|
73
80
|
return false;
|
|
74
81
|
}
|
|
75
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Shared prune logic — read, filter, write back. Used by both the file-backed
|
|
85
|
+
* and in-memory implementations so the eviction policy lives in one place.
|
|
86
|
+
*
|
|
87
|
+
* Eviction rule: drop entries where EITHER the owner PID is dead OR the entry
|
|
88
|
+
* hasn't been touched within `staleAfterMs`. The freshness check is what
|
|
89
|
+
* defends against PID-reuse (bead 3th).
|
|
90
|
+
*/
|
|
91
|
+
function pruneRegistryData(store, opts) {
|
|
92
|
+
const now = opts.nowMs ?? Date.now();
|
|
93
|
+
const data = store.read();
|
|
94
|
+
const next = {};
|
|
95
|
+
let pruned = 0;
|
|
96
|
+
for (const [port, entry] of Object.entries(data)) {
|
|
97
|
+
const aliveAndFresh = store.isPidAlive(entry.ownerPid) &&
|
|
98
|
+
(now - entry.lastAccessedAt) <= opts.staleAfterMs;
|
|
99
|
+
if (aliveAndFresh) {
|
|
100
|
+
next[port] = entry;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
pruned++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (pruned > 0)
|
|
107
|
+
store.write(next);
|
|
108
|
+
return { pruned, remaining: Object.keys(next).length };
|
|
109
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildTestPageChangesTool, buildValidatedTestPageChangesTool } from './testPageChanges.js';
|
|
2
2
|
import { buildTriggerCrawlTool, buildValidatedTriggerCrawlTool } from './triggerCrawl.js';
|
|
3
|
+
import { buildProbePageTool, buildValidatedProbePageTool } from './probePage.js';
|
|
3
4
|
import { buildSearchProjectsTool, buildValidatedSearchProjectsTool } from './searchProjects.js';
|
|
4
5
|
import { buildSearchEnvironmentsTool, buildValidatedSearchEnvironmentsTool } from './searchEnvironments.js';
|
|
5
6
|
import { buildSearchExecutionsTool, buildValidatedSearchExecutionsTool } from './searchExecutions.js';
|
|
@@ -19,6 +20,7 @@ export function initTools(ctx) {
|
|
|
19
20
|
const tools = [
|
|
20
21
|
buildTestPageChangesTool(ctx),
|
|
21
22
|
buildTriggerCrawlTool(ctx),
|
|
23
|
+
buildProbePageTool(),
|
|
22
24
|
buildSearchProjectsTool(),
|
|
23
25
|
buildSearchEnvironmentsTool(),
|
|
24
26
|
buildCreateEnvironmentTool(),
|
|
@@ -32,6 +34,7 @@ export function initTools(ctx) {
|
|
|
32
34
|
const validated = [
|
|
33
35
|
buildValidatedTestPageChangesTool(ctx),
|
|
34
36
|
buildValidatedTriggerCrawlTool(ctx),
|
|
37
|
+
buildValidatedProbePageTool(),
|
|
35
38
|
buildValidatedSearchProjectsTool(),
|
|
36
39
|
buildValidatedSearchEnvironmentsTool(),
|
|
37
40
|
buildValidatedCreateEnvironmentTool(),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe Page Tool Definition.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight no-LLM batch page probe — navigate + capture state for 1-20
|
|
5
|
+
* URLs in one backend execution. Returns screenshots, page metadata,
|
|
6
|
+
* structured console errors, and per-URL networkSummary (origin+pathname
|
|
7
|
+
* aggregation that surfaces refetch loops as a single entry).
|
|
8
|
+
*
|
|
9
|
+
* NOT an agent: no LLM in the critical path; no interaction (clicks/fills);
|
|
10
|
+
* no scenario verification. For those, use check_app_in_browser.
|
|
11
|
+
*/
|
|
12
|
+
import { ProbePageInputSchema } from '../types/index.js';
|
|
13
|
+
import { probePageHandler } from '../handlers/probePageHandler.js';
|
|
14
|
+
const DESCRIPTION = `Probe one or more URLs and return their rendered state — screenshot, page metadata (title/finalUrl/statusCode/loadTimeMs), structured console errors, and per-URL network summary (refetch loops collapse into one row by origin+pathname).
|
|
15
|
+
|
|
16
|
+
WHEN TO USE: "did I just break /settings?" / "smoke-test these 5 routes after my refactor" / "what's actually rendering at /dashboard?" — fast (<10s for 1 URL, <25s for 20), no LLM cost, no agent loop.
|
|
17
|
+
|
|
18
|
+
NOT FOR: scenario verification (sign in → click X → assert Y), interaction (clicks, form fills, scrolls), or anything requiring agent decisions. Use check_app_in_browser for those.
|
|
19
|
+
|
|
20
|
+
LOCALHOST SUPPORT: any localhost URL is auto-tunneled. Pre-flight TCP probe fails fast (<2s) if the dev server isn't listening.
|
|
21
|
+
|
|
22
|
+
BATCH MODE: pass up to 20 targets in one call to share browser session + tunnel — dramatically faster than firing parallel single-URL probes (one execution unit, not N). Per-URL waitForSelector / waitForLoadState / timeoutMs override defaults.
|
|
23
|
+
|
|
24
|
+
A single failed target's error appears in result.error without failing the whole batch — the other results stay valid.`;
|
|
25
|
+
const TARGET_PROPERTIES = {
|
|
26
|
+
url: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'URL to probe. Public URL or localhost URL (auto-tunneled).',
|
|
29
|
+
},
|
|
30
|
+
waitForSelector: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Optional CSS selector to wait for after navigation completes. Useful for SPAs that mount content asynchronously.',
|
|
33
|
+
},
|
|
34
|
+
waitForLoadState: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
enum: ['load', 'domcontentloaded', 'networkidle'],
|
|
37
|
+
description: "When to consider the page 'loaded' before capturing. Default 'load'. Use 'networkidle' for SPAs to wait until the bundle finishes rendering.",
|
|
38
|
+
},
|
|
39
|
+
timeoutMs: {
|
|
40
|
+
type: 'number',
|
|
41
|
+
description: 'Per-URL navigation timeout in milliseconds (1000-30000, default 10000).',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export function buildProbePageTool() {
|
|
45
|
+
return {
|
|
46
|
+
name: 'probe_page',
|
|
47
|
+
title: 'Probe Page',
|
|
48
|
+
description: DESCRIPTION,
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
targets: {
|
|
53
|
+
type: 'array',
|
|
54
|
+
minItems: 1,
|
|
55
|
+
maxItems: 20,
|
|
56
|
+
items: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: TARGET_PROPERTIES,
|
|
59
|
+
required: ['url'],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
description: '1-20 URLs to probe. Each entry can carry its own per-URL wait config.',
|
|
63
|
+
},
|
|
64
|
+
includeHtml: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
description: "If true, each result includes the page's outerHTML. Default false to keep response size sane.",
|
|
67
|
+
},
|
|
68
|
+
captureScreenshots: {
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
description: 'If true (default), one PNG screenshot is returned per target. Set false for very large batches or when only the structured data matters.',
|
|
71
|
+
},
|
|
72
|
+
repoName: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this to scope the probe to a different project context.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: ['targets'],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function buildValidatedProbePageTool() {
|
|
83
|
+
const tool = buildProbePageTool();
|
|
84
|
+
return {
|
|
85
|
+
...tool,
|
|
86
|
+
inputSchema: ProbePageInputSchema,
|
|
87
|
+
handler: probePageHandler,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/types/index.js
CHANGED
|
@@ -152,3 +152,20 @@ export var LogLevel;
|
|
|
152
152
|
LogLevel["INFO"] = "info";
|
|
153
153
|
LogLevel["DEBUG"] = "debug";
|
|
154
154
|
})(LogLevel || (LogLevel = {}));
|
|
155
|
+
// ── probe-page ────────────────────────────────────────────────────────────
|
|
156
|
+
// Lightweight no-LLM page-probe tool. Each target gets its own wait config;
|
|
157
|
+
// targets[] is the batch — one workflow execution covers up to 20 URLs sharing
|
|
158
|
+
// browser session + tunnel. Strict schema: forbidden agent fields like
|
|
159
|
+
// `description` and `credentialId` reject (zero-LLM contract).
|
|
160
|
+
export const ProbePageTargetSchema = z.object({
|
|
161
|
+
url: z.preprocess(normalizeUrl, z.string().url('Invalid URL. Pass a full URL like "http://localhost:3000" or "https://example.com". Localhost URLs are auto-tunneled to the remote browser.')),
|
|
162
|
+
waitForSelector: z.string().optional(),
|
|
163
|
+
waitForLoadState: z.enum(['load', 'domcontentloaded', 'networkidle']).default('load'),
|
|
164
|
+
timeoutMs: z.number().int().min(1000, 'timeoutMs minimum is 1000 (1s)').max(30000, 'timeoutMs maximum is 30000 (30s) — longer probes should use check_app_in_browser').default(10000),
|
|
165
|
+
}).strict();
|
|
166
|
+
export const ProbePageInputSchema = z.object({
|
|
167
|
+
targets: z.array(ProbePageTargetSchema).min(1, 'targets must have at least one URL').max(20, 'targets capped at 20 per call — split larger sweeps across multiple calls'),
|
|
168
|
+
includeHtml: z.boolean().default(false),
|
|
169
|
+
captureScreenshots: z.boolean().default(true),
|
|
170
|
+
repoName: z.string().optional(),
|
|
171
|
+
}).strict();
|
package/dist/utils/errors.js
CHANGED
|
@@ -83,7 +83,6 @@ export function handleConfigurationError(error) {
|
|
|
83
83
|
* Handle external service errors (e.g., API calls)
|
|
84
84
|
*/
|
|
85
85
|
export function handleExternalServiceError(error, serviceName, operation) {
|
|
86
|
-
const context = `${serviceName}${operation ? `:${operation}` : ''}`;
|
|
87
86
|
if (error instanceof Error) {
|
|
88
87
|
logger.error('External service error', {
|
|
89
88
|
serviceName,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* harSummarizer — pure HAR + console aggregation utilities.
|
|
3
|
+
*
|
|
4
|
+
* Aggregation key for networkSummary: `origin + pathname` (per system reqs).
|
|
5
|
+
* Refetch loops with varying query strings collapse into a single entry.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions — no I/O, no async — so they can be reused by the future
|
|
8
|
+
* `summarize_execution` tool.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Aggregate HAR `log.entries` into per-endpoint NetworkSummary[], sorted
|
|
12
|
+
* descending by request count (hottest endpoints first). Malformed entries
|
|
13
|
+
* (missing request.url or response.status) are skipped, not thrown.
|
|
14
|
+
*/
|
|
15
|
+
export function summarizeHar(harEntries) {
|
|
16
|
+
if (!Array.isArray(harEntries))
|
|
17
|
+
return [];
|
|
18
|
+
const buckets = new Map();
|
|
19
|
+
for (const entry of harEntries) {
|
|
20
|
+
try {
|
|
21
|
+
const reqUrl = entry?.request?.url;
|
|
22
|
+
const status = entry?.response?.status;
|
|
23
|
+
if (typeof reqUrl !== 'string' || typeof status !== 'number')
|
|
24
|
+
continue;
|
|
25
|
+
// Aggregation key: origin + pathname (refetch loops collapse).
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(reqUrl);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const key = `${parsed.origin}${parsed.pathname}`;
|
|
34
|
+
const bytesRaw = entry?.response?.content?.size;
|
|
35
|
+
const bytes = typeof bytesRaw === 'number' && bytesRaw >= 0 ? bytesRaw : 0;
|
|
36
|
+
const mime = entry?.response?.content?.mimeType;
|
|
37
|
+
const mimeStr = typeof mime === 'string' && mime ? mime : '';
|
|
38
|
+
const existing = buckets.get(key);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.count++;
|
|
41
|
+
const sk = String(status);
|
|
42
|
+
existing.statuses[sk] = (existing.statuses[sk] ?? 0) + 1;
|
|
43
|
+
existing.totalBytes += bytes;
|
|
44
|
+
if (mimeStr)
|
|
45
|
+
existing.mimeTypes.add(mimeStr);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
buckets.set(key, {
|
|
49
|
+
url: key,
|
|
50
|
+
count: 1,
|
|
51
|
+
statuses: { [String(status)]: 1 },
|
|
52
|
+
totalBytes: bytes,
|
|
53
|
+
mimeTypes: mimeStr ? new Set([mimeStr]) : new Set(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// malformed — skip
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...buckets.values()]
|
|
62
|
+
.map(({ mimeTypes, url, count, statuses, totalBytes }) => {
|
|
63
|
+
const out = { url, count, statuses, totalBytes };
|
|
64
|
+
// Only attach mimeType when homogeneous — mixed types omit the field.
|
|
65
|
+
if (mimeTypes.size === 1) {
|
|
66
|
+
out.mimeType = [...mimeTypes][0];
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
})
|
|
70
|
+
.sort((a, b) => b.count - a.count);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a console-log JSON array into ConsoleErrorEntry[].
|
|
74
|
+
* Maps backend's snake_case (`line_number`, `url`) to MCP's camelCase
|
|
75
|
+
* (`lineNumber`, `source`). Drops entries that aren't plain objects.
|
|
76
|
+
*/
|
|
77
|
+
export function summarizeConsole(consoleEntries) {
|
|
78
|
+
if (!Array.isArray(consoleEntries))
|
|
79
|
+
return [];
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const e of consoleEntries) {
|
|
82
|
+
if (typeof e !== 'object' || e === null)
|
|
83
|
+
continue;
|
|
84
|
+
const entry = {
|
|
85
|
+
level: typeof e.level === 'string' ? e.level : 'log',
|
|
86
|
+
text: typeof e.text === 'string' ? e.text : '',
|
|
87
|
+
};
|
|
88
|
+
// source: prefer `url` (backend convention), fall back to `source`
|
|
89
|
+
const sourceVal = typeof e.url === 'string' && e.url
|
|
90
|
+
? e.url
|
|
91
|
+
: (typeof e.source === 'string' && e.source ? e.source : undefined);
|
|
92
|
+
if (sourceVal)
|
|
93
|
+
entry.source = sourceVal;
|
|
94
|
+
// lineNumber: snake_case from backend → camelCase
|
|
95
|
+
const lineVal = typeof e.line_number === 'number'
|
|
96
|
+
? e.line_number
|
|
97
|
+
: (typeof e.lineNumber === 'number' ? e.lineNumber : undefined);
|
|
98
|
+
if (typeof lineVal === 'number')
|
|
99
|
+
entry.lineNumber = lineVal;
|
|
100
|
+
if (typeof e.timestamp === 'number')
|
|
101
|
+
entry.timestamp = e.timestamp;
|
|
102
|
+
out.push(entry);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -45,7 +45,7 @@ export class ProjectAnalyzer {
|
|
|
45
45
|
/**
|
|
46
46
|
* Analyze codebase for context extraction
|
|
47
47
|
*/
|
|
48
|
-
async analyzeCodebase(repoPath, repoName, branchName,
|
|
48
|
+
async analyzeCodebase(repoPath, repoName, branchName, _includeChanges = true) {
|
|
49
49
|
try {
|
|
50
50
|
logger.info('Starting codebase analysis', { repoPath, repoName, branchName });
|
|
51
51
|
const analysis = await this.analyzeProject(repoPath);
|
|
@@ -384,7 +384,7 @@ export class ProjectAnalyzer {
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
|
-
catch
|
|
387
|
+
catch {
|
|
388
388
|
// Skip directories we can't read
|
|
389
389
|
}
|
|
390
390
|
};
|
package/dist/utils/telemetry.js
CHANGED
|
@@ -52,6 +52,7 @@ export const TelemetryEvents = {
|
|
|
52
52
|
TOOL_EXECUTED: 'tool.executed',
|
|
53
53
|
TOOL_FAILED: 'tool.failed',
|
|
54
54
|
WORKFLOW_EXECUTED: 'workflow.executed',
|
|
55
|
+
WORKFLOW_TRANSIENT_RETRY: 'workflow.transient_retry',
|
|
55
56
|
TUNNEL_PROVISIONED: 'tunnel.provisioned',
|
|
56
57
|
TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
|
|
57
58
|
TUNNEL_STOPPED: 'tunnel.stopped',
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect well-known transient failure signatures in completed workflow
|
|
3
|
+
* executions. When `isTransientWorkflowError` returns true, the MCP handler
|
|
4
|
+
* auto-retries the workflow (cost: one extra quota unit) — saving the caller
|
|
5
|
+
* from the 'pure infrastructure noise' failure mode the original client
|
|
6
|
+
* called out in their feedback (Pydantic JSON parse errors, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Be CONSERVATIVE: only patterns documented as transient. False positives
|
|
9
|
+
* waste quota; false negatives leave existing behavior, which is fine — the
|
|
10
|
+
* caller still gets a clear error and can decide what to do.
|
|
11
|
+
*
|
|
12
|
+
* Bead `kbxy`. Patterns are extracted (not inlined) so they're easy to audit
|
|
13
|
+
* + extend as new transient signatures get observed in production.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Patterns that match transient backend failures worth retrying. Each entry
|
|
17
|
+
* is a regex tested against `errorMessage` AND `state.error`. Matching ANY
|
|
18
|
+
* pattern in EITHER field flags the execution as transient.
|
|
19
|
+
*
|
|
20
|
+
* To add a new pattern: confirm by sampling production telemetry that the
|
|
21
|
+
* signature recovers on retry (a one-shot reproduce-then-retry test is
|
|
22
|
+
* sufficient evidence). Document the source in the comment.
|
|
23
|
+
*/
|
|
24
|
+
const TRANSIENT_PATTERNS = [
|
|
25
|
+
// The original client complaint. Backend agent's brain.step occasionally
|
|
26
|
+
// returns malformed JSON for the structured output — Pydantic chokes on
|
|
27
|
+
// EOF / partial JSON. A fresh agent invocation reliably recovers.
|
|
28
|
+
{ pattern: /Invalid JSON.*EOF while parsing/i, reason: 'pydantic-eof' },
|
|
29
|
+
{ pattern: /Failed to parse AgentOutput/i, reason: 'agent-output-parse' },
|
|
30
|
+
// Backend-side infrastructure flakes (nginx 502 from upstream + timeouts).
|
|
31
|
+
// Both observed in production during 2026-04-26 + 2026-04-27 deploys —
|
|
32
|
+
// recovery on next request is the rule, not the exception.
|
|
33
|
+
{ pattern: /502 Bad Gateway/i, reason: 'nginx-502' },
|
|
34
|
+
{ pattern: /upstream connect timeout/i, reason: 'upstream-timeout' },
|
|
35
|
+
// Network-layer transient — TCP reset between MCP↔backend or backend↔model.
|
|
36
|
+
{ pattern: /ECONNRESET|connection reset by peer/i, reason: 'econnreset' },
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* @returns true if the execution's error fields contain a known transient
|
|
40
|
+
* signature, indicating a retry has a reasonable chance of succeeding.
|
|
41
|
+
*/
|
|
42
|
+
export function isTransientWorkflowError(execution) {
|
|
43
|
+
if (!execution)
|
|
44
|
+
return false;
|
|
45
|
+
const candidates = [];
|
|
46
|
+
if (typeof execution.errorMessage === 'string' && execution.errorMessage) {
|
|
47
|
+
candidates.push(execution.errorMessage);
|
|
48
|
+
}
|
|
49
|
+
if (typeof execution.state?.error === 'string' && execution.state.error) {
|
|
50
|
+
candidates.push(execution.state.error);
|
|
51
|
+
}
|
|
52
|
+
if (candidates.length === 0)
|
|
53
|
+
return false;
|
|
54
|
+
for (const text of candidates) {
|
|
55
|
+
for (const { pattern } of TRANSIENT_PATTERNS) {
|
|
56
|
+
if (pattern.test(text))
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* @returns the reason tag for the matched transient pattern (for telemetry),
|
|
64
|
+
* or undefined if no pattern matched. Useful when you want to attach a
|
|
65
|
+
* classifier to a `workflow.transient_retry` event.
|
|
66
|
+
*/
|
|
67
|
+
export function transientReasonTag(execution) {
|
|
68
|
+
if (!execution)
|
|
69
|
+
return undefined;
|
|
70
|
+
const fields = [];
|
|
71
|
+
if (typeof execution.errorMessage === 'string' && execution.errorMessage)
|
|
72
|
+
fields.push(execution.errorMessage);
|
|
73
|
+
if (typeof execution.state?.error === 'string' && execution.state.error)
|
|
74
|
+
fields.push(execution.state.error);
|
|
75
|
+
for (const text of fields) {
|
|
76
|
+
for (const { pattern, reason } of TRANSIENT_PATTERNS) {
|
|
77
|
+
if (pattern.test(text))
|
|
78
|
+
return reason;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
package/dist/utils/urlParser.js
CHANGED
package/dist/utils/validation.js
CHANGED
|
@@ -81,7 +81,7 @@ export function validatePort(port) {
|
|
|
81
81
|
try {
|
|
82
82
|
return commonSchemas.port.parse(port);
|
|
83
83
|
}
|
|
84
|
-
catch
|
|
84
|
+
catch {
|
|
85
85
|
throw new MCPError(MCPErrorCode.VALIDATION_ERROR, `Invalid port number: ${port}. Port must be between 1 and 65535.`, { port });
|
|
86
86
|
}
|
|
87
87
|
}
|