@debugg-ai/debugg-ai-mcp 5.0.0 → 6.0.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.
@@ -17,7 +17,7 @@ import { randomUUID } from 'node:crypto';
17
17
  import { detectRepoName } from '../utils/gitContext.js';
18
18
  import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
19
19
  import { probeLocalPort, probeTunnelHealth, extractTunnelErrorCode } from '../utils/localReachability.js';
20
- import { extractLocalhostPort } from '../utils/urlParser.js';
20
+ import { extractLocalhostPort, rewriteLocalhostInText } from '../utils/urlParser.js';
21
21
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
22
22
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
23
23
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
@@ -211,7 +211,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
211
211
  if (!probe.reachable) {
212
212
  const payload = {
213
213
  error: 'LocalServerUnreachable',
214
- message: `No server listening on 127.0.0.1:${localPort}. Start your dev server on that port before running check_app_in_browser. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
214
+ message: `No server listening on 127.0.0.1:${localPort}.`,
215
215
  detail: {
216
216
  port: localPort,
217
217
  probeCode: probe.code,
@@ -244,10 +244,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
244
244
  catch (provisionError) {
245
245
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
246
246
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
247
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
248
- `The remote browser needs a secure tunnel to reach your local dev server. ` +
249
- `Make sure your dev server is running on the specified port and try again. ` +
250
- `(Detail: ${msg})${diag}`);
247
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}: ${msg}${diag}`);
251
248
  }
252
249
  provision = tunnel;
253
250
  try {
@@ -261,10 +258,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
261
258
  }
