autokap 1.0.8 → 1.1.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.
Files changed (58) hide show
  1. package/assets/skill/OPCODE-REFERENCE.md +29 -1
  2. package/assets/skill/SKILL.md +2 -1
  3. package/dist/auth-capture.js +35 -2
  4. package/dist/billing-operation-logging.d.ts +4 -3
  5. package/dist/billing-operation-logging.js +3 -2
  6. package/dist/browser.d.ts +10 -10
  7. package/dist/browser.js +32 -28
  8. package/dist/capture-encryption.d.ts +3 -1
  9. package/dist/capture-encryption.js +21 -6
  10. package/dist/capture-strategy.js +3 -2
  11. package/dist/cli-config.d.ts +2 -1
  12. package/dist/cli-config.js +51 -2
  13. package/dist/cli-contract.d.ts +5 -1
  14. package/dist/cli-contract.js +7 -1
  15. package/dist/cli-runner-local.js +16 -3
  16. package/dist/cli-runner.js +165 -18
  17. package/dist/cli.js +25 -19
  18. package/dist/clip-begin-frame-recorder.d.ts +44 -0
  19. package/dist/clip-begin-frame-recorder.js +250 -0
  20. package/dist/clip-capture-backend.d.ts +25 -0
  21. package/dist/clip-capture-backend.js +189 -0
  22. package/dist/clip-capture-loop.d.ts +61 -0
  23. package/dist/clip-capture-loop.js +111 -0
  24. package/dist/clip-frame-recorder.d.ts +63 -0
  25. package/dist/clip-frame-recorder.js +305 -0
  26. package/dist/clip-postprocess.d.ts +31 -2
  27. package/dist/clip-postprocess.js +174 -57
  28. package/dist/clip-runtime.d.ts +18 -0
  29. package/dist/clip-runtime.js +67 -0
  30. package/dist/clip-scale.d.ts +10 -0
  31. package/dist/clip-scale.js +21 -0
  32. package/dist/clip-screencast-recorder.d.ts +42 -0
  33. package/dist/clip-screencast-recorder.js +242 -0
  34. package/dist/clip-sidecar.d.ts +54 -0
  35. package/dist/clip-sidecar.js +208 -0
  36. package/dist/cost-logging.d.ts +1 -1
  37. package/dist/env-validation.js +38 -4
  38. package/dist/execution-schema.d.ts +690 -360
  39. package/dist/execution-schema.js +98 -42
  40. package/dist/execution-types.d.ts +53 -3
  41. package/dist/execution-types.js +2 -1
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +1 -0
  44. package/dist/llm-healer.d.ts +2 -10
  45. package/dist/llm-healer.js +109 -62
  46. package/dist/llm-provider.js +3 -0
  47. package/dist/opcode-actions.js +13 -0
  48. package/dist/opcode-runner.js +21 -12
  49. package/dist/program-signing.d.ts +1094 -0
  50. package/dist/program-signing.js +140 -0
  51. package/dist/provider-config.d.ts +5 -0
  52. package/dist/provider-config.js +28 -1
  53. package/dist/recovery-chain.js +40 -16
  54. package/dist/server-credit-usage.d.ts +1 -1
  55. package/dist/types.d.ts +8 -2
  56. package/dist/web-playwright-local.d.ts +31 -1
  57. package/dist/web-playwright-local.js +207 -37
  58. package/package.json +12 -2
@@ -3,19 +3,10 @@
3
3
  *
4
4
  * Last-resort recovery: when all deterministic strategies fail,
5
5
  * the healer asks an LLM to analyze the current page state and
6
- * produce a replacement opcode.
7
- *
8
- * Constraints:
9
- * - Cannot change the preset's intention
10
- * - Cannot change variant order
11
- * - Cannot invent new capture targets
12
- * - Cannot skip the failed opcode's postcondition
13
- * - Max 1 LLM call per healing attempt
14
- * - Max 3 healing attempts per run
6
+ * produce a bounded selector patch.
15
7
  */
16
- import { ExecutionOpcodeSchema } from './execution-schema.js';
17
8
  /**
18
- * The LLM Healer — a constrained agent that repairs failed opcodes.
9
+ * The LLM Healer — a constrained agent that repairs failed selectors.
19
10
  */
20
11
  export class LLMHealer {
21
12
  llmProvider;
@@ -36,34 +27,27 @@ export class LLMHealer {
36
27
  try {
37
28
  const prompt = buildHealerPrompt(context);
38
29
  const { response, llmResult } = await this.llmProvider.call(prompt, context.screenshot);
39
- // Parse the LLM response
40
- const parsed = parseHealerResponse(response, context);
30
+ const parsed = parseHealerResponse(response);
41
31
  if (!parsed) {
42
32
  return { healed: false, reason: 'healer produced invalid response', llmResult };
43
33
  }
44
34
  if (parsed.cannotHeal) {
45
35
  return { healed: false, reason: parsed.reason ?? 'healer cannot resolve this failure', llmResult };
46
36
  }
47
- // Validate replacement opcodes against schema
48
- const validOpcodes = [];
49
- for (const opcode of parsed.replacementOpcodes) {
50
- const result = ExecutionOpcodeSchema.safeParse(opcode);
51
- if (!result.success) {
52
- return { healed: false, reason: `healer produced invalid opcode: ${result.error}`, llmResult };
53
- }
54
- validOpcodes.push(result.data);
55
- }
56
- if (validOpcodes.length === 0 || validOpcodes.length > 3) {
57
- return { healed: false, reason: `healer produced ${validOpcodes.length} opcodes (expected 1-3)`, llmResult };
37
+ const replacementOpcode = buildReplacementOpcode(context.failedOpcode, parsed);
38
+ if (!replacementOpcode) {
39
+ return { healed: false, reason: 'healer could not produce a bounded selector patch', llmResult };
58
40
  }
59
41
  const patch = {
60
42
  opcodeIndex: context.opcodeIndex,
61
43
  originalOpcode: context.failedOpcode,
62
- replacementOpcodes: validOpcodes,
63
- reason: parsed.reason ?? 'healer repair',
44
+ replacementOpcodes: [replacementOpcode],
45
+ patchType: 'selector_patch',
46
+ interactionMode: parsed.interactionMode,
47
+ reason: parsed.reason ?? 'healer selector repair',
64
48
  patchedAt: new Date().toISOString(),
65
49
  };
66
- return { healed: true, patch, reason: parsed.reason ?? 'healer repair applied', llmResult };
50
+ return { healed: true, patch, reason: patch.reason, llmResult };
67
51
  }
68
52
  catch (err) {
69
53
  return {
@@ -73,48 +57,50 @@ export class LLMHealer {
73
57
  }
74
58
  }
75
59
  }
76
- // ── Prompt construction ─────────────────────────────────────────────
77
60
  function buildHealerPrompt(context) {
78
61
  const { failedOpcode, errorMessage, akTreeSerialized, currentUrl, surroundingOpcodes } = context;
62
+ const selector = hasPatchableSelector(failedOpcode) ? failedOpcode.selector : null;
79
63
  const surroundingDesc = surroundingOpcodes
80
- .map((op, i) => ` ${i + 1}. ${op.kind}: ${op.description}`)
64
+ .map((op, i) => ` ${i + 1}. ${op.kind}: ${sanitizePromptText(op.description)}`)
81
65
  .join('\n');
82
- return `You are a web automation repair agent. An opcode in a deterministic browser automation program has failed. Your job is to analyze the current page state and produce a replacement opcode that achieves the same goal.
66
+ return `You are a web automation repair agent. An opcode in a deterministic browser automation program has failed. Your job is to repair the selector only.
83
67
 
84
68
  ## Failed Opcode
85
69
  Kind: ${failedOpcode.kind}
86
- Description: ${failedOpcode.description}
87
- ${failedOpcode.kind === 'CLICK' ? `Selector: ${failedOpcode.selector}` : ''}
88
- ${failedOpcode.kind === 'TYPE' ? `Selector: ${failedOpcode.selector}, Text: ${failedOpcode.text}` : ''}
89
- ${failedOpcode.kind === 'WAIT_FOR' ? `Selector: ${failedOpcode.selector}` : ''}
70
+ Description: ${sanitizePromptText(failedOpcode.description)}
71
+ Selector: ${selector ?? '<unsupported>'}
90
72
 
91
73
  ## Error
92
- ${errorMessage}
74
+ <<<ERROR>>>
75
+ ${sanitizePromptText(errorMessage)}
76
+ <<<END_ERROR>>>
93
77
 
94
78
  ## Current Page
95
- URL: ${currentUrl}
79
+ URL: ${sanitizeUrlForPrompt(currentUrl)}
96
80
 
97
81
  ## AKTree (current page structure)
98
- ${akTreeSerialized.slice(0, 8000)}
82
+ <<<AKTREE>>>
83
+ ${sanitizePromptText(akTreeSerialized).slice(0, 8000)}
84
+ <<<END_AKTREE>>>
99
85
 
100
86
  ## Surrounding Opcodes (context)
101
87
  ${surroundingDesc}
102
88
 
103
89
  ## Rules
104
- 1. You MUST produce the same kind of opcode (${failedOpcode.kind}) or a sequence of 1-3 opcodes that achieve the same postcondition.
105
- 2. You CANNOT change the intention only fix the selector or interaction method.
106
- 3. You CANNOT invent new navigation targets or capture actions.
107
- 4. Every opcode must have a valid postcondition.
108
- 5. Use selectors you can actually see in the AKTree above.
90
+ 1. You MUST NOT emit opcodes or navigation targets.
91
+ 2. You may only return a selector patch for the existing opcode.
92
+ 3. You may optionally return a bounded interaction mode: default, keyboard, js_dispatch, or coordinates.
93
+ 4. Use selectors you can actually see in the AKTree above.
94
+ 5. If the opcode kind is unsupported or there is no reliable selector, return cannotHeal=true.
109
95
 
110
96
  ## Response Format
111
97
  Respond with a JSON object:
112
98
  {
113
99
  "cannotHeal": false,
114
100
  "reason": "explanation of what you changed",
115
- "replacementOpcodes": [
116
- { "kind": "${failedOpcode.kind}", "description": "...", "selector": "...", "postcondition": {...}, "recovery": {...}, "timeoutMs": 15000, "maxFailures": 3, ... }
117
- ]
101
+ "selector": "[data-ak='submit']",
102
+ "selectorAlternates": ["button[type='submit']"],
103
+ "interactionMode": "default"
118
104
  }
119
105
 
120
106
  Or if you cannot fix it:
@@ -125,42 +111,103 @@ Or if you cannot fix it:
125
111
 
126
112
  Respond ONLY with the JSON object, no markdown, no explanation outside JSON.`;
127
113
  }
128
- function parseHealerResponse(response, context) {
114
+ function parseHealerResponse(response) {
129
115
  try {
130
- // Extract JSON from response (handle markdown code blocks)
131
116
  const jsonStr = response
132
117
  .replace(/```json\s*/g, '')
133
118
  .replace(/```\s*/g, '')
134
119
  .trim();
135
120
  const parsed = JSON.parse(jsonStr);
136
- if (typeof parsed !== 'object' || parsed === null)
121
+ if (!parsed || typeof parsed !== 'object')
137
122
  return null;
138
123
  if (parsed.cannotHeal) {
139
124
  return {
140
125
  cannotHeal: true,
141
- reason: parsed.reason ?? null,
142
- replacementOpcodes: [],
126
+ reason: typeof parsed.reason === 'string' ? parsed.reason : null,
127
+ selector: null,
128
+ selectorAlternates: [],
129
+ interactionMode: 'default',
143
130
  };
144
131
  }
145
- if (!Array.isArray(parsed.replacementOpcodes))
132
+ const selector = typeof parsed.selector === 'string' && parsed.selector.trim()
133
+ ? parsed.selector.trim()
134
+ : null;
135
+ if (!selector)
146
136
  return null;
147
- // Inject the original postcondition and recovery into replacement opcodes
148
- // (healer cannot skip postconditions)
149
- const enriched = parsed.replacementOpcodes.map((op) => ({
150
- ...op,
151
- postcondition: op.postcondition ?? context.failedOpcode.postcondition,
152
- recovery: op.recovery ?? context.failedOpcode.recovery,
153
- timeoutMs: op.timeoutMs ?? context.failedOpcode.timeoutMs,
154
- maxFailures: op.maxFailures ?? context.failedOpcode.maxFailures,
155
- }));
156
137
  return {
157
138
  cannotHeal: false,
158
- reason: parsed.reason ?? null,
159
- replacementOpcodes: enriched,
139
+ reason: typeof parsed.reason === 'string' ? parsed.reason : null,
140
+ selector,
141
+ selectorAlternates: Array.isArray(parsed.selectorAlternates)
142
+ ? parsed.selectorAlternates
143
+ .filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
144
+ .map((entry) => entry.trim())
145
+ .slice(0, 5)
146
+ : [],
147
+ interactionMode: parsed.interactionMode === 'keyboard'
148
+ || parsed.interactionMode === 'js_dispatch'
149
+ || parsed.interactionMode === 'coordinates'
150
+ ? parsed.interactionMode
151
+ : 'default',
160
152
  };
161
153
  }
162
154
  catch {
163
155
  return null;
164
156
  }
165
157
  }
158
+ function hasPatchableSelector(opcode) {
159
+ return ((opcode.kind === 'CLICK'
160
+ || opcode.kind === 'TYPE'
161
+ || opcode.kind === 'WAIT_FOR'
162
+ || opcode.kind === 'HOVER'
163
+ || opcode.kind === 'SELECT_OPTION'
164
+ || opcode.kind === 'CHECK'
165
+ || opcode.kind === 'DOUBLE_CLICK')
166
+ && typeof opcode.selector === 'string'
167
+ && opcode.selector.trim().length > 0);
168
+ }
169
+ function buildReplacementOpcode(failedOpcode, parsed) {
170
+ if (!hasPatchableSelector(failedOpcode) || !parsed.selector) {
171
+ return null;
172
+ }
173
+ switch (failedOpcode.kind) {
174
+ case 'CLICK':
175
+ case 'TYPE':
176
+ case 'WAIT_FOR':
177
+ case 'HOVER':
178
+ case 'SELECT_OPTION':
179
+ case 'CHECK':
180
+ case 'DOUBLE_CLICK':
181
+ return {
182
+ ...failedOpcode,
183
+ selector: parsed.selector,
184
+ ...('selectorAlternates' in failedOpcode
185
+ ? { selectorAlternates: parsed.selectorAlternates }
186
+ : {}),
187
+ };
188
+ default:
189
+ return null;
190
+ }
191
+ }
192
+ function sanitizePromptText(value) {
193
+ return value
194
+ .replace(/\{\{(email|password|loginUrl)\}\}/g, '<credential-placeholder>')
195
+ .replace(/data:[^"' )]+/gi, '<data-url>')
196
+ .replace(/https?:\/\/[^\s"'<>]+/gi, (match) => sanitizeUrlForPrompt(match))
197
+ .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, ' ')
198
+ .slice(0, 12_000);
199
+ }
200
+ function sanitizeUrlForPrompt(value) {
201
+ try {
202
+ const parsed = new URL(value);
203
+ parsed.username = '';
204
+ parsed.password = '';
205
+ parsed.search = parsed.search ? '?<redacted>' : '';
206
+ parsed.hash = parsed.hash ? '#<redacted>' : '';
207
+ return parsed.toString();
208
+ }
209
+ catch {
210
+ return value.slice(0, 512);
211
+ }
212
+ }
166
213
  //# sourceMappingURL=llm-healer.js.map
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import OpenAI from 'openai';
8
8
  import { createOpenRouterCompletion } from './openrouter-client.js';
9
+ import { assertZdrCompatibleModel, zdrParam } from './provider-config.js';
9
10
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
10
11
  const DEFAULT_MODEL = 'google/gemini-2.5-flash';
11
12
  /**
@@ -19,6 +20,7 @@ export async function callLLM(config, systemPrompt, userContent, image) {
19
20
  });
20
21
  const model = config.model ?? DEFAULT_MODEL;
21
22
  const maxTokens = config.maxTokens ?? 200;
23
+ assertZdrCompatibleModel(model);
22
24
  const messages = [
23
25
  { role: 'system', content: systemPrompt },
24
26
  ];
@@ -43,6 +45,7 @@ export async function callLLM(config, systemPrompt, userContent, image) {
43
45
  messages,
44
46
  max_tokens: maxTokens,
45
47
  temperature: 0.1,
48
+ provider: Object.keys(zdrParam()).length > 0 ? zdrParam() : undefined,
46
49
  };
47
50
  const controller = new AbortController();
48
51
  const timer = setTimeout(() => controller.abort(), config.timeoutMs ?? 15000);
@@ -171,6 +171,19 @@ export async function executeOpcodeCoreAction(opcode, adapter, context = {}) {
171
171
  return { success: false, error: 'adapter does not support DOUBLE_CLICK' };
172
172
  await adapter.doubleClick(opcode.selector);
173
173
  break;
174
+ case 'DRAG':
175
+ if (!adapter.drag)
176
+ return { success: false, error: 'adapter does not support DRAG' };
177
+ await adapter.drag({
178
+ selector: opcode.selector,
179
+ target: opcode.target,
180
+ selectorAlternates: opcode.selectorAlternates,
181
+ toSelector: opcode.toSelector,
182
+ toTarget: opcode.toTarget,
183
+ toSelectorAlternates: opcode.toSelectorAlternates,
184
+ offset: opcode.offset,
185
+ });
186
+ break;
174
187
  case 'CLONE_ELEMENT':
175
188
  if (!adapter.cloneElement) {
176
189
  return { success: false, error: 'adapter does not support CLONE_ELEMENT' };
@@ -53,6 +53,13 @@ export class NoOpRecoveryChain {
53
53
  return { recovered: false, reason: 'no recovery chain configured' };
54
54
  }
55
55
  }
56
+ const MIN_CLIP_FINALIZATION_TIMEOUT_MS = 30000;
57
+ function resolveOpcodeTimeoutMs(opcode) {
58
+ if (opcode.kind === 'END_CLIP') {
59
+ return Math.max(opcode.timeoutMs, MIN_CLIP_FINALIZATION_TIMEOUT_MS);
60
+ }
61
+ return opcode.timeoutMs;
62
+ }
56
63
  // ── Main execution function ─────────────────────────────────────────
57
64
  export async function executeProgram(program, createAdapter, options = {}) {
58
65
  const recoveryChain = options.recoveryChain ?? new NoOpRecoveryChain();
@@ -238,7 +245,8 @@ function softSkipResult(opcode, index, startTime, reason, telemetry) {
238
245
  }
239
246
  async function executeOpcode(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, artifacts, options, variantId, executionState, artifactPlan, mockDataGroups, currentVariant, credentials) {
240
247
  const startTime = Date.now();
241
- const deadlineMs = startTime + opcode.timeoutMs;
248
+ const effectiveTimeoutMs = resolveOpcodeTimeoutMs(opcode);
249
+ const deadlineMs = startTime + effectiveTimeoutMs;
242
250
  const isInteraction = ['CLICK', 'TYPE', 'PRESS_KEY', 'SCROLL'].includes(opcode.kind);
243
251
  const isSoft = isSoftOpcodeKind(opcode.kind);
244
252
  // Track page context for circuit breaker
@@ -249,7 +257,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
249
257
  catch { /* ignore */ }
250
258
  // Execute with timeout
251
259
  try {
252
- logger.debug(`[opcode ${index}] ${opcode.kind} start — budget ${opcode.timeoutMs}ms${formatOpcodeDebug(opcode)}`);
260
+ logger.debug(`[opcode ${index}] ${opcode.kind} start — budget ${effectiveTimeoutMs}ms${formatOpcodeDebug(opcode)}`);
253
261
  if (isInteraction) {
254
262
  const beforeStart = Date.now();
255
263
  await verifier.captureBeforeState(adapter);
@@ -257,11 +265,11 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
257
265
  }
258
266
  const actionBudgetMs = getRemainingTimeMs(deadlineMs);
259
267
  if (actionBudgetMs <= 0) {
260
- const reason = `timeout after ${opcode.timeoutMs}ms`;
268
+ const reason = `timeout after ${effectiveTimeoutMs}ms`;
261
269
  logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${deadlineMs}, now=${Date.now()})`);
262
270
  if (isSoft)
263
271
  return softSkipResult(opcode, index, startTime, reason, telemetry);
264
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, reason);
272
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
265
273
  }
266
274
  logger.debug(`[opcode ${index}] action exec start — actionBudget ${actionBudgetMs}ms`);
267
275
  const actionStart = Date.now();
@@ -271,16 +279,16 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
271
279
  const reason = result.error ?? 'action failed';
272
280
  if (isSoft)
273
281
  return softSkipResult(opcode, index, startTime, reason, telemetry);
274
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, reason);
282
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
275
283
  }
276
284
  // Verify postcondition
277
285
  const postconditionBudgetMs = getRemainingTimeMs(deadlineMs);
278
286
  if (postconditionBudgetMs <= 0) {
279
- const reason = `timeout after ${opcode.timeoutMs}ms`;
287
+ const reason = `timeout after ${effectiveTimeoutMs}ms`;
280
288
  logger.debug(`[opcode ${index}] no budget left for postcondition check`);
281
289
  if (isSoft)
282
290
  return softSkipResult(opcode, index, startTime, reason, telemetry);
283
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, reason);
291
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
284
292
  }
285
293
  const postStart = Date.now();
286
294
  const postcondition = await evaluatePostcondition(adapter, withClampedPostconditionTimeout(opcode.postcondition, postconditionBudgetMs));
@@ -289,13 +297,13 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
289
297
  const reason = `postcondition failed: ${postcondition.reason}`;
290
298
  if (isSoft)
291
299
  return softSkipResult(opcode, index, startTime, reason, telemetry);
292
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, reason);
300
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, reason);
293
301
  }
294
302
  // Verify action had effect (for interaction opcodes)
295
303
  if (isInteraction) {
296
304
  const verification = await verifier.verifyAfterAction(adapter);
297
305
  if (!verification.hadEffect && opcode.postcondition.type !== 'always' && opcode.postcondition.type !== 'any_change') {
298
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, `action had no effect: ${verification.summary}`);
306
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, `action had no effect: ${verification.summary}`);
299
307
  }
