@debugg-ai/debugg-ai-mcp 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -226,6 +226,7 @@ Response-shape changes: the bare `count` field on list responses is gone — use
226
226
  | `DEBUGGAI_API_KEY` | yes | Backend API key. Aliases: `DEBUGGAI_API_TOKEN`, `DEBUGGAI_JWT_TOKEN`. |
227
227
  | `DEBUGGAI_API_URL` | no | Backend base URL. Defaults to `https://api.debugg.ai`. |
228
228
  | `DEBUGGAI_TOKEN_TYPE` | no | `token` (default) or `bearer`. |
229
+ | `DEBUGGAI_EVAL_TEMPLATE` | no | Override the App Evaluation workflow **slug** that `check_app_in_browser` dispatches to. Defaults to `flow/e2es/app-eval`. Dispatch pins to this slug so a backend template rename can't break it. |
229
230
  | `LOG_LEVEL` | no | `error` / `warn` / `info` (default) / `debug`. |
230
231
  | `POSTHOG_API_KEY` | no | Override the embedded telemetry project key (e.g. private fork). |
231
232
  | `DEBUGGAI_TELEMETRY_DISABLED` | no | Set to `1` / `true` / `yes` / `on` to disable telemetry entirely. |
@@ -8,6 +8,8 @@ import { Logger } from '../utils/logger.js';
8
8
  import { handleExternalServiceError } from '../utils/errors.js';
9
9
  import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
10
10
  import { DebuggAIServerClient } from '../services/index.js';
11
+ import { getEvalTemplateSlug } from '../services/workflows.js';
12
+ import { adaptVerdict } from '../services/verdictAdapter.js';
11
13
  import { TunnelProvisionError } from '../services/tunnels.js';
12
14
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
13
15
  import { detectRepoName } from '../utils/gitContext.js';
@@ -18,7 +20,6 @@ import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, i
18
20
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
19
21
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
20
22
  const logger = new Logger({ module: 'testPageChangesHandler' });
21
- const TEMPLATE_NAME = 'app evaluation';
22
23
  // Bead kbxy: bounded retry on known transient backend signatures (Pydantic
23
24
  // JSON parse errors, 502s, ECONNRESETs). Default 1 retry; env-overridable
24
25
  // up to 3 to balance reliability vs quota cost. Conservative: only retries
@@ -90,12 +91,26 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
90
91
  const originalUrl = resolveTargetUrl(input);
91
92
  let ctx = buildContext(originalUrl);
92
93
  let keyId;
94
+ // Cancellation is driven by the MCP request/transport lifecycle, not
95
+ // process.stdin. The SDK aborts context.signal when the client cancels the
96
+ // call OR the transport closes — e.g. an HTTP client drops the connection.
97
+ // Under the stateless HTTP transport that is the ONLY signal we get: stdin is
98
+ // not the transport, so the old stdin 'close' listener never fired and a
99
+ // dropped client kept polling for up to ~10 min, holding one of just
100
+ // MAX_CONCURRENT=2 slots. Wiring to context.signal cancels the poll and frees
101
+ // the slot immediately.
93
102
  const abortController = new AbortController();
94
- const onStdinClose = () => {
103
+ const onAbort = () => {
95
104
  abortController.abort();
96
105
  progressDisabled = true; // client is gone — stop emitting
97
106
  };
98
- process.stdin.once('close', onStdinClose);
107
+ const requestSignal = context.signal;
108
+ if (requestSignal) {
109
+ if (requestSignal.aborted)
110
+ onAbort();
111
+ else
112
+ requestSignal.addEventListener('abort', onAbort, { once: true });
113
+ }
99
114
  // Progress budget: 3 setup steps + 25 execution steps = 28 total
100
115
  const SETUP_STEPS = 3;
101
116
  const MAX_EXEC_STEPS = 25;
@@ -203,7 +218,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
203
218
  }
204
219
  const repoName = input.repoName || detectRepoName();