262
259
  catch (tunnelError) {
263
260
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
264
- throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. ` +
265
- `Could not establish a secure connection between the remote browser and your local port. ` +
266
- `Verify your dev server is running and the port is accessible. ` +
267
- `(Detail: ${msg})`);
261
+ throw new Error(`Tunnel creation failed for ${ctx.originalUrl}: ${msg}`);
268
262
  }
269
263
  logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
270
264
  }
@@ -296,7 +290,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
296
290
  if (!health.healthy) {
297
291
  const payload = {
298
292
  error: 'TunnelTrafficBlocked',
299
- message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
293
+ message: `Tunnel established; a request through it to 127.0.0.1:${extractLocalhostPort(ctx.originalUrl)} failed: ${health.detail ?? health.code}.`,
300
294
  detail: {
301
295
  code: health.code,
302
296
  status: health.status,
@@ -346,22 +340,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
346
340
  : Promise.resolve(undefined),
347
341
  ]);
348
342
  if (!templateUuid) {
349
- throw new Error('App Evaluation Workflow Template not found. ' +
350
- 'Ensure the template is seeded in the backend (GET /api/v1/workflows/?is_template=true).');
343
+ throw new Error(`App Evaluation workflow template not found (slug "${getEvalTemplateSlug()}").`);
351
344
  }
352
345
  // Fail fast + actionable when project_id can't be resolved (pinned backend
353
346
  // semantics: project_id is required). Surfacing "link this repo to a
354
347
  // project" now — before executeWorkflow — beats letting a backend workflow
355
348
  // node fail mid-run several minutes into the evaluation.
356
349
  if (!projectUuid) {
357
- const detail = repoName
358
- ? `Repo "${repoName}" isn't linked to a DebuggAI project.`
359
- : 'No git repository was detected to resolve a project from.';
360
350
  const payload = {
361
351
  error: 'ProjectRequired',
362
- message: `project_id is required but could not be resolved. ${detail} ` +
363
- 'Link this repo to a project at https://debugg.ai (Projects → connect your repository), then retry. ' +
364
- 'To evaluate a different repo than the current one, pass repoName.',
352
+ message: repoName
353
+ ? `No DebuggAI project found for repo "${repoName}".`
354
+ : 'No git repository detected and no repoName passed.',
365
355
  };
366
356
  logger.warn(`check_app_in_browser: ${payload.message}`);
367
357
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
@@ -369,7 +359,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
369
359
  // --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
370
360
  const contextData = {
371
361
  targetUrl: ctx.targetUrl ?? originalUrl,
372
- question: input.description,
362
+ // The goal is read by a browser in OUR cloud. A goal that still says
363
+ // "go to http://localhost:3017" sends it to its own loopback, where it
364
+ // gets connection refused and the run is recorded as the app failing —
365
+ // with the tunnel working the whole time. Rewrite it onto the same
366
+ // tunnel the targetUrl above uses.
367
+ question: rewriteLocalhostInText(input.description, ctx.targetUrl),
373
368
  };
374
369
  if (projectUuid) {
375
370
  contextData.projectId = projectUuid;
@@ -386,7 +381,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
386
381
  if (input.auth.precondition)
387
382
  auth.precondition = input.auth.precondition;
388
383
  // Bead go1m: entryUrl/deepUrl are URLs the run NAVIGATES, so they need the
389
- // same localhost→tunnel rewrite `url` gets (contextData.targetUrl above).
384
+ // same localhost→tunnel rewrite `url` and the goal text get
385
+ // (contextData.targetUrl and contextData.question above).
390
386
  // Forwarded verbatim they reached the remote browser as literal localhost
391
387
  // and it dialled its own loopback: offscope_host + ERR_CONNECTION_REFUSED.
392
388
  for (const field of ['entryUrl', 'deepUrl']) {
@@ -397,11 +393,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
397
393
  if (!rewrite.ok) {
398
394
  const payload = {
399
395
  error: 'AuthUrlPortMismatch',
400
- message: `auth.${field} points at localhost:${rewrite.port} but url points at ` +
401
- `localhost:${rewrite.primaryPort}. A single call tunnels exactly one local ` +
402
- `port, so the remote browser cannot reach both. Put the login page and the ` +
403
- `target page on the same port, or drop auth.${field} and pass username/password ` +
404
- `at the top level so the agent signs in on the page it already reached.`,
396
+ message: `auth.${field} points at localhost:${rewrite.port}; url points at ` +
397
+ `localhost:${rewrite.primaryPort}. One call tunnels one local port.`,
405
398
  detail: { field, authPort: rewrite.port, urlPort: rewrite.primaryPort, url: originalUrl },
406
399
  };
407
400
  logger.warn(`check_app_in_browser: ${payload.message}`);
@@ -546,11 +539,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
546
539
  // next line (line 183 in services/workflows.ts), so there's no
547
540
  // post-pollExecution progress emission that could race the response.
548
541
  if (TERMINAL_STATUSES.has(exec.status)) {
549
- const terminalOutcome = exec.state?.outcome ?? exec.status;
542
+ // Status only: state.outcome is an internal value and can be one the
543
+ // user-facing verdict retired ('inconclusive', 'unverified').
550
544
  await progressCallback({
551
545
  progress: TOTAL_STEPS,
552
546
  total: TOTAL_STEPS,
553
- message: `Complete: ${terminalOutcome}`,
547
+ message: `Complete: ${exec.status}`,
554
548
  });
555
549
  return;
556
550
  }
@@ -622,89 +616,32 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
622
616
  const duration = Date.now() - startTime;
623
617
  // --- Format result ---
624
618
  const nodes = finalExecution.nodeExecutions ?? [];
625
- // subworkflow.run is the current graph shape — carries outcome, actionHistory, screenshot
619
+ // subworkflow.run carries an inline base64 screenshot on some graph shapes;
620
+ // only used for the image block below.
626
621
  const subworkflowNode = nodes.find(n => n.nodeType === 'subworkflow.run');
627
- // surfer.execute_task and brain.step/brain.evaluate are older graph shapes
628
- const surferNode = nodes.find(n => n.nodeType === 'surfer.execute_task');
629
- // Action trace: brain.step nodes (old) → subworkflow.run actionHistory (new)
630
- const brainSteps = nodes
631
- .filter(n => n.nodeType === 'brain.step' && n.outputData)
632
- .sort((a, b) => a.executionOrder - b.executionOrder);
633
- const actionTrace = brainSteps.map((n, i) => {
634
- const d = n.outputData.decision ?? n.outputData;
635
- return {
636
- step: i + 1,
637
- action: d.actionType ?? d.action_type,
638
- intent: d.intent,
639
- target: d.target,
640
- value: d.value ?? undefined,
641
- success: n.outputData.success ?? n.status === 'success',
642
- durationMs: n.executionTimeMs,
643
- };
644
- });
645
- const subworkflowHistory = subworkflowNode?.outputData?.actionHistory;
646
- if (actionTrace.length === 0 && Array.isArray(subworkflowHistory) && subworkflowHistory.length > 0) {
647
- subworkflowHistory.forEach((step, i) => {
648
- actionTrace.push({
649
- step: i + 1,
650
- action: step.actionType ?? step.action_type ?? step.action,
651
- intent: step.intent,
652
- target: step.target,
653
- value: step.value ?? undefined,
654
- success: step.success ?? true,
655
- durationMs: step.durationMs ?? step.duration_ms ?? undefined,
656
- });
657
- });
658
- }
659
- // Evaluation: RELAY the backend's block; derive one only for older backends.
660
- //
661
- // The backend computes `evaluation` from the SAME verdict as the headline
662
- // outcome (sentinal-sk5sl.1) precisely so one payload cannot say
663
- // outcome='inconclusive' at the top and evaluation.outcome='unknown'
664
- // underneath. Rebuilding it here from raw subworkflow node output
665
- // reintroduced that contradiction client-side, and worse: the node's
666
- // `success` is a bare boolean, so "could not determine" arrived as
667
- // passed:false. The backend deliberately sends passed:null for
668
- // inconclusive — an undetermined run is not a failed one.
669
- //
670
- // Deriving from nodes stays ONLY as the pre-contract fallback.
671
- const evalNode = nodes.find(n => n.nodeType === 'brain.evaluate');
672
- let evaluation;
673
- if (finalExecution.evaluation && typeof finalExecution.evaluation === 'object') {
674
- evaluation = finalExecution.evaluation;
675
- }
676
- else if (evalNode?.outputData) {
677
- evaluation = {
678
- passed: evalNode.outputData.passed,
679
- outcome: evalNode.outputData.outcome,
680
- reason: evalNode.outputData.reason,
681
- verifications: evalNode.outputData.verifications,
682
- };
683
- }
684
- else if (subworkflowNode?.outputData) {
685
- const sw = subworkflowNode.outputData;
686
- evaluation = {
687
- passed: sw.success,
688
- outcome: sw.outcome,
689
- reason: sw.error || undefined,
690
- };
691
- }
692
622
  // --- Relay the backend's explicit verdict (bead 56kd.2) ---
693
623
  // ONE adapter owns the backend-field → MCP mapping (services/verdictAdapter).
694
- // We consume the verdict/budget/evidence VERBATIM — no fabricated outcome,
695
- // no success default-false, no synthesized 'assertion-mismatch'. A
696
- // missing/unknown verdict surfaces as 'inconclusive', never as a failure.
697
624
  // On a poll-deadline timeout (bead 56kd.3) pollExecution returns the last
698
625
  // observed execution flagged `timedOut`; force outcome 'timeout' since there
699
- // is no terminal backend verdict, and shape its partial evidence below.
626
+ // is no terminal backend verdict.
700
627
  const timedOut = finalExecution.timedOut === true;
701
628
  const verdict = adaptVerdict(finalExecution, {
702
- fallbackBudget: MAX_EXEC_STEPS,
703
629
  outcomeOverride: timedOut ? 'timeout' : undefined,
630
+ // real numbers from the deadline path, so the reason is a record of what
631
+ // we observed rather than a fixed sentence
632
+ pollTimeout: timedOut ? finalExecution.pollTimeout : undefined,
704
633
  });
705
- // Evidence: prefer the backend's contract evidence.actionTrace; fall back to
706
- // the legacy node-extracted trace while the backend contract deploys.
707
- const relayActionTrace = verdict.actionTrace ?? actionTrace;
634
+ const relayActionTrace = verdict.actionTrace;
635
+ // Evaluation: the backend derives it from the SAME verdict as the headline
636
+ // (sentinal-sk5sl.1), so its passed/outcome/reason are that verdict a second
637
+ // time — and passed is just outcome === 'pass'. Relay only what the verdict
638
+ // does not already carry (e.g. verifications); omit it when that is nothing.
639
+ let evaluation;
640
+ if (finalExecution.evaluation && typeof finalExecution.evaluation === 'object') {
641
+ const { passed: _p, outcome: _o, reason: _r, ...rest } = finalExecution.evaluation;
642
+ if (Object.keys(rest).length > 0)
643
+ evaluation = rest;
644
+ }
708
645
  // --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
709
646
  // The pre-flight probe above proves the tunnel was alive when we handed it to
710
647
  // the remote browser — it can still die mid-run. When it does, the browser
@@ -769,28 +706,17 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
769
706
  }
770
707
  const responsePayload = {
771
708
  outcome: verdict.outcome,
772
- success: verdict.success,
709
+ ...(verdict.reason ? { reason: verdict.reason } : {}),
710
+ // platform-98fv.16: never attempted a test — no verdict, the backend's reason verbatim
711
+ ...(verdict.skipped ? { skipped: true } : {}),
712
+ ...(verdict.skipReason ? { skipReason: verdict.skipReason } : {}),
773
713
  status: finalExecution.status,
774
- stepsTaken: verdict.stepsTaken,
775
- stepsBudget: verdict.stepsBudget, // from the response (bead 56kd.2)
776
- stepsRemaining: verdict.stepsRemaining,
777
- targetUrl: originalUrl,
778
714
  executionId: executionUuid,
715
+ targetUrl: originalUrl,
779
716
  durationMs: finalExecution.durationMs ?? duration,
780
717
  };
781
- // failureCategory = the outcome verbatim (fail | inconclusive | error |
782
- // timeout); OMITTED on success. No inference, no 'assertion-mismatch'.
783
- if (verdict.failureCategory)
784
- responsePayload.failureCategory = verdict.failureCategory;
785
- if (verdict.reason)
786
- responsePayload.reason = verdict.reason;
787
- // --- Which identity did the run actually sign in as? ---
788
- // A login as the wrong account and an app rejecting the right one produce
789
- // the SAME symptom ("login failed") and used to be indistinguishable from
790
- // the result, so an environment-default substitution read as an application
791
- // bug. Relay the logins verbatim, and when the caller named an account but
792
- // an environment default was used anyway, say so explicitly rather than
793
- // leaving the caller to infer it from a failed check.
718
+ if (verdict.budget)
719
+ responsePayload.budget = verdict.budget;
794
720
  // For "navigate and describe what you see", the answer IS the deliverable —
795
721
  // and it reached callers only incidentally, buried in actionTrace[0].intent,
796
722
  // which the verify-gate rewrites when it disagrees with the page. Surface it.
@@ -800,10 +726,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
800
726
  responsePayload.logins = verdict.logins;
801
727
  if (verdict.loginError)
802
728
  responsePayload.loginError = verdict.loginError;
803
- // Bead b5x6: only a SUBMITTED env-default login under a DIFFERENT account
804
- // counts. A refused/skipped login (offscope_host, no form) typed nothing,
805
- // and the env's stored credential may be the very account that was named —
806
- // warning on either states something that did not happen.
729
+ // Bead b5x6: the caller named an account and the run SUBMITTED an
730
+ // environment default for a different one. Stated as the two facts — what
731
+ // was asked for, what was used — and nothing about what to conclude.
732
+ // Only a SUBMITTED env-default login under a DIFFERENT account counts: a
733
+ // refused/skipped login typed nothing, and the env's stored credential may
734
+ // be the very account that was named.
807
735
  const requestedIdentity = input.username
808
736
  ?? input.auth?.username
809
737
  ?? input.loginCredentials?.[0]?.username;
@@ -816,43 +744,26 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
816
744
  responsePayload.credentialWarning = {
817
745
  requested: requestedIdentity,
818
746
  used: [...new Set(substituted.map(l => l.username))],
819
- message: `This run signed in with an environment default credential even though ` +
820
- `'${requestedIdentity}' was specified. Treat a login failure here as a ` +
821
- `credential-resolution problem, not an application failure.`,
822
747
  };
823
748
  logger.warn(`check_app_in_browser: requested identity '${requestedIdentity}' but the run used ` +
824
749
  `environment-default credential(s): ${responsePayload.credentialWarning.used.join(', ')}`);
825
750
  }
826
- // Bug z15n: OUR tunnel died mid-run, so this 'fail' describes our error page,
827
- // not the user's app. Relay it as a distinct, retryable infrastructure class
828
- // so an automated caller doesn't record a UI regression that never happened.
829
- // The backend's original verdict is preserved verbatim, never swallowed.
830
- //
831
- // Bug 4bui: the stated cause leads with the MARKER, because the marker is the
832
- // evidence that justified getting here — the run itself recorded the tunnel's
833
- // interstitial. The probe only corroborates (and may legitimately say the
834
- // tunnel has recovered by now), so it rides in `detail`, never as the cause.
751
+ // Bug z15n: the run itself recorded our tunnel's DEBUGG_TUNNEL_* interstitial,
752
+ // so its 'fail' describes our error page, not the user's app. That is an
753
+ // MCP-observed fact, so the MCP may state it: outcome 'error', a reason
754
+ // built from the marker and the probe result, the backend's own verdict kept
755
+ // verbatim under `backendVerdict`. No commentary on top.
835
756
  if (tunnelFault) {
836
757
  const { probe, tunnelErrorCode } = tunnelFault;
837
758
  responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
838
759
  responsePayload.outcome = 'error';
839
- responsePayload.success = false;
840
- responsePayload.failureCategory = 'infrastructure';
841
760
  responsePayload.error = 'TunnelOfflineDuringRun';
842
- responsePayload.message =
843
- 'During this run the remote browser landed on our tunnel\'s error page instead of your ' +
844
- 'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
845
- 'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
846
- 'preserved under `backendVerdict`.';
847
- responsePayload.reason =
848
- `The run recorded our tunnel's ${tunnelErrorCode} interstitial, so the remote browser reached ` +
849
- 'our tunnel error page instead of your app.' +
850
- (probe
851
- ? probe.healthy
852
- ? ' (Our re-probe after the run found the tunnel reachable again, so it has since recovered.)'
853
- : ` (Our re-probe after the run also failed: ${probe.code}` +
854
- `${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
855
- : '');
761
+ const probeFact = probe
762
+ ? probe.healthy
763
+ ? 'post-run tunnel probe: reachable'
764
+ : `post-run tunnel probe: ${probe.code}${probe.status ? ` (HTTP ${probe.status})` : ''}`
765
+ : 'post-run tunnel probe: not run';
766
+ responsePayload.reason = `The run recorded the tunnel error page ${tunnelErrorCode}; ${probeFact}.`;
856
767
  responsePayload.detail = {
857
768
  tunnelErrorCode,
858
769
  probeCode: probe?.code,
@@ -869,19 +780,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
869
780
  responsePayload.agentError = finalExecution.state.error;
870
781
  if (finalExecution.errorMessage)
871
782
  responsePayload.errorMessage = finalExecution.errorMessage;
872
- if (finalExecution.errorInfo?.failedNodeId)
873
- responsePayload.failedNode = finalExecution.errorInfo.failedNodeId;
783
+ if (finalExecution.errorInfo)
784
+ responsePayload.errorInfo = finalExecution.errorInfo;
874
785
  if (executeResponse.resolvedEnvironmentId)
875
786
  responsePayload.resolvedEnvironmentId = executeResponse.resolvedEnvironmentId;
876
787
  if (executeResponse.resolvedCredentialId)
877
788
  responsePayload.resolvedCredentialId = executeResponse.resolvedCredentialId;
878
- if (surferNode?.outputData) {
879
- responsePayload.surferOutput = sanitizeResponseUrls(surferNode.outputData, ctx);
880
- }
881
- // Backend release 2026-04-25: browser_session block on execution detail
882
- // carries presigned S3 URLs for HAR + console log + recording. Pass through
883
- // verbatim — sanitizeResponseUrls below only strips tunnel hosts so S3 URLs
884
- // are preserved. Resolves client-feedback items #1 (network) + #7 (console).
789
+ // browser_session block: presigned S3 URLs for HAR + console log + recording,
790
+ // passed through verbatim.
885
791
  if (finalExecution.browserSession) {
886
792
  responsePayload.browserSession = finalExecution.browserSession;
887
793
  }
@@ -962,7 +868,6 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
962
868
  ? [resourceLinkBlock(gifUrl, 'run-recording.gif', {
963
869
  mimeType: 'image/gif',
964
870
  title: 'Run recording',
965
- description: 'Animated recording of the run (presigned URL — open or fetch on demand).',
966
871
  })]
967
872
  : []),
968
873
  ...artifactResourceLinks(sanitizedPayload.browserSession),
@@ -11,6 +11,7 @@
11
11
  * and reports back what happened.
12
12
  */
13
13
  import { config } from '../config/index.js';
14
+ import { toUserFacingOutcome, noVerdictReason } from '../services/verdictAdapter.js';
14
15
  import { Logger } from '../utils/logger.js';
15
16
  import { handleExternalServiceError } from '../utils/errors.js';
16
17
  import { DebuggAIServerClient } from '../services/index.js';
@@ -92,7 +93,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
92
93
  if (!probe.reachable) {
93
94
  const payload = {
94
95
  error: 'LocalServerUnreachable',
95
- message: `No server listening on 127.0.0.1:${localPort}. Start your dev server on that port before running trigger_crawl. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
96
+ message: `No server listening on 127.0.0.1:${localPort}.`,
96
97
  detail: { port: localPort, probeCode: probe.code, probeDetail: probe.detail, elapsedMs: probe.elapsedMs },
97
98
  };
98
99
  logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
@@ -119,9 +120,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
119
120
  catch (provisionError) {
120
121
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
121
122
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
122
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
123
- `The remote browser needs a secure tunnel to reach your local dev server. ` +
124
- `(Detail: ${msg})${diag}`);
123
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}: ${msg}${diag}`);
125
124
  }
126
125
  provision = tunnel;
127
126
  ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
@@ -151,7 +150,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
151
150
  if (!health.healthy) {
152
151
  const payload = {
153
152
  error: 'TunnelTrafficBlocked',
154
- message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
153
+ message: `Tunnel established; a request through it to 127.0.0.1:${extractLocalhostPort(ctx.originalUrl)} failed: ${health.detail ?? health.code}.`,
155
154
  detail: {
156
155
  code: health.code,
157
156
  status: health.status,
@@ -186,9 +185,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
186
185
  return client.workflows.findTemplateBySlug(templateSlug);
187
186
  });
188
187
  if (!templateUuid) {
189
- throw new Error(`Crawl Workflow Template not found (slug "${templateSlug}"). ` +
190
- `Ensure the backend has that template seeded and accessible ` +
191
- `(GET /api/v1/workflows/?slug=${templateSlug}).`);
188
+ throw new Error(`Crawl workflow template not found (slug "${templateSlug}").`);
192
189
  }
193
190
  // --- Build contextData + env ---
194
191
  const contextData = {
@@ -293,13 +290,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
293
290
  targetUrl: ctx.originalUrl,
294
291
  durationMs: finalExecution.durationMs ?? duration,
295
292
  };
296
- const outcome = finalExecution.state?.outcome;
297
- if (outcome !== undefined && outcome !== null)
298
- responsePayload.outcome = outcome;
293
+ // Map onto the user-facing enum instead of relaying raw. This path never
294
+ // went through adaptVerdict, so it would otherwise emit whatever the
295
+ // backend sent — including retired non-answers like 'inconclusive'.
296
+ const rawOutcome = finalExecution.state?.outcome;
297
+ if (finalExecution.verdict?.skipped === true && !finalExecution.verdict.outcome) {
298
+ // platform-98fv.16: the run never attempted a test — no verdict, the
299
+ // backend's recorded reason verbatim. Never the raw run outcome.
300
+ responsePayload.outcome = null;
301
+ responsePayload.skipped = true;
302
+ const skipReason = finalExecution.verdict.reason;
303
+ if (typeof skipReason === 'string' && skipReason.trim() !== '')
304
+ responsePayload.skipReason = skipReason;
305
+ }
306
+ else if (rawOutcome !== undefined && rawOutcome !== null) {
307
+ responsePayload.outcome = toUserFacingOutcome(rawOutcome);
308
+ if (responsePayload.outcome === 'error' && !finalExecution.errorMessage) {
309
+ responsePayload.reason = noVerdictReason(rawOutcome);
310
+ }
311
+ }
299
312
  if (finalExecution.errorMessage)
300
313
  responsePayload.errorMessage = finalExecution.errorMessage;
301
- if (finalExecution.errorInfo?.failedNodeId)
302
- responsePayload.failedNode = finalExecution.errorInfo.failedNodeId;
314
+ if (finalExecution.errorInfo)
315
+ responsePayload.errorInfo = finalExecution.errorInfo;
303
316
  if (executeResponse.resolvedEnvironmentId)
304
317
  responsePayload.resolvedEnvironmentId = executeResponse.resolvedEnvironmentId;
305
318
  if (executeResponse.resolvedCredentialId)
@@ -310,34 +323,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
310
323
  if (finalExecution.browserSession) {
311
324
  responsePayload.browserSession = finalExecution.browserSession;
312
325
  }
313
- // Extract crawl metrics from surfer.crawl node (absent in older graph shapes)
314
- const crawlNode = nodes.find(n => n.nodeType === 'surfer.crawl');
315
- if (crawlNode?.outputData) {
316
- const d = crawlNode.outputData;
317
- responsePayload.crawlSummary = {
318
- pagesDiscovered: d.pagesDiscovered,
319
- actionsExecuted: d.actionsExecuted,
320
- stepsTaken: d.stepsTaken,
321
- transitionsRecorded: d.transitionsRecorded,
322
- knowledgeGraphStates: d.knowledgeGraphStates,
323
- success: d.success,
324
- ...(d.error ? { error: d.error } : {}),
325
- };
326
- }
327
- // Extract KG import result from knowledge_graph.import node (absent in older graph shapes)
328
- const kgNode = nodes.find(n => n.nodeType === 'knowledge_graph.import');
329
- if (kgNode?.outputData) {
330
- const d = kgNode.outputData;
331
- responsePayload.knowledgeGraph = {
332
- imported: !d.skipped,
333
- skipped: d.skipped ?? false,
334
- reason: d.reason ?? '',
335
- edgesImported: d.edgesImported ?? 0,
336
- statesImported: d.statesImported ?? 0,
337
- knowledgeGraphId: d.knowledgeGraphId ?? '',
338
- ...(Array.isArray(d.importErrors) && d.importErrors.length > 0 ? { importErrors: d.importErrors } : {}),
339
- };
340
- }
326
+ // Node output, relayed under the backend's own keys. Only the listed keys,
327
+ // and only when the node reported them: a missing count is absent, never a
328
+ // fabricated 0 / '' that reads as "the backend said zero".
329
+ const pick = (src, keys) => {
330
+ if (!src)
331
+ return undefined;
332
+ const out = {};
333
+ for (const k of keys)
334
+ if (src[k] !== undefined && src[k] !== null)
335
+ out[k] = src[k];
336
+ return out;
337
+ };
338
+ const crawlSummary = pick(nodes.find(n => n.nodeType === 'surfer.crawl')?.outputData, [
339
+ 'pagesDiscovered', 'actionsExecuted', 'stepsTaken', 'transitionsRecorded',
340
+ 'knowledgeGraphStates', 'success', 'error',
341
+ ]);
342
+ if (crawlSummary)
343
+ responsePayload.crawlSummary = crawlSummary;
344
+ const knowledgeGraph = pick(nodes.find(n => n.nodeType === 'knowledge_graph.import')?.outputData, [
345
+ 'skipped', 'reason', 'edgesImported', 'statesImported', 'knowledgeGraphId', 'importErrors',
346
+ ]);
347
+ if (knowledgeGraph)
348
+ responsePayload.knowledgeGraph = knowledgeGraph;
341
349
  logger.toolComplete('trigger_crawl', duration);
342
350
  // Bead 0bq: final progress is emitted INSIDE pollExecution's onUpdate when
343
351
  // terminal status is detected. Emitting it here would race the response
@@ -134,10 +134,10 @@ export async function updateEnvironmentHandler(input, _context) {
134
134
  if (warnings.length > 0)
135
135
  payload.credentialWarnings = warnings;
136
136
  if (input.authorizedCredentialHosts !== undefined) {
137
- const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts, 'update');
137
+ const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts);
138
138
  if (hostsWarning) {
139
139
  payload.authorizedCredentialHostsWarning = hostsWarning;
140
- logger.warn(`update_environment: ${hostsWarning.message}`);
140
+ logger.warn(`update_environment: authorizedCredentialHosts not persisted`, hostsWarning);
141
141
  }
142
142
  }
143
143
  logger.toolComplete('update_environment', Date.now() - start);
@@ -1,3 +1,4 @@
1
+ import { toUserFacingOutcome } from './verdictAdapter.js';
1
2
  import { createWorkflowsService } from "./workflows.js";
2
3
  import { createTunnelsService } from "./tunnels.js";
3
4
  import { AxiosTransport } from "../utils/axiosTransport.js";
@@ -298,7 +299,8 @@ export class DebuggAIServerClient {
298
299
  if (!this.tx)
299
300
  throw new Error('Client not initialized — call init() first');
300
301
  const response = await this.tx.delete(`api/v1/environments/${envUuid}/sessions/`, { params: filters });
301
- return { invalidated: response?.invalidated ?? 0 };
302
+ // absent → null: a count the backend did not report is not a reported 0
303
+ return { invalidated: response?.invalidated ?? null };
302
304
  }
303
305
  /**
304
306
  * Fetch a single environment by UUID. Throws AxiosError with status 404 if not found.
@@ -500,8 +502,8 @@ export class DebuggAIServerClient {
500
502
  uuid: s.uuid,
501
503
  name: s.name,
502
504
  description: s.description ?? null,
503
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
504
- testsCount: s.testsCount ?? s.tests_count ?? 0,
505
+ runStatus: s.runStatus ?? s.run_status ?? null,
506
+ testsCount: s.testsCount ?? s.tests_count ?? null,
505
507
  passRate: s.passRate ?? s.pass_rate ?? null,
506
508
  lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
507
509
  })),
@@ -536,7 +538,7 @@ export class DebuggAIServerClient {
536
538
  agentTaskDescription: t.agentTaskDescription ?? t.agent_task_description ?? '',
537
539
  suite: t.suite ?? input.suiteUuid,
538
540
  project: t.project ?? input.projectUuid,
539
- runCount: t.runCount ?? t.run_count ?? 0,
541
+ runCount: t.runCount ?? t.run_count ?? null,
540
542
  };
541
543
  }
542
544
  async updateTestCase(testUuid, patch) {
@@ -572,7 +574,7 @@ export class DebuggAIServerClient {
572
574
  const s = await this.tx.post(`api/v1/test-suites/${suiteUuid}/run/`, body);
573
575
  return {
574
576
  suiteUuid,
575
- runStatus: s?.runStatus ?? s?.run_status ?? 'PENDING',
577
+ runStatus: s?.runStatus ?? s?.run_status ?? null,
576
578
  testsTriggered: (s?.tests ?? []).length,
577
579
  };
578
580
  }
@@ -584,7 +586,7 @@ export class DebuggAIServerClient {
584
586
  return {
585
587
  uuid: s.uuid,
586
588
  name: s.name,
587
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
589
+ runStatus: s.runStatus ?? s.run_status ?? null,
588
590
  testsCount: tests.length,
589
591
  passRate: s.passRate ?? s.pass_rate ?? null,
590
592
  lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
@@ -594,14 +596,18 @@ export class DebuggAIServerClient {
594
596
  return {
595
597
  uuid: t.uuid,
596
598
  name: t.name,
597
- runCount: t.runCount ?? t.run_count ?? 0,
598
- passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? 0,
599
- failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? 0,
599
+ runCount: t.runCount ?? t.run_count ?? null,
600
+ passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? null,
601
+ failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? null,
600
602
  passRate: t.passRate ?? t.pass_rate ?? null,
601
603
  lastRun: lastRun ? {
602
604
  uuid: lastRun.uuid,
603
605
  status: lastRun.status,
604
- outcome: lastRun.outcome,
606
+ // the user-facing verdict allowlist, same as every other surface;
607
+ // a run with no outcome yet keeps its null
608
+ outcome: typeof lastRun.outcome === 'string' && lastRun.outcome.trim() !== ''
609
+ ? toUserFacingOutcome(lastRun.outcome)
610
+ : lastRun.outcome ?? null,
605
611
  executionTime: lastRun.executionTime ?? lastRun.execution_time ?? null,
606
612
  timestamp: lastRun.timestamp,
607
613
  } : null,
@@ -614,8 +620,8 @@ export class DebuggAIServerClient {
614
620
  uuid: s.uuid,
615
621
  name: s.name,
616
622
  description: s.description ?? null,
617
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
618
- testsCount: s.testsCount ?? s.tests_count ?? 0,
623
+ runStatus: s.runStatus ?? s.run_status ?? null,
624
+ testsCount: s.testsCount ?? s.tests_count ?? null,
619
625
  };
620
626
  }
621
627
  }