300
308
  }
301
309
  // Record successful mock data application
@@ -317,11 +325,11 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
317
325
  const errorMsg = err instanceof Error ? err.message : String(err);
318
326
  if (isSoft)
319
327
  return softSkipResult(opcode, index, startTime, errorMsg, telemetry);
320
- return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, errorMsg);
328
+ return handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg);
321
329
  }
322
330
  }
323
331
  // ── Failure handling with recovery ──────────────────────────────────
324
- async function handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, errorMsg) {
332
+ async function handleFailure(opcode, index, adapter, verifier, isInteraction, breaker, recoveryChain, telemetry, healerPatches, options, variantId, currentVariant, startTime, deadlineMs, effectiveTimeoutMs, errorMsg) {
325
333
  const breakerState = breaker.recordFailure(index, opcode.maxFailures);
326
334
  if (breakerState.tripped) {
327
335
  telemetry.circuitBreakerTrips++;
@@ -342,7 +350,7 @@ async function handleFailure(opcode, index, adapter, verifier, isInteraction, br
342
350
  status: 'failed',
343
351
  durationMs: Date.now() - startTime,
344
352
  recoveryAttempts: 0,
345
- error: `${errorMsg} (timeout after ${opcode.timeoutMs}ms)`,
353
+ error: `${errorMsg} (timeout after ${effectiveTimeoutMs}ms)`,
346
354
  };
347
355
  }
348
356
  // Attempt recovery
@@ -424,6 +432,7 @@ async function executeOpcodeAction(opcode, opcodeIndex, adapter, artifacts, tele
424
432
  case 'SELECT_OPTION':
425
433
  case 'CHECK':
426
434
  case 'DOUBLE_CLICK':
435
+ case 'DRAG':
427
436
  case 'CLONE_ELEMENT':
428
437
  case 'INJECT_MOCK_DATA':
429
438
  case 'REMOVE_ELEMENT':