205
220
  const [templateUuid, projectUuid] = await Promise.all([
206
- getCachedTemplateUuid(TEMPLATE_NAME, async () => {
221
+ // Cache key = the dispatch slug so the cache key and the lookup can never
222
+ // drift apart (bug clka: the key used to be a decoupled 'app evaluation'
223
+ // literal while the lookup searched a different string).
224
+ getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
207
225
  return client.workflows.findEvaluationTemplate();
208
226
  }),
209
227
  repoName
@@ -222,8 +240,22 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
222
240
  throw new Error('App Evaluation Workflow Template not found. ' +
223
241
  'Ensure the template is seeded in the backend (GET /api/v1/workflows/?is_template=true).');
224
242
  }
225
- if (repoName && !projectUuid) {
226
- logger.info(`No project found for repo "${repoName}" proceeding without project_id`);
243
+ // Fail fast + actionable when project_id can't be resolved (pinned backend
244
+ // semantics: project_id is required). Surfacing "link this repo to a
245
+ // project" now — before executeWorkflow — beats letting a backend workflow
246
+ // node fail mid-run several minutes into the evaluation.
247
+ if (!projectUuid) {
248
+ const detail = repoName
249
+ ? `Repo "${repoName}" isn't linked to a DebuggAI project.`
250
+ : 'No git repository was detected to resolve a project from.';
251
+ const payload = {
252
+ error: 'ProjectRequired',
253
+ message: `project_id is required but could not be resolved. ${detail} ` +
254
+ 'Link this repo to a project at https://debugg.ai (Projects → connect your repository), then retry. ' +
255
+ 'To evaluate a different repo than the current one, pass repoName.',
256
+ };
257
+ logger.warn(`check_app_in_browser: ${payload.message}`);
258
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
227
259
  }
228
260
  // --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
229
261
  const contextData = {
@@ -366,6 +398,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
366
398
  // result the agent reached.
367
399
  if (attempt > MAX_RETRIES)
368
400
  break;
401
+ // A poll-deadline timeout (bead 56kd.3) is never retried — surface the
402
+ // partial result instead of burning another 10 minutes.
403
+ if (finalExecution.timedOut)
404
+ break;
369
405
  if (!isTransientWorkflowError(finalExecution))
370
406
  break;
371
407
  logger.warn(`Transient backend error detected (${transientReasonTag(finalExecution) ?? 'unknown'}) — ` +
@@ -373,7 +409,6 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
373
409
  }
374
410
  const duration = Date.now() - startTime;
375
411
  // --- Format result ---
376
- const outcome = finalExecution.state?.outcome ?? finalExecution.status;
377
412
  const nodes = finalExecution.nodeExecutions ?? [];
378
413
  // subworkflow.run is the current graph shape — carries outcome, actionHistory, screenshot
379
414
  const subworkflowNode = nodes.find(n => n.nodeType === 'subworkflow.run');
@@ -428,43 +463,41 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
428
463
  reason: sw.error || undefined,
429
464
  };
430
465
  }
431
- const stepsTaken = finalExecution.state?.stepsTaken ?? subworkflowNode?.outputData?.stepsTaken ?? actionTrace.length;
432
- const success = finalExecution.state?.success ?? subworkflowNode?.outputData?.success ?? false;
466
+ // --- Relay the backend's explicit verdict (bead 56kd.2) ---
467
+ // ONE adapter owns the backend-field MCP mapping (services/verdictAdapter).
468
+ // We consume the verdict/budget/evidence VERBATIM — no fabricated outcome,
469
+ // no success default-false, no synthesized 'assertion-mismatch'. A
470
+ // missing/unknown verdict surfaces as 'inconclusive', never as a failure.
471
+ // On a poll-deadline timeout (bead 56kd.3) pollExecution returns the last
472
+ // observed execution flagged `timedOut`; force outcome 'timeout' since there
473
+ // is no terminal backend verdict, and shape its partial evidence below.
474
+ const timedOut = finalExecution.timedOut === true;
475
+ const verdict = adaptVerdict(finalExecution, {
476
+ fallbackBudget: MAX_EXEC_STEPS,
477
+ outcomeOverride: timedOut ? 'timeout' : undefined,
478
+ });
479
+ // Evidence: prefer the backend's contract evidence.actionTrace; fall back to
480
+ // the legacy node-extracted trace while the backend contract deploys.
481
+ const relayActionTrace = verdict.actionTrace ?? actionTrace;
433
482
  const responsePayload = {
434
- outcome,
435
- success,
483
+ outcome: verdict.outcome,
484
+ success: verdict.success,
436
485
  status: finalExecution.status,
437
- stepsTaken,
438
- stepsBudget: MAX_EXEC_STEPS, // bead qmdd
439
- stepsRemaining: Math.max(0, MAX_EXEC_STEPS - (stepsTaken ?? 0)), // bead qmdd
486
+ stepsTaken: verdict.stepsTaken,
487
+ stepsBudget: verdict.stepsBudget, // from the response (bead 56kd.2)
488
+ stepsRemaining: verdict.stepsRemaining,
440
489
  targetUrl: originalUrl,
441
490
  executionId: executionUuid,
442
491
  durationMs: finalExecution.durationMs ?? duration,
443
492
  };
444
- // Bead jqmj: failureCategory disambiguates the three meanings of 'fail':
445
- // 'agent-error' — workflow/infra failure (Pydantic parse error,
446
- // backend exception, transport issue). Caller's
447
- // right move: retry-with-backoff.
448
- // 'assertion-mismatch' — agent ran the scenario but page state didn't
449
- // match expectations. Caller's right move: fix
450
- // code or update the test description.
451
- // ('page-error' is reserved for v2 — needs a structured signal from
452
- // backend to distinguish from assertion-mismatch reliably; today's
453
- // inferrable info is too fragile.)
454
- // Field is OMITTED on success (no failure to categorize).
455
- if (!success) {
456
- // state.error is the AGENT's narrative — it can describe assertion
457
- // failures ("expected heading to contain Welcome") OR infrastructure
458
- // failures ("Pydantic JSON parse error"). Without a structured signal,
459
- // we only count it as 'agent-error' when paired with workflow-level
460
- // failure (status='failed') or transient signature.
461
- // status='failed' or errorMessage set → workflow-level / transport error.
462
- const hasInfraFailure = finalExecution.status === 'failed'
463
- || !!finalExecution.errorMessage;
464
- responsePayload.failureCategory = hasInfraFailure ? 'agent-error' : 'assertion-mismatch';
465
- }
466
- if (actionTrace.length > 0)
467
- responsePayload.actionTrace = actionTrace;
493
+ // failureCategory = the outcome verbatim (fail | inconclusive | error |
494
+ // timeout); OMITTED on success. No inference, no 'assertion-mismatch'.
495
+ if (verdict.failureCategory)
496
+ responsePayload.failureCategory = verdict.failureCategory;
497
+ if (verdict.reason)
498
+ responsePayload.reason = verdict.reason;
499
+ if (Array.isArray(relayActionTrace) && relayActionTrace.length > 0)
500
+ responsePayload.actionTrace = relayActionTrace;
468
501
  if (evaluation)
469
502
  responsePayload.evaluation = evaluation;
470
503
  if (finalExecution.state?.error)
@@ -505,14 +538,29 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
505
538
  const GIF_KEYS = ['runGif', 'gifUrl', 'gif', 'videoUrl', 'recordingUrl'];
506
539
  let screenshotEmbedded = false;
507
540
  let gifUrl = null;
541
+ let screenshotUrl = null;
542
+ // Contract evidence.screenshot (bead 56kd.2/.3) is the preferred source and
543
+ // is present on EVERY terminal state — including fail and timeout — so we
544
+ // always have the last screenshot on non-success. Base64 embeds inline; an
545
+ // http(s) URL is fetched below via the same path as legacy node URLs.
546
+ const evidenceScreenshot = verdict.screenshot;
547
+ if (typeof evidenceScreenshot === 'string' && evidenceScreenshot) {
548
+ if (/^https?:\/\//i.test(evidenceScreenshot)) {
549
+ screenshotUrl = evidenceScreenshot;
550
+ }
551
+ else {
552
+ logger.info('Embedding inline base64 screenshot from backend evidence');
553
+ content.push(imageContentBlock(evidenceScreenshot, 'image/png'));
554
+ screenshotEmbedded = true;
555
+ }
556
+ }
508
557
  // subworkflow.run carries screenshotB64 directly — no fetch needed
509
558
  const screenshotB64 = subworkflowNode?.outputData?.screenshotB64;
510
- if (typeof screenshotB64 === 'string' && screenshotB64) {
559
+ if (!screenshotEmbedded && !screenshotUrl && typeof screenshotB64 === 'string' && screenshotB64) {
511
560
  logger.info('Embedding inline base64 screenshot from subworkflow.run');
512
561
  content.push(imageContentBlock(screenshotB64, 'image/png'));
513
562
  screenshotEmbedded = true;
514
563
  }
515
- let screenshotUrl = null;
516
564
  for (const node of nodes) {
517
565
  const data = node.outputData ?? {};
518
566
  if (!screenshotEmbedded && !screenshotUrl) {
@@ -573,7 +621,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
573
621
  throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
574
622
  }
575
623
  finally {
576
- process.stdin.removeListener('close', onStdinClose);
624
+ if (requestSignal)
625
+ requestSignal.removeEventListener('abort', onAbort);
577
626
  // Tunnel is intentionally NOT torn down here — tunnelManager reuses it on
578
627
  // subsequent calls to the same port and auto-shutoffs after 55 min idle.
579
628
  // Process-exit cleanup happens via stopAllTunnels() in the SIGINT/SIGTERM
package/dist/index.js CHANGED
@@ -90,7 +90,7 @@ export function buildConfiguredServer() {
90
90
  * singleton in main(), and once per request by the HTTP transport (stateless).
91
91
  */
92
92
  function registerHandlers(srv) {
93
- srv.setRequestHandler(CallToolRequestSchema, async (req) => {
93
+ srv.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
94
94
  const typedReq = req;
95
95
  const requestId = `req_${Date.now()}`;
96
96
  const requestLogger = logger.child({ requestId });
@@ -123,6 +123,9 @@ function registerHandlers(srv) {
123
123
  progressToken: typeof progressToken === 'string' ? progressToken : undefined,
124
124
  requestId,
125
125
  timestamp: new Date(),
126
+ // Cancellation source for long-running handlers: the SDK aborts this
127
+ // signal when the client cancels the call or the transport closes.
128
+ signal: extra?.signal,
126
129
  };
127
130
  const progressCallback = createProgressCallback(srv, typeof progressToken === 'string' || typeof progressToken === 'number' ? String(progressToken) : undefined);
128
131
  requestLogger.info(`Executing tool: ${name}`);
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * Project Context Service
3
- * At startup: detect repo → resolve project → fetch environments + credentials.
4
- * Exposes the result so tool descriptions can be enriched dynamically.
3
+ * At startup (lazily, on first tool call): detect repo → resolve project →
4
+ * fetch environments + credential LABELS. Exposes the result so tool
5
+ * descriptions can be enriched dynamically (see tools/index.ts).
6
+ *
7
+ * Thin relay: we consume the backend's environment/credential contract and
8
+ * surface LABELS ONLY — never a password or any other secret. The
9
+ * backend→internal field mapping lives in ONE place: mapEnvironments().
5
10
  */
6
11
  import { config } from '../config/index.js';
7
12
  import { DebuggAIServerClient } from './index.js';
@@ -10,14 +15,54 @@ import { Logger } from '../utils/logger.js';
10
15
  const logger = new Logger({ module: 'projectContext' });
11
16
  let cached = null;
12
17
  let inFlight = null;
18
+ const STARTUP_TIMEOUT_MS = 10_000;
13
19
  /**
14
- * Resolve the current project context: repo project environments → credentials.
20
+ * The ONE place the backend environment/credential contract is mapped to our
21
+ * internal shape. Pinned contract (backend epic sentinal-k8x1f.8):
22
+ *
23
+ * GET /api/v1/environments/?project=<uuid>
24
+ * → [{ uuid, name, url, is_default, is_active, endpoint_type,
25
+ * credentials: [{ label, username }] }]
26
+ *
27
+ * (The axios transport auto-converts snake_case → camelCase, so we read
28
+ * `isDefault`/`isActive`/`endpointType` here.) Password is write-only and
29
+ * NEVER returned; we defensively copy only label/username (+ uuid/role if the
30
+ * backend ever includes them) — we never spread a raw credential object, so a
31
+ * secret can't leak into a tool description even if the contract regresses.
32
+ */
33
+ export function mapEnvironments(rawEnvs) {
34
+ return (rawEnvs ?? [])
35
+ // Surface only active environments (is_active !== false).
36
+ .filter((e) => e?.isActive !== false)
37
+ .map((env) => ({
38
+ uuid: env.uuid,
39
+ name: env.name,
40
+ url: env.url || env.activeUrl || '',
41
+ isDefault: env.isDefault ?? false,
42
+ isActive: env.isActive ?? true,
43
+ endpointType: env.endpointType ?? null,
44
+ credentials: (env.credentials ?? [])
45
+ .filter((c) => c && c.isActive !== false)
46
+ .map((c) => ({
47
+ label: c.label || c.username,
48
+ username: c.username,
49
+ ...(c.uuid ? { uuid: c.uuid } : {}),
50
+ ...(c.role !== undefined ? { role: c.role } : {}),
51
+ environmentName: env.name,
52
+ environmentUuid: env.uuid,
53
+ })),
54
+ }));
55
+ }
56
+ /**
57
+ * Resolve the current project context: repo → project → environments →
58
+ * credential labels.
15
59
  *
16
60
  * Caches the first successful result. Concurrent calls share a single in-flight
17
- * promise. Failures are NOT cached — the next call will retry — so a transient
18
- * network error on the first tool call doesn't permanently disable the service.
61
+ * promise. Failures are NOT cached — the next call retries — so a transient
62
+ * network error on the first tool call doesn't permanently disable enrichment.
63
+ * The whole resolution is bounded by STARTUP_TIMEOUT_MS so a slow/hung backend
64
+ * can never block boot or a tool call.
19
65
  */
20
- const STARTUP_TIMEOUT_MS = 10_000;
21
66
  export async function resolveProjectContext() {
22
67
  if (cached)
23
68
  return cached;
@@ -67,40 +112,15 @@ async function resolveProjectContextInner(repoName) {
67
112
  return null;
68
113
  }
69
114
  logger.info(`Resolved project: ${project.name} (${project.uuid})`);
70
- // Fetch environments for this project
71
- const envResponse = await client.tx.get(`api/v1/projects/${project.uuid}/environments/`);
72
- const rawEnvs = envResponse?.results ?? [];
73
- // Fetch credentials for each environment in parallel
74
- const environments = await Promise.all(rawEnvs
75
- .filter((e) => e.isActive)
76
- .map(async (env) => {
77
- let credentials = [];
78
- try {
79
- const credResponse = await client.tx.get(`api/v1/projects/${project.uuid}/environments/${env.uuid}/credentials/`);
80
- credentials = (credResponse?.results ?? [])
81
- .filter((c) => c.isActive)
82
- .map((c) => ({
83
- uuid: c.uuid,
84
- label: c.label || c.username,
85
- username: c.username,
86
- role: c.role,
87
- environmentName: env.name,
88
- environmentUuid: env.uuid,
89
- }));
90
- }
91
- catch {
92
- // Some environments may not support credentials
93
- }
94
- return {
95
- uuid: env.uuid,
96
- name: env.name,
97
- url: env.url || env.activeUrl || '',
98
- credentials,
99
- };
100
- }));
115
+ // Pinned contract: environments (with credentials inlined) for the project.
116
+ const envResponse = await client.tx.get('api/v1/environments/', {
117
+ project: project.uuid,
118
+ });
119
+ const rawEnvs = Array.isArray(envResponse) ? envResponse : (envResponse?.results ?? []);
120
+ const environments = mapEnvironments(rawEnvs);
101
121
  cached = { repoName, project, environments };
102
122
  const totalCreds = environments.reduce((n, e) => n + e.credentials.length, 0);
103
- logger.info(`Project context ready: ${environments.length} environments, ${totalCreds} credentials`);
123
+ logger.info(`Project context ready: ${environments.length} environments, ${totalCreds} credential labels`);
104
124
  return cached;
105
125
  }
106
126
  catch (err) {
@@ -111,3 +131,8 @@ async function resolveProjectContextInner(repoName) {
111
131
  export function getProjectContext() {
112
132
  return cached;
113
133
  }
134
+ /** Test-only: reset the module cache so each test starts clean. */
135
+ export function __resetProjectContextForTests() {
136
+ cached = null;
137
+ inFlight = null;
138
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Verdict adapter (bead 56kd.2).
3
+ *
4
+ * THE ONE place that maps the backend explicit-verdict + budget + evidence
5
+ * contract onto the MCP relay fields. If the backend's final field shape
6
+ * differs, re-align it here and nowhere else.
7
+ *
8
+ * Backend contract (camelCase after axiosTransport conversion). The containers
9
+ * are TOP-LEVEL siblings of `state` on the execution-detail response:
10
+ * execution.verdict: { outcome: pass|fail|inconclusive|error|timeout, reason }
11
+ * execution.budget: { maxSteps, usedSteps }
12
+ * execution.evidence: { screenshot, actionTrace }
13
+ * `verdict` is SINGULAR — distinct from the pre-existing plural `verdicts`
14
+ * (RunVerdict array) and the raw `outcome` string, neither of which we read.
15
+ *
16
+ * Principle: relay, never invent.
17
+ * - `verdict.outcome` is relayed VERBATIM; `success` = (outcome === 'pass').
18
+ * - Anything else (fail/inconclusive/error/timeout) → success:false with
19
+ * `failureCategory = outcome` — NOT a fabricated 'assertion-mismatch'.
20
+ * - A missing / null / unrecognized verdict maps to `inconclusive`, never to
21
+ * `fail` and never to the raw execution status.
22
+ * - Budget comes from the response, not a client-side constant (the constant
23
+ * is only a fallback for pre-contract backends).
24
+ */
25
+ /** The verdict enum the backend emits (bead sentinal-k8x1f.2). */
26
+ export const KNOWN_OUTCOMES = ['pass', 'fail', 'inconclusive', 'error', 'timeout'];
27
+ const KNOWN = new Set(KNOWN_OUTCOMES);
28
+ /**
29
+ * Map a workflow execution onto the MCP relay verdict. Never throws.
30
+ */
31
+ export function adaptVerdict(execution, opts = {}) {
32
+ const state = execution?.state ?? null;
33
+ // Contract containers live at the TOP LEVEL of the execution response
34
+ // (siblings of `state`), NOT nested under state.
35
+ const verdict = execution?.verdict ?? null;
36
+ const budget = execution?.budget ?? null;
37
+ const evidence = execution?.evidence ?? null;
38
+ // --- Outcome (verbatim relay, inconclusive on anything unrecognized) ---
39
+ let outcome;
40
+ if (opts.outcomeOverride) {
41
+ outcome = opts.outcomeOverride;
42
+ }
43
+ else {
44
+ // Prefer the explicit verdict; fall back to the legacy per-run outcome
45
+ // field; NEVER fall back to execution.status. Unknown values → inconclusive.
46
+ const raw = verdict?.outcome ?? state?.outcome;
47
+ outcome = typeof raw === 'string' && KNOWN.has(raw) ? raw : 'inconclusive';
48
+ }
49
+ const success = outcome === 'pass';
50
+ // --- Budget (from the response; constant is a fallback only) ---
51
+ const fallbackBudget = opts.fallbackBudget ?? 0;
52
+ const stepsBudget = typeof budget?.maxSteps === 'number' ? budget.maxSteps : fallbackBudget;
53
+ const stepsTaken = typeof budget?.usedSteps === 'number'
54
+ ? budget.usedSteps
55
+ : (typeof state?.stepsTaken === 'number' ? state.stepsTaken : 0);
56
+ const stepsRemaining = Math.max(0, stepsBudget - stepsTaken);
57
+ const relay = {
58
+ outcome,
59
+ success,
60
+ stepsBudget,
61
+ stepsTaken,
62
+ stepsRemaining,
63
+ };
64
+ if (!success)
65
+ relay.failureCategory = outcome;
66
+ if (typeof verdict?.reason === 'string' && verdict.reason)
67
+ relay.reason = verdict.reason;
68
+ if (typeof evidence?.screenshot === 'string' && evidence.screenshot)
69
+ relay.screenshot = evidence.screenshot;
70
+ if (Array.isArray(evidence?.actionTrace))
71
+ relay.actionTrace = evidence.actionTrace;
72
+ return relay;
73
+ }
@@ -12,6 +12,22 @@ const POLL_INTERVAL_INITIAL_MS = 1000;
12
12
  const POLL_INTERVAL_MAX_MS = 5000;
13
13
  const POLL_BACKOFF_MULTIPLIER = 1.5;
14
14
  const EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
15
+ /**
16
+ * Stable dispatch slug for the App Evaluation workflow (bug clka). Dispatch
17
+ * pins to this slug so a backend display-name change never breaks
18
+ * check_app_in_browser. Backend contract sentinal-k8x1f.8 exposes the slug on
19
+ * template results; until that deploy lands the resolver falls back to a
20
+ * name-substring search (see EVAL_TEMPLATE_NAME_FALLBACK).
21
+ *
22
+ * Override with the DEBUGGAI_EVAL_TEMPLATE env var to pin a different slug.
23
+ */
24
+ export const EVAL_TEMPLATE_SLUG = 'flow/e2es/app-eval';
25
+ /** Interim name keyword used ONLY when the backend hasn't exposed slugs yet. */
26
+ export const EVAL_TEMPLATE_NAME_FALLBACK = 'app evaluation workflow';
27
+ /** The slug the eval-template dispatch pins to (env-overridable). */
28
+ export function getEvalTemplateSlug() {
29
+ return process.env.DEBUGGAI_EVAL_TEMPLATE || EVAL_TEMPLATE_SLUG;
30
+ }
15
31
  export const createWorkflowsService = (tx) => {
16
32
  const service = {
17
33
  async findTemplateByName(keyword) {
@@ -41,11 +57,37 @@ export const createWorkflowsService = (tx) => {
41
57
  `Available templates: ${seenNames.map(n => `"${n}"`).join(', ')}. ` +
42
58
  `Ensure the backend has a template with "${keyword}" in its name.`);
43
59
  },
60
+ async findTemplateBySlug(slug) {
61
+ // Pin dispatch to the stable slug (bug clka). We pass `slug` as a query
62
+ // param so a slug-aware backend can filter server-side; on backends that
63
+ // ignore it we still walk the pages and match client-side on the `slug`
64
+ // field. Returns null if NO result carries a slug field (backend hasn't
65
+ // deployed the slug contract yet) — the caller then falls back to name
66
+ // search. `next`-paging mirrors findTemplateByName for the same reason
67
+ // (the backend caps page size).
68
+ const MAX_PAGES = 50;
69
+ for (let page = 1; page <= MAX_PAGES; page++) {
70
+ const response = await tx.get('api/v1/workflows/', { isTemplate: true, slug, page });
71
+ const templates = response?.results ?? [];
72
+ for (const t of templates) {
73
+ if (t.slug === slug)
74
+ return t;
75
+ }
76
+ if (!response?.next)
77
+ break;
78
+ }
79
+ return null;
80
+ },
44
81
  async findEvaluationTemplate() {
45
- // 'app evaluation workflow' is specific enough to skip 'App Evaluation Brain'
46
- // (subworkflow, no browser lifecycle) which also contains 'app evaluation'.
47
- const keyword = process.env.DEBUGGAI_EVAL_TEMPLATE || 'app evaluation workflow';
48
- return service.findTemplateByName(keyword);
82
+ // Primary: resolve by the stable slug so a backend rename can't break us.
83
+ const slug = getEvalTemplateSlug();
84
+ const bySlug = await service.findTemplateBySlug(slug);
85
+ if (bySlug)
86
+ return bySlug;
87
+ // Fallback (interim, until backend contract sentinal-k8x1f.8 lands): the
88
+ // name is specific enough to skip 'App Evaluation Brain' (subworkflow, no
89
+ // browser lifecycle) which also contains 'app evaluation'.
90
+ return service.findTemplateByName(EVAL_TEMPLATE_NAME_FALLBACK);
49
91
  },
50
92
  async executeWorkflow(workflowUuid, contextData, env) {
51
93
  const body = { contextData };
@@ -105,11 +147,16 @@ export const createWorkflowsService = (tx) => {
105
147
  const pollStart = Date.now();
106
148
  let pollCount = 0;
107
149
  let intervalMs = POLL_INTERVAL_INITIAL_MS;
150
+ // Track the most recent observation so a poll-deadline timeout can return
151
+ // partial results (screenshot + trace) instead of discarding them (bead
152
+ // 56kd.3).
153
+ let lastExecution;
108
154
  while (Date.now() < deadline) {
109
155
  if (signal?.aborted) {
110
156
  throw new Error(`Polling cancelled for execution ${executionUuid}`);
111
157
  }
112
158
  const execution = await service.getExecution(executionUuid);
159
+ lastExecution = execution;
113
160
  pollCount++;
114
161
  if (onUpdate) {
115
162
  await onUpdate(execution).catch(() => { });
@@ -147,6 +194,23 @@ export const createWorkflowsService = (tx) => {
147
194
  // past terminal-state achievement on the longest runs.
148
195
  intervalMs = Math.min(Math.round(intervalMs * POLL_BACKOFF_MULTIPLIER), POLL_INTERVAL_MAX_MS);
149
196
  }
197
+ // Deadline hit. Return the last observed execution flagged `timedOut` so
198
+ // the handler shapes a partial 'timeout' result (bead 56kd.3) rather than
199
+ // discarding the screenshot + trace we already captured. Only throw if we
200
+ // never observed anything to shape.
201
+ if (lastExecution) {
202
+ Telemetry.capture(TelemetryEvents.WORKFLOW_EXECUTED, {
203
+ status: lastExecution.status,
204
+ success: false,
205
+ outcome: 'timeout',
206
+ stepsTaken: lastExecution.state?.stepsTaken ?? 0,
207
+ durationMs: Date.now() - pollStart,
208
+ pollCount,
209
+ finalIntervalMs: intervalMs,
210
+ timedOut: true,
211
+ });
212
+ return { ...lastExecution, timedOut: true };
213
+ }
150
214
  throw new Error(`Execution ${executionUuid} timed out after ${EXECUTION_TIMEOUT_MS / 1000}s`);
151
215
  }
152
216
  };
@@ -6,9 +6,36 @@ import { buildEnvironmentTool, buildValidatedEnvironmentTool } from './environme
6
6
  import { buildExecutionsTool, buildValidatedExecutionsTool } from './executions.js';
7
7
  import { buildTestSuiteTool, buildValidatedTestSuiteTool } from './testSuite.js';
8
8
  import { buildTestCaseTool, buildValidatedTestCaseTool } from './testCase.js';
9
+ import { resolveProjectContext } from '../services/projectContext.js';
9
10
  let _tools = null;
10
11
  let _validatedTools = null;
11
12
  const toolRegistry = new Map();
13
+ let enrichmentTriggered = false;
14
+ /**
15
+ * Lazily resolve project → environments → credential labels ON FIRST USE and
16
+ * rebuild the tool definitions so the enriched description (available
17
+ * environments + credential labels) is served on the next tools/list.
18
+ *
19
+ * Fire-and-forget: this never blocks the triggering call. resolveProjectContext
20
+ * has its own 10s timeout, so boot and the first tool call are never gated on a
21
+ * slow backend. Idempotent — the first invocation wins; the cached context
22
+ * makes subsequent resolves free.
23
+ */
24
+ function triggerEnrichmentOnce() {
25
+ if (enrichmentTriggered)
26
+ return;
27
+ enrichmentTriggered = true;
28
+ resolveProjectContext()
29
+ .then((ctx) => {
30
+ // Only rebuild when we actually resolved a linked project; otherwise the
31
+ // base description stays (no project detected / not linked).
32
+ if (ctx)
33
+ initTools(ctx);
34
+ })
35
+ .catch(() => {
36
+ // Best-effort enrichment — a failure leaves the base description in place.
37
+ });
38
+ }
12
39
  /**
13
40
  * Initialize tools with project context (call once after resolveProjectContext).
14
41
  *
@@ -46,10 +73,12 @@ export function initTools(ctx) {
46
73
  export function getTools() {
47
74
  if (!_tools)
48
75
  initTools(null);
76
+ triggerEnrichmentOnce();
49
77
  return _tools;
50
78
  }
51
79
  export function getTool(name) {
52
80
  if (!_validatedTools)
53
81
  initTools(null);
82
+ triggerEnrichmentOnce();
54
83
  return toolRegistry.get(name);
55
84
  }
@@ -26,12 +26,16 @@ export function buildToolDescription(ctx) {
26
26
  `\nAVAILABLE ENVIRONMENTS & CREDENTIALS (pass environmentId + credentialId for authenticated testing):`,
27
27
  ];
28
28
  for (const env of envsWithCreds) {
29
- lines.push(`\n Environment: "${env.name}" (${env.uuid})${env.url ? ` ${env.url}` : ''}`);
29
+ const defaultTag = env.isDefault ? ' [default]' : '';
30
+ lines.push(`\n Environment: "${env.name}" (${env.uuid})${defaultTag}${env.url ? ` — ${env.url}` : ''}`);
30
31
  for (const cred of env.credentials) {
31
- const parts = [` - "${cred.label}" (${cred.uuid}) user: ${cred.username}`];
32
+ // The backend contract surfaces credential LABELS (never passwords). The
33
+ // uuid/role are optional — shown only when the backend includes them.
34
+ const idPart = cred.uuid ? ` (${cred.uuid})` : '';
35
+ let line = ` - "${cred.label}"${idPart} — user: ${cred.username}`;
32
36
  if (cred.role)
33
- parts[0] += `, role: ${cred.role}`;
34
- lines.push(parts[0]);
37
+ line += `, role: ${cred.role}`;
38
+ lines.push(line);
35
39
  }
36
40
  }
37
41
  lines.push(`\nTo use: pass environmentId and credentialId from above. Or provide username/password directly.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {