@olib-ai/owl-browser-sdk 2.0.9 → 2.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.
@@ -6,9 +6,64 @@
6
6
  */
7
7
  import * as fs from 'node:fs';
8
8
  import { ConditionOperator } from '../types.js';
9
- import { resolveVariables } from './variables.js';
9
+ import { resolveValue, resolveVariables } from './variables.js';
10
10
  import { checkExpectation } from './expectations.js';
11
11
  import { evaluateCondition } from './conditions.js';
12
+ import { applyExtract } from './extraction.js';
13
+ import { makeEvent } from './notifications.js';
14
+ class TimeoutAbort extends Error {
15
+ constructor() {
16
+ super('step timeout');
17
+ this.name = 'TimeoutAbort';
18
+ }
19
+ }
20
+ function sleep(ms) {
21
+ if (ms <= 0)
22
+ return Promise.resolve();
23
+ return new Promise((r) => setTimeout(r, ms));
24
+ }
25
+ function retryDelayMs(attempt, baseMs, maxMs) {
26
+ if (baseMs <= 0)
27
+ return 0;
28
+ let delay = baseMs * Math.pow(2, attempt);
29
+ if (maxMs > 0 && delay > maxMs)
30
+ delay = maxMs;
31
+ return delay;
32
+ }
33
+ /**
34
+ * Resolve any ${prev}/${vars}/${params} references inside an expectation
35
+ * payload. Returns a new StepExpectation with string fields run through
36
+ * the resolver; numeric/boolean fields are passed through unchanged.
37
+ */
38
+ function resolveExpectation(expected, previousResult, vars_, params_) {
39
+ const resolved = { ...expected };
40
+ if (expected.equals !== undefined) {
41
+ resolved.equals = resolveValue(expected.equals, previousResult, vars_, params_);
42
+ }
43
+ if (expected.contains !== undefined) {
44
+ resolved.contains = resolveValue(expected.contains, previousResult, vars_, params_);
45
+ }
46
+ if (expected.matches !== undefined) {
47
+ resolved.matches = resolveValue(expected.matches, previousResult, vars_, params_);
48
+ }
49
+ if (expected.oneOf !== undefined) {
50
+ resolved.oneOf = resolveValue(expected.oneOf, previousResult, vars_, params_);
51
+ }
52
+ return resolved;
53
+ }
54
+ async function runWithTimeout(fn, timeoutMs) {
55
+ let timer;
56
+ const timeoutPromise = new Promise((_, reject) => {
57
+ timer = setTimeout(() => reject(new TimeoutAbort()), timeoutMs);
58
+ });
59
+ try {
60
+ return await Promise.race([fn(), timeoutPromise]);
61
+ }
62
+ finally {
63
+ if (timer)
64
+ clearTimeout(timer);
65
+ }
66
+ }
12
67
  /**
13
68
  * Parameter aliases for flow JSON compatibility.
14
69
  * Maps tool_name -> {alias_name -> canonical_name}
@@ -52,12 +107,6 @@ function applyParameterAliases(toolName, params) {
52
107
  function generateStepId() {
53
108
  return 'step_' + Math.random().toString(36).substring(2, 10);
54
109
  }
55
- /**
56
- * Sleep for a given number of milliseconds.
57
- */
58
- function sleep(ms) {
59
- return new Promise(resolve => setTimeout(resolve, ms));
60
- }
61
110
  /**
62
111
  * Flow execution engine that runs a series of browser tool steps.
63
112
  *
@@ -92,9 +141,31 @@ export class FlowExecutor {
92
141
  _client;
93
142
  _contextId;
94
143
  _abortFlag = false;
95
- constructor(client, contextId) {
144
+ /**
145
+ * Flow-scoped variables captured by `step.capture = "name"`. Resolved via
146
+ * `${vars.NAME}` in any later step's params, regardless of how many
147
+ * steps separate the capture from the use.
148
+ */
149
+ _vars = {};
150
+ /**
151
+ * Flow-scoped read-only parameters. Seeded from `flow.parameters`, may be
152
+ * overridden via setParams() (runner CLI). Resolved via `${params.NAME}`.
153
+ */
154
+ _params = {};
155
+ _notifier;
156
+ _eventMetadata = {};
157
+ constructor(client, contextId, options) {
96
158
  this._client = client;
97
159
  this._contextId = contextId;
160
+ this._notifier = options?.notifier;
161
+ }
162
+ /** Attach metadata to every FlowEvent this executor emits. */
163
+ setEventMetadata(metadata) {
164
+ this._eventMetadata = { ...metadata };
165
+ }
166
+ /** Override flow parameters; merged with declared defaults at execute() time. */
167
+ setParams(params) {
168
+ Object.assign(this._params, params);
98
169
  }
99
170
  /**
100
171
  * Signal to abort the current flow execution.
@@ -103,42 +174,140 @@ export class FlowExecutor {
103
174
  this._abortFlag = true;
104
175
  }
105
176
  /**
106
- * Reset the abort flag for a new execution.
177
+ * Reset the abort flag and clear flow-scoped vars for a new execution.
107
178
  */
108
179
  reset() {
109
180
  this._abortFlag = false;
181
+ this._vars = {};
182
+ this._params = {};
110
183
  }
111
184
  /**
112
185
  * Execute a flow and return the results.
186
+ *
187
+ * Honours the flow's top-level `requiredEnv` and `timeoutSeconds`
188
+ * directly — embedders that drive `FlowExecutor` from their own code
189
+ * get the same gates the bundled runner provides, without having to
190
+ * reimplement them. When `requiredEnv` is unmet, the executor returns
191
+ * `success: true, skipped: true` and emits `flow.skipped` (no
192
+ * `flow.started` is emitted in that case so subscribers never see a
193
+ * started event without a terminal counterpart).
113
194
  */
114
195
  async execute(flow) {
115
196
  this._abortFlag = false;
197
+ this._vars = {};
198
+ // Seed parameter scope: flow defaults first, then any setParams() overrides.
199
+ const merged = { ...(flow.parameters ?? {}), ...this._params };
200
+ this._params = merged;
116
201
  const startTime = performance.now();
117
202
  const results = [];
118
- const enabledSteps = flow.steps.filter(s => s.enabled);
119
- if (enabledSteps.length === 0) {
120
- return {
203
+ // Pre-flight: requiredEnv. If any declared env var is missing or
204
+ // empty, skip the flow with a clean reason instead of running it
205
+ // blind and failing mid-step. Emit `flow.skipped`; no `flow.started`
206
+ // first because there's no terminal event to pair with.
207
+ const missingEnv = (flow.requiredEnv ?? []).filter((n) => !process.env[n]);
208
+ if (missingEnv.length > 0) {
209
+ const reason = `missing required env vars: ${missingEnv.join(', ')}`;
210
+ const skipped = {
121
211
  success: true,
122
212
  steps: [],
123
213
  totalDurationMs: 0,
214
+ error: reason,
215
+ skipped: true,
124
216
  };
217
+ await this._emitSkipped(flow, reason);
218
+ return skipped;
219
+ }
220
+ await this._emitStarted(flow);
221
+ const enabledSteps = flow.steps.filter(s => s.enabled);
222
+ if (enabledSteps.length === 0) {
223
+ const empty = { success: true, steps: [], totalDurationMs: 0 };
224
+ await this._emitTerminal(flow, empty, startTime);
225
+ return empty;
125
226
  }
227
+ // Wall-clock cap for the entire flow. When the flow declares
228
+ // `timeoutSeconds` we race the step-loop against a timer. The
229
+ // bundled runner has its own outer cap; embedders calling execute()
230
+ // directly would otherwise have no cap.
231
+ const timeoutS = flow.timeoutSeconds && flow.timeoutSeconds > 0
232
+ ? flow.timeoutSeconds : undefined;
233
+ let final;
126
234
  try {
127
- const [success] = await this._executeSteps(enabledSteps, results, undefined);
128
- return {
235
+ let success;
236
+ if (timeoutS !== undefined) {
237
+ const [ok] = await runWithTimeout(() => this._executeSteps(enabledSteps, results, undefined), timeoutS * 1000);
238
+ success = ok;
239
+ }
240
+ else {
241
+ const [ok] = await this._executeSteps(enabledSteps, results, undefined);
242
+ success = ok;
243
+ }
244
+ final = {
129
245
  success,
130
246
  steps: results,
131
247
  totalDurationMs: performance.now() - startTime,
132
248
  };
133
249
  }
134
250
  catch (e) {
135
- return {
251
+ const isTimeout = e instanceof TimeoutAbort;
252
+ final = {
136
253
  success: false,
137
254
  steps: results,
138
255
  totalDurationMs: performance.now() - startTime,
139
- error: e instanceof Error ? e.message : String(e),
256
+ error: isTimeout
257
+ ? `flow timeout after ${timeoutS}s`
258
+ : (e instanceof Error ? e.message : String(e)),
140
259
  };
141
260
  }
261
+ await this._emitTerminal(flow, final, startTime);
262
+ return final;
263
+ }
264
+ // ------------------------------------------------------------------
265
+ // Notification emission helpers — no-op when no notifier attached.
266
+ // ------------------------------------------------------------------
267
+ async _emitStarted(flow) {
268
+ if (!this._notifier)
269
+ return;
270
+ await this._notifier.emit(makeEvent({
271
+ type: 'flow.started',
272
+ flowName: flow.name,
273
+ stepCount: flow.steps.length,
274
+ metadata: this._eventMetadata,
275
+ }));
276
+ }
277
+ /** Emit `flow.skipped` when `requiredEnv` is unmet. */
278
+ async _emitSkipped(flow, reason) {
279
+ if (!this._notifier)
280
+ return;
281
+ await this._notifier.emit(makeEvent({
282
+ type: 'flow.skipped',
283
+ flowName: flow.name,
284
+ stepCount: flow.steps.length,
285
+ error: reason,
286
+ metadata: this._eventMetadata,
287
+ }));
288
+ }
289
+ async _emitTerminal(flow, result, startTime) {
290
+ if (!this._notifier)
291
+ return;
292
+ let failedStep;
293
+ for (const r of result.steps) {
294
+ if (!r.success) {
295
+ failedStep = { index: r.stepIndex, tool: r.toolName, error: r.error };
296
+ break;
297
+ }
298
+ }
299
+ await this._notifier.emit(makeEvent({
300
+ type: result.success ? 'flow.succeeded' : 'flow.failed',
301
+ flowName: flow.name,
302
+ durationMs: performance.now() - startTime,
303
+ success: result.success,
304
+ error: result.error,
305
+ failedStep,
306
+ stepCount: flow.steps.length,
307
+ executedSteps: result.steps.length,
308
+ metadata: this._eventMetadata,
309
+ result,
310
+ }));
142
311
  }
143
312
  async _executeSteps(steps, results, previousResult) {
144
313
  let lastResult = previousResult;
@@ -147,6 +316,18 @@ export class FlowExecutor {
147
316
  return [false, lastResult];
148
317
  }
149
318
  const step = steps[i];
319
+ if (step.type === 'for_each') {
320
+ // Iterate `step.params.source` and run `step.params.body` once per
321
+ // element. See _executeForEachStep for the full schema.
322
+ const [loopOk, loopLast] = await this._executeForEachStep(step, i, lastResult, results);
323
+ if (!loopOk && !step.optional) {
324
+ return [false, lastResult];
325
+ }
326
+ if (loopOk) {
327
+ lastResult = loopLast;
328
+ }
329
+ continue;
330
+ }
150
331
  if (step.type === 'condition' && step.condition) {
151
332
  const result = await this._executeConditionStep(step, i, lastResult, results);
152
333
  if (!result.success) {
@@ -158,9 +339,22 @@ export class FlowExecutor {
158
339
  const result = await this._executeToolStep(step, i, lastResult);
159
340
  results.push(result);
160
341
  if (!result.success) {
342
+ if (step.optional) {
343
+ // Best-effort step: record the failure but don't abort the flow
344
+ // and don't propagate the error as the new lastResult (we'd
345
+ // otherwise corrupt downstream variable resolution).
346
+ continue;
347
+ }
161
348
  return [false, lastResult];
162
349
  }
163
350
  lastResult = result.result;
351
+ // Capture into flow-scoped vars so later steps can resolve
352
+ // ${vars.NAME} regardless of how many steps separate them. When
353
+ // `step.extract` is set, the captured value is the extracted form;
354
+ // raw stays on `${prev}` / `result.result`.
355
+ if (step.capture) {
356
+ this._vars[step.capture] = step.extract ? result.extracted : result.result;
357
+ }
164
358
  }
165
359
  // Small delay between steps
166
360
  if (i < steps.length - 1) {
@@ -169,6 +363,69 @@ export class FlowExecutor {
169
363
  }
170
364
  return [true, lastResult];
171
365
  }
366
+ /**
367
+ * Execute a `for_each` step: iterate an array source, run a body per item.
368
+ *
369
+ * Schema (under step.params):
370
+ * source `${...}` ref or literal array
371
+ * body array of step JSON to run per item
372
+ * item_var capture name for the current item (default 'item')
373
+ * index_var capture name for the index (default 'index')
374
+ */
375
+ async _executeForEachStep(step, stepIndex, previousResult, results) {
376
+ const sourceRaw = resolveValue(step.params['source'], previousResult, this._vars, this._params);
377
+ const bodyRaw = step.params['body'];
378
+ const itemVar = step.params['item_var'] || 'item';
379
+ const indexVar = step.params['index_var'] || 'index';
380
+ let items = [];
381
+ if (Array.isArray(sourceRaw)) {
382
+ items = sourceRaw;
383
+ }
384
+ else if (typeof sourceRaw === 'string') {
385
+ try {
386
+ const parsed = JSON.parse(sourceRaw);
387
+ items = Array.isArray(parsed) ? parsed : [];
388
+ }
389
+ catch {
390
+ items = [];
391
+ }
392
+ }
393
+ if (!Array.isArray(bodyRaw) || bodyRaw.length === 0) {
394
+ results.push({
395
+ stepIndex,
396
+ stepId: step.id,
397
+ toolName: 'for_each',
398
+ success: true,
399
+ result: { iterations: 0, items: 0, note: 'for_each body empty/invalid' },
400
+ durationMs: 0,
401
+ });
402
+ return [true, previousResult];
403
+ }
404
+ const bodySteps = bodyRaw.map((b) => FlowExecutor.parseStep(b));
405
+ let last = previousResult;
406
+ let iterations = 0;
407
+ for (let idx = 0; idx < items.length; idx++) {
408
+ if (this._abortFlag)
409
+ return [false, last];
410
+ this._vars[itemVar] = items[idx];
411
+ this._vars[indexVar] = idx;
412
+ const enabled = bodySteps.filter((s) => s.enabled);
413
+ const [iterOk, iterLast] = await this._executeSteps(enabled, results, items[idx]);
414
+ iterations++;
415
+ if (!iterOk)
416
+ return [false, iterLast];
417
+ last = iterLast;
418
+ }
419
+ results.push({
420
+ stepIndex,
421
+ stepId: step.id,
422
+ toolName: 'for_each',
423
+ success: true,
424
+ result: { iterations, items: items.length },
425
+ durationMs: 0,
426
+ });
427
+ return [true, last];
428
+ }
172
429
  async _executeConditionStep(step, stepIndex, previousResult, results) {
173
430
  const startTime = performance.now();
174
431
  if (!step.condition) {
@@ -181,7 +438,15 @@ export class FlowExecutor {
181
438
  durationMs: performance.now() - startTime,
182
439
  };
183
440
  }
184
- const conditionResult = evaluateCondition(step.condition, previousResult);
441
+ // Resolve any ${prev}/${vars}/${params} references in the condition
442
+ // value before comparing. Without this, condition.value was used
443
+ // verbatim and any ${vars.X}-style condition silently always took the
444
+ // wrong branch.
445
+ const resolvedCondition = {
446
+ ...step.condition,
447
+ value: resolveValue(step.condition.value, previousResult, this._vars, this._params),
448
+ };
449
+ const conditionResult = evaluateCondition(resolvedCondition, previousResult, results);
185
450
  const branchTaken = conditionResult ? 'true' : 'false';
186
451
  const conditionStepResult = {
187
452
  stepIndex,
@@ -223,55 +488,119 @@ export class FlowExecutor {
223
488
  }
224
489
  async _executeToolStep(step, stepIndex, previousResult) {
225
490
  const startTime = performance.now();
226
- let params = resolveVariables(step.params, previousResult);
491
+ let params = resolveVariables(step.params, previousResult, this._vars, this._params);
227
492
  params = applyParameterAliases(step.type, params);
228
- params['context_id'] = this._contextId;
229
- try {
230
- const result = await this._client.execute(step.type, params);
231
- const durationMs = performance.now() - startTime;
232
- if (step.expected) {
233
- const expectationResult = checkExpectation(result, step.expected);
234
- if (!expectationResult.passed) {
493
+ // Only inject context_id for tools that actually accept it. See the
494
+ // matching gating in the Python executor for the why.
495
+ const toolDef = this._client.getTool(step.type);
496
+ if (!toolDef || 'context_id' in toolDef.parameters) {
497
+ params['context_id'] = this._contextId;
498
+ }
499
+ const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
500
+ const backoffMs = step.retry?.backoffMs ?? 0;
501
+ const maxBackoffMs = step.retry?.maxBackoffMs ?? 0;
502
+ let lastError;
503
+ let lastExpectation;
504
+ let lastResult = undefined;
505
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
506
+ try {
507
+ if (typeof step.timeoutMs === 'number' && step.timeoutMs > 0) {
508
+ lastResult = await runWithTimeout(() => this._client.execute(step.type, params), step.timeoutMs);
509
+ }
510
+ else {
511
+ lastResult = await this._client.execute(step.type, params);
512
+ }
513
+ // Apply post-execution extraction (jsonpath or regex capture
514
+ // groups) BEFORE the expectation check so authors can `expected`
515
+ // against the extracted value via `field: "<group_name>"`.
516
+ const extracted = step.extract ? applyExtract(lastResult, step.extract) : undefined;
517
+ const extractFailed = step.extract !== undefined && extracted === undefined;
518
+ if (extractFailed) {
519
+ lastError = `Extract "${step.extract.mode}" yielded no match for pattern: ${step.extract.pattern}`;
520
+ if (attempt + 1 < maxAttempts) {
521
+ await sleep(retryDelayMs(attempt, backoffMs, maxBackoffMs));
522
+ continue;
523
+ }
235
524
  return {
236
525
  stepIndex,
237
526
  stepId: step.id,
238
527
  toolName: step.type,
239
528
  success: false,
240
- result,
241
- error: 'Expectation failed: ' + expectationResult.message,
242
- durationMs,
243
- expectationResult,
529
+ result: lastResult,
530
+ extracted,
531
+ error: lastError,
532
+ durationMs: performance.now() - startTime,
533
+ attemptsUsed: attempt + 1,
244
534
  };
245
535
  }
536
+ if (step.expected) {
537
+ // Resolve `${prev}/${vars}/${params}` references inside the
538
+ // expectation payload before checking. Without this,
539
+ // `expected: { equals: "${vars.X}" }` always failed verbatim.
540
+ const resolvedExpected = resolveExpectation(step.expected, lastResult, this._vars, this._params);
541
+ // Expectation runs against the extracted value when an extract
542
+ // block is present (authors typically expect-on the OTP / token
543
+ // they just pulled, not the wrapping payload).
544
+ const expectationTarget = step.extract ? extracted : lastResult;
545
+ lastExpectation = checkExpectation(expectationTarget, resolvedExpected);
546
+ if (!lastExpectation.passed) {
547
+ lastError = 'Expectation failed: ' + lastExpectation.message;
548
+ // Expectation failure is retryable.
549
+ if (attempt + 1 < maxAttempts) {
550
+ await sleep(retryDelayMs(attempt, backoffMs, maxBackoffMs));
551
+ continue;
552
+ }
553
+ return {
554
+ stepIndex,
555
+ stepId: step.id,
556
+ toolName: step.type,
557
+ success: false,
558
+ result: lastResult,
559
+ extracted,
560
+ error: lastError,
561
+ durationMs: performance.now() - startTime,
562
+ expectationResult: lastExpectation,
563
+ attemptsUsed: attempt + 1,
564
+ };
565
+ }
566
+ }
246
567
  return {
247
568
  stepIndex,
248
569
  stepId: step.id,
249
570
  toolName: step.type,
250
571
  success: true,
251
- result,
252
- durationMs,
253
- expectationResult,
572
+ result: lastResult,
573
+ extracted,
574
+ durationMs: performance.now() - startTime,
575
+ expectationResult: lastExpectation,
576
+ attemptsUsed: attempt + 1,
254
577
  };
255
578
  }
256
- return {
257
- stepIndex,
258
- stepId: step.id,
259
- toolName: step.type,
260
- success: true,
261
- result,
262
- durationMs,
263
- };
264
- }
265
- catch (e) {
266
- return {
267
- stepIndex,
268
- stepId: step.id,
269
- toolName: step.type,
270
- success: false,
271
- error: e instanceof Error ? e.message : String(e),
272
- durationMs: performance.now() - startTime,
273
- };
579
+ catch (e) {
580
+ if (e instanceof TimeoutAbort) {
581
+ lastError =
582
+ `Step timed out after ${step.timeoutMs}ms` +
583
+ ` (attempt ${attempt + 1}/${maxAttempts})`;
584
+ }
585
+ else {
586
+ lastError = e instanceof Error ? e.message : String(e);
587
+ }
588
+ }
589
+ if (attempt + 1 < maxAttempts) {
590
+ await sleep(retryDelayMs(attempt, backoffMs, maxBackoffMs));
591
+ }
274
592
  }
593
+ return {
594
+ stepIndex,
595
+ stepId: step.id,
596
+ toolName: step.type,
597
+ success: false,
598
+ result: lastResult,
599
+ error: lastError ?? 'step failed',
600
+ durationMs: performance.now() - startTime,
601
+ expectationResult: lastExpectation,
602
+ attemptsUsed: maxAttempts,
603
+ };
275
604
  }
276
605
  /**
277
606
  * Load a flow from a JSON file.
@@ -287,10 +616,25 @@ export class FlowExecutor {
287
616
  static parseFlow(data) {
288
617
  const stepsData = (data['steps'] ?? []);
289
618
  const steps = stepsData.map(s => FlowExecutor.parseStep(s));
619
+ const timeoutSecondsRaw = data['timeoutSeconds'] ?? data['timeout_seconds'];
620
+ const timeoutSeconds = typeof timeoutSecondsRaw === 'number' && Number.isFinite(timeoutSecondsRaw)
621
+ ? timeoutSecondsRaw
622
+ : undefined;
623
+ const requiredEnvRaw = data['requiredEnv'] ?? data['required_env'];
624
+ const requiredEnv = Array.isArray(requiredEnvRaw)
625
+ ? requiredEnvRaw
626
+ : undefined;
627
+ const parametersRaw = data['parameters'] ?? data['params'];
628
+ const parameters = parametersRaw && typeof parametersRaw === 'object' && !Array.isArray(parametersRaw)
629
+ ? parametersRaw
630
+ : undefined;
290
631
  return {
291
632
  name: String(data['name'] ?? 'Unnamed Flow'),
292
633
  description: data['description'],
293
634
  steps,
635
+ timeoutSeconds,
636
+ requiredEnv,
637
+ parameters,
294
638
  };
295
639
  }
296
640
  /**
@@ -299,11 +643,18 @@ export class FlowExecutor {
299
643
  static parseStep(data) {
300
644
  const stepId = String(data['id'] ?? generateStepId());
301
645
  const stepType = String(data['type'] ?? '');
302
- // Extract params (everything except metadata)
646
+ // Extract params (everything except metadata). Reserved step-level keys
647
+ // get stripped so they aren't sent to the http-server's strict
648
+ // tools_validate as unknown tool params.
303
649
  const params = {};
304
650
  const keysToRemove = new Set([
305
651
  'type', 'selected', 'enabled', 'expected',
306
652
  'condition', 'onTrue', 'onFalse', 'on_true', 'on_false', 'id',
653
+ // Lap-1.5 step-level extensions:
654
+ 'retry', 'timeout', 'timeoutMs', 'timeout_ms',
655
+ 'optional', 'tags', 'note',
656
+ // Lap-2 step-level extensions:
657
+ 'capture', 'extract',
307
658
  ]);
308
659
  // Only remove 'description' if this tool doesn't use it as a parameter
309
660
  if (!TOOLS_WITH_DESCRIPTION_PARAM.has(stepType)) {
@@ -327,6 +678,7 @@ export class FlowExecutor {
327
678
  notEmpty: (expData['notEmpty'] ?? expData['not_empty']),
328
679
  field: expData['field'],
329
680
  matches: expData['matches'],
681
+ oneOf: (expData['oneOf'] ?? expData['one_of']),
330
682
  };
331
683
  }
332
684
  // Parse condition
@@ -353,6 +705,21 @@ export class FlowExecutor {
353
705
  onFalse = onFalseData.map(s => FlowExecutor.parseStep(s));
354
706
  }
355
707
  const enabled = (data['enabled'] ?? data['selected'] ?? true);
708
+ // Step-level extensions: retry / timeout / optional / tags.
709
+ let retry;
710
+ if (data['retry'] && typeof data['retry'] === 'object') {
711
+ const r = data['retry'];
712
+ const maxAttempts = Number(r['maxAttempts'] ?? r['max_attempts'] ?? 1);
713
+ retry = {
714
+ maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 1,
715
+ backoffMs: Number(r['backoffMs'] ?? r['backoff_ms'] ?? 200),
716
+ maxBackoffMs: Number(r['maxBackoffMs'] ?? r['max_backoff_ms'] ?? 5000),
717
+ };
718
+ }
719
+ const timeoutMsRaw = data['timeoutMs'] ?? data['timeout_ms'];
720
+ const timeoutMs = typeof timeoutMsRaw === 'number' && Number.isFinite(timeoutMsRaw)
721
+ ? timeoutMsRaw
722
+ : undefined;
356
723
  return {
357
724
  id: stepId,
358
725
  type: stepType,
@@ -363,7 +730,30 @@ export class FlowExecutor {
363
730
  condition,
364
731
  onTrue,
365
732
  onFalse,
733
+ timeoutMs,
734
+ retry,
735
+ optional: Boolean(data['optional']),
736
+ tags: Array.isArray(data['tags']) ? data['tags'] : undefined,
737
+ capture: typeof data['capture'] === 'string' ? data['capture'] : undefined,
738
+ extract: parseExtract(data['extract']),
366
739
  };
367
740
  }
368
741
  }
742
+ function parseExtract(raw) {
743
+ if (!raw || typeof raw !== 'object')
744
+ return undefined;
745
+ const r = raw;
746
+ const mode = r['mode'];
747
+ if (mode !== 'jsonpath' && mode !== 'regex')
748
+ return undefined;
749
+ const pattern = typeof r['pattern'] === 'string' ? r['pattern'] : '';
750
+ if (!pattern)
751
+ return undefined;
752
+ return {
753
+ mode,
754
+ pattern,
755
+ on: typeof r['on'] === 'string' ? r['on'] : undefined,
756
+ flags: typeof r['flags'] === 'string' ? r['flags'] : undefined,
757
+ };
758
+ }
369
759
  //# sourceMappingURL=executor.js.map