@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.
- package/CHANGELOG.md +61 -1
- package/README.md +23 -7
- package/dist/handlers/createEnvironmentHandler.js +4 -4
- package/dist/handlers/createProjectHandler.js +1 -1
- package/dist/handlers/environmentSessionsHandler.js +2 -18
- package/dist/handlers/probePageHandler.js +8 -11
- package/dist/handlers/runTestSuiteHandler.js +4 -5
- package/dist/handlers/searchEnvironmentsHandler.js +3 -6
- package/dist/handlers/searchExecutionsHandler.js +3 -11
- package/dist/handlers/searchProjectsHandler.js +1 -6
- package/dist/handlers/testPageChangesHandler.js +67 -162
- package/dist/handlers/triggerCrawlHandler.js +49 -41
- package/dist/handlers/updateEnvironmentHandler.js +2 -2
- package/dist/services/index.js +18 -12
- package/dist/services/verdictAdapter.js +167 -41
- package/dist/services/workflows.js +10 -1
- package/dist/utils/authorizedCredentialHosts.js +8 -20
- package/dist/utils/confirmDestructive.js +1 -2
- package/dist/utils/errors.js +4 -5
- package/dist/utils/imageUtils.js +0 -1
- package/dist/utils/resolveProject.js +1 -1
- package/dist/utils/urlParser.js +33 -0
- package/package.json +1 -1
|
@@ -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}
|
|
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
|
|
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(
|
|
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:
|
|
363
|
-
|
|
364
|
-
'
|
|
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
|
-
|
|
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`
|
|
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}
|
|
401
|
-
`localhost:${rewrite.primaryPort}.
|
|
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
|
-
|
|
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: ${
|
|
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
|
|
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
|
|
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
|
-
|
|
706
|
-
// the
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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:
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
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:
|
|
827
|
-
// not the user's app.
|
|
828
|
-
//
|
|
829
|
-
//
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
'
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|
873
|
-
responsePayload.
|
|
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
|
-
|
|
879
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
302
|
-
responsePayload.
|
|
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
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
137
|
+
const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts);
|
|
138
138
|
if (hostsWarning) {
|
|
139
139
|
payload.authorizedCredentialHostsWarning = hostsWarning;
|
|
140
|
-
logger.warn(`update_environment:
|
|
140
|
+
logger.warn(`update_environment: authorizedCredentialHosts not persisted`, hostsWarning);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
logger.toolComplete('update_environment', Date.now() - start);
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
504
|
-
testsCount: s.testsCount ?? s.tests_count ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
598
|
-
passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ??
|
|
599
|
-
failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ??
|
|
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
|
-
|
|
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 ??
|
|
618
|
-
testsCount: s.testsCount ?? s.tests_count ??
|
|
623
|
+
runStatus: s.runStatus ?? s.run_status ?? null,
|
|
624
|
+
testsCount: s.testsCount ?? s.tests_count ?? null,
|
|
619
625
|
};
|
|
620
626
|
}
|
|
621
627
|
}
|