@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.
@@ -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
- const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
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 finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
163
- if (ctx.tunnelId)
164
- touchTunnelById(ctx.tunnelId);
165
- if (!progressCallback)
166
- return;
167
- const nodeCount = (exec.nodeExecutions ?? []).length;
168
- if (TERMINAL_STATUSES.has(exec.status)) {
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
- return;
174
- }
175
- await progressCallback({
176
- progress: 4, total: 4,
177
- message: `Crawl ${exec.status} (${nodeCount} nodes)`,
178
- });
179
- }, abortController.signal);
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.reg.isPidAlive(entry.ownerPid)) {
120
+ if (!entry || !this.isEntryUsable(entry)) {
88
121
  this.activeTunnels.delete(existing.tunnelId);
89
- logger.info(`Evicted stale borrowed tunnel ${existing.tunnelId} (owner PID ${entry?.ownerPid} dead)`);
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.reg.isPidAlive(regEntry.ownerPid)) {
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 = self.connectBackoffMs; // bead ixh: test-overridable
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
- return {
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 store = {};
46
- return {
47
- read: () => ({ ...store }),
48
- write: (data) => { store = { ...data }; },
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
+ }
@@ -1,2 +1 @@
1
1
  export {};
2
- /* eslint-enable */
@@ -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
+ }
@@ -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();
@@ -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, includeChanges = true) {
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 (error) {
387
+ catch {
388
388
  // Skip directories we can't read
389
389
  }
390
390
  };
@@ -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
+ }
@@ -54,7 +54,7 @@ export function parseUrl(urlString) {
54
54
  hash: url.hash
55
55
  };
56
56
  }
57
- catch (error) {
57
+ catch {
58
58
  throw new Error(`Invalid URL format: ${urlString}`);
59
59
  }
60
60
  }
@@ -81,7 +81,7 @@ export function validatePort(port) {
81
81
  try {
82
82
  return commonSchemas.port.parse(port);
83
83
  }
84
- catch (error) {
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
  }