@jobshimo/browser-link 0.25.1 → 0.26.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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * In-flight flow registry + per-tab flow history — the state behind the
3
+ * popup's Flows panel and behind `deps.shouldCancel` (see `flow.ts`).
4
+ *
5
+ * Extracted out of `background.ts` for the same reason `idle-policy.ts`,
6
+ * `reconnect-policy.ts` and `flow-recording-policy.ts` were: the service
7
+ * worker itself is untestable (it reaches for `chrome.*` at import time),
8
+ * while THIS is the part with rules worth pinning down — the lifecycle of a
9
+ * running flow, what a cancel does to an id nobody recognises, the 20-entry
10
+ * history cap, and above all WHAT A HISTORY ENTRY IS ALLOWED TO CONTAIN.
11
+ *
12
+ * PRIVACY CONTRACT (same rule as the persistent map, enforced by
13
+ * `toHistoryEntry` and asserted in the tests): a history entry holds UI
14
+ * STRUCTURE AND OUTCOMES ONLY — counts, durations, a step index, a step
15
+ * KIND. Never page text, never a selector, never typed input, never a
16
+ * failure message. Flow error strings routinely quote the page (a
17
+ * `find: multiple-matches` error carries candidate labels), which is
18
+ * exactly why the failure message is dropped here rather than trimmed: the
19
+ * operator needs to know WHICH STEP died, not what the page said.
20
+ *
21
+ * The history lives in `chrome.storage.session` (written by
22
+ * `background.ts`, read by the popup through the same runtime-message
23
+ * channel) and therefore dies with the browser session BY DESIGN. This is
24
+ * an operator view, not an audit log.
25
+ */
26
+ /** How many completed flows are kept per tab. Small on purpose — this is a
27
+ * "what just happened on this tab" panel in a 320px-wide popup, not a log. */
28
+ export const MAX_FLOW_HISTORY_PER_TAB = 20;
29
+ /** `chrome.storage.session` key holding one tab's flow history. Keyed by
30
+ * the CHROME tab id (not the browser-link `tab_N` id) because the popup
31
+ * only ever knows the former, and a tab that reconnects under a new
32
+ * browser-link id is still the same tab to the human looking at it. */
33
+ export function flowHistoryKey(tabId) {
34
+ return `flow-history:${tabId}`;
35
+ }
36
+ export function createFlowRegistry() {
37
+ const flows = new Map();
38
+ return {
39
+ register({ flowId, tabId, steps, title }) {
40
+ flows.set(flowId, {
41
+ flowId,
42
+ tabId,
43
+ title: title ?? '',
44
+ startedAt: Date.now(),
45
+ steps,
46
+ cancelled: false,
47
+ // Step 0 of N until the runner reports its first step — "not
48
+ // started yet" rather than a fake "step 1".
49
+ progress: { step: 0, steps },
50
+ });
51
+ },
52
+ get(flowId) {
53
+ return flows.get(flowId);
54
+ },
55
+ cancel(flowId) {
56
+ const entry = flows.get(flowId);
57
+ if (!entry || entry.cancelled)
58
+ return false;
59
+ entry.cancelled = true;
60
+ return true;
61
+ },
62
+ cancelForTab(tabId) {
63
+ let cancelled = 0;
64
+ for (const entry of flows.values()) {
65
+ if (entry.tabId !== tabId || entry.cancelled)
66
+ continue;
67
+ entry.cancelled = true;
68
+ cancelled += 1;
69
+ }
70
+ return cancelled;
71
+ },
72
+ finish(flowId) {
73
+ const entry = flows.get(flowId);
74
+ flows.delete(flowId);
75
+ return entry;
76
+ },
77
+ listRunning(now) {
78
+ const views = [];
79
+ for (const entry of flows.values()) {
80
+ views.push({
81
+ flowId: entry.flowId,
82
+ tabId: entry.tabId,
83
+ title: entry.title,
84
+ // Clamped at 0: `now` is supplied by the caller and a clock that
85
+ // stepped backwards should read "0s", never "-4s".
86
+ elapsedMs: Math.max(0, now - entry.startedAt),
87
+ steps: entry.steps,
88
+ step: entry.progress.step,
89
+ ...(entry.progress.iteration === undefined
90
+ ? {}
91
+ : { iteration: entry.progress.iteration }),
92
+ ...(entry.progress.iterations === undefined
93
+ ? {}
94
+ : { iterations: entry.progress.iterations }),
95
+ cancelling: entry.cancelled,
96
+ });
97
+ }
98
+ // Insertion order is start order (Map preserves it and a flow is
99
+ // registered exactly once), so the oldest — the one most likely to
100
+ // be the runaway — sits at the top of the panel.
101
+ return views;
102
+ },
103
+ };
104
+ }
105
+ /** Sum the completed iterations of every `repeat` step in a finished
106
+ * flow's results. Reads ONLY the numeric `iterations_completed` field off
107
+ * entries that carry one; everything else in a result entry is ignored, so
108
+ * nothing page-derived can reach the history. */
109
+ function countIterations(results) {
110
+ let total;
111
+ for (const entry of results) {
112
+ if (typeof entry !== 'object' || entry === null)
113
+ continue;
114
+ const value = entry.iterations_completed;
115
+ if (typeof value !== 'number' || !Number.isFinite(value))
116
+ continue;
117
+ total = (total ?? 0) + value;
118
+ }
119
+ return total;
120
+ }
121
+ /**
122
+ * Reduce a finished flow to a history entry — the ONE place a `FlowResult`
123
+ * is allowed to turn into stored state, and therefore the one place the
124
+ * privacy contract has to hold. Nothing is copied across by spread: every
125
+ * field is named, typed and numeric/enum.
126
+ *
127
+ * `result === null` means the flow threw instead of returning (a detached
128
+ * debugger, a closed tab). That is a `failed` outcome with no step
129
+ * attribution, which is honest — the runner never got to report one.
130
+ */
131
+ export function toHistoryEntry(entry, result, endedAt) {
132
+ const base = {
133
+ flowId: entry.flowId,
134
+ startedAt: entry.startedAt,
135
+ durationMs: Math.max(0, endedAt - entry.startedAt),
136
+ steps: entry.steps,
137
+ };
138
+ if (result === null) {
139
+ // No result to read, so fall back to the last progress report: the
140
+ // runner had announced step N, which means N-1 finished.
141
+ return { ...base, outcome: 'failed', stepsCompleted: Math.max(0, entry.progress.step - 1) };
142
+ }
143
+ if (!result.ok) {
144
+ return {
145
+ ...base,
146
+ outcome: 'failed',
147
+ stepsCompleted: result.steps_completed,
148
+ failedStep: result.failed_step,
149
+ // A fixed vocabulary from `stepKind()` ('click', 'wait_for',
150
+ // 'unknown', 'none'), never free text — see the privacy contract.
151
+ failedStepKind: result.step_kind,
152
+ };
153
+ }
154
+ const iterations = countIterations(result.results);
155
+ return {
156
+ ...base,
157
+ outcome: result.stopped_by === 'cancelled' ? 'cancelled' : 'completed',
158
+ stepsCompleted: result.steps_completed,
159
+ ...(iterations === undefined ? {} : { iterationsCompleted: iterations }),
160
+ };
161
+ }
162
+ /** Prepend a finished flow to a tab's history and enforce the cap. Newest
163
+ * first — the panel shows the most recent flow at the top, and the cap
164
+ * drops the oldest. Pure so the cap is testable without touching
165
+ * `chrome.storage`. */
166
+ export function appendFlowHistory(existing, entry) {
167
+ return [entry, ...existing].slice(0, MAX_FLOW_HISTORY_PER_TAB);
168
+ }
169
+ /** Narrow whatever `chrome.storage.session` hands back into a history
170
+ * list. Storage is not a trusted, typed channel (an older extension
171
+ * version may have written a different shape, and the value survives a
172
+ * service-worker restart), so an entry that does not carry the required
173
+ * primitives is dropped rather than rendered as `undefined`. */
174
+ export function parseFlowHistory(raw) {
175
+ if (!Array.isArray(raw))
176
+ return [];
177
+ const parsed = [];
178
+ for (const item of raw) {
179
+ if (typeof item !== 'object' || item === null)
180
+ continue;
181
+ const candidate = item;
182
+ if (typeof candidate.flowId !== 'string')
183
+ continue;
184
+ if (typeof candidate.startedAt !== 'number')
185
+ continue;
186
+ if (typeof candidate.durationMs !== 'number')
187
+ continue;
188
+ if (typeof candidate.steps !== 'number')
189
+ continue;
190
+ if (typeof candidate.stepsCompleted !== 'number')
191
+ continue;
192
+ if (candidate.outcome !== 'completed' &&
193
+ candidate.outcome !== 'cancelled' &&
194
+ candidate.outcome !== 'failed') {
195
+ continue;
196
+ }
197
+ parsed.push({
198
+ flowId: candidate.flowId,
199
+ startedAt: candidate.startedAt,
200
+ durationMs: candidate.durationMs,
201
+ outcome: candidate.outcome,
202
+ steps: candidate.steps,
203
+ stepsCompleted: candidate.stepsCompleted,
204
+ ...(typeof candidate.iterationsCompleted === 'number'
205
+ ? { iterationsCompleted: candidate.iterationsCompleted }
206
+ : {}),
207
+ ...(typeof candidate.failedStep === 'number' ? { failedStep: candidate.failedStep } : {}),
208
+ ...(typeof candidate.failedStepKind === 'string'
209
+ ? { failedStepKind: candidate.failedStepKind }
210
+ : {}),
211
+ });
212
+ }
213
+ return parsed.slice(0, MAX_FLOW_HISTORY_PER_TAB);
214
+ }
215
+ /** Compact duration for the popup: "0.4s" under a second, "12s" under a
216
+ * minute, "3m 07s" beyond. Seconds are zero-padded in the minute form so
217
+ * the column does not jitter as a flow runs. */
218
+ export function formatFlowDuration(ms) {
219
+ const safe = ms > 0 ? ms : 0;
220
+ if (safe < 1000)
221
+ return `${(safe / 1000).toFixed(1)}s`;
222
+ const totalSeconds = Math.floor(safe / 1000);
223
+ if (totalSeconds < 60)
224
+ return `${totalSeconds}s`;
225
+ const minutes = Math.floor(totalSeconds / 60);
226
+ const seconds = totalSeconds % 60;
227
+ return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
228
+ }
229
+ /** What the popup calls a flow's tab. The title is best-effort (it is
230
+ * captured asynchronously at flow start and can legitimately be empty on
231
+ * a blank or still-loading tab), so the Chrome tab id is the fallback —
232
+ * an unlabelled Stop button next to an unlabelled flow would be exactly
233
+ * the wrong thing to hand someone trying to stop the right one. */
234
+ export function flowTabLabel(view) {
235
+ const trimmed = view.title.trim();
236
+ return trimmed.length > 0 ? trimmed : `Tab ${view.tabId}`;
237
+ }
238
+ /** The progress line for one running flow: "step 2/5" outside a repeat,
239
+ * "step 2/5 · iteration 12/50" inside one, "starting…" before the runner
240
+ * has reported its first step. */
241
+ export function describeFlowProgress(view) {
242
+ if (view.step < 1)
243
+ return 'starting…';
244
+ const base = `step ${view.step}/${view.steps}`;
245
+ if (view.iteration === undefined || view.iterations === undefined)
246
+ return base;
247
+ return `${base} · iteration ${view.iteration}/${view.iterations}`;
248
+ }
249
+ /** The one-line summary for a finished flow: outcome, what it got through,
250
+ * and how long it took. Structure and counts only. */
251
+ export function describeFlowHistoryEntry(entry) {
252
+ const progress = entry.iterationsCompleted === undefined
253
+ ? `${entry.stepsCompleted}/${entry.steps} steps`
254
+ : `${entry.stepsCompleted}/${entry.steps} steps · ${entry.iterationsCompleted} iterations`;
255
+ const failure = entry.outcome === 'failed' && entry.failedStep !== undefined
256
+ ? ` · failed at step ${entry.failedStep + 1}${entry.failedStepKind === undefined ? '' : ` (${entry.failedStepKind})`}`
257
+ : '';
258
+ return `${progress} · ${formatFlowDuration(entry.durationMs)}${failure}`;
259
+ }
260
+ //# sourceMappingURL=flow-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flow-registry.js","sourceRoot":"","sources":["../src/flow-registry.ts"],"names":[],"mappings":"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"}
@@ -18,6 +18,18 @@
18
18
  * target — so nothing leaks across an iteration or across the repeat
19
19
  * boundary in either direction.
20
20
  *
21
+ * Cancellation is cooperative and lives entirely in this loop: the caller
22
+ * injects `deps.shouldCancel`, and the runner checks it before dispatching
23
+ * anything — at the top of every top-level step, at the top of every
24
+ * repeat iteration, and before every inner step of a repeat body. That is
25
+ * what makes the advertised worst-case latency ONE STEP real rather than
26
+ * aspirational, and it is why cancelling means "do not dispatch step N+1"
27
+ * instead of "interrupt whatever is in flight" (an in-flight CDP command
28
+ * cannot be un-sent). A cancelled flow is a SUCCESS carrying
29
+ * `stopped_by: 'cancelled'` and every result it collected — the actions
30
+ * really happened, so reporting them as an error, or dropping them, would
31
+ * both be lies.
32
+ *
21
33
  * Deliberately decoupled from any specific transport: every side effect
22
34
  * goes through the `FlowDeps` the caller injects. The concrete wiring —
23
35
  * `background.ts` in the extension, `cdp/transport.ts` on the server —
@@ -143,6 +155,11 @@ export interface WaitForStepResult {
143
155
  matched: boolean;
144
156
  elapsed_ms: number;
145
157
  checks: number;
158
+ /** `'timeout'` on a genuine non-match. `'cancelled'` when the poll loop
159
+ * was cut short by the flow's cancellation flag — a DIFFERENT outcome
160
+ * that the step executor must not report as a timeout failure, since the
161
+ * condition was never given its full budget. Both perform* implementations
162
+ * (`background.ts`, `cdp/transport.ts`) set this. */
146
163
  reason?: string;
147
164
  }
148
165
  /** Mirrors the standalone `browser.drag` tool's params exactly (minus
@@ -240,11 +257,20 @@ export interface RepeatStepResult {
240
257
  iterations_completed: number;
241
258
  /** `condition` — `while_found` stopped matching. `max_iterations` — the
242
259
  * bound was reached with the condition still true (or with no condition
243
- * at all). A failing inner step does not produce a result at all: it
244
- * fails the whole flow, like every other step. */
245
- stopped_by: 'condition' | 'max_iterations';
246
- /** Per-iteration list of the inner steps' compacted results. */
260
+ * at all). `cancelled` — `deps.shouldCancel()` went true and the loop
261
+ * stopped dispatching. A failing inner step does not produce a result at
262
+ * all: it fails the whole flow, like every other step. */
263
+ stopped_by: 'condition' | 'max_iterations' | 'cancelled';
264
+ /** Per-iteration list of the inner steps' compacted results. Holds
265
+ * COMPLETE iterations only, so `iterations_completed` never counts an
266
+ * iteration that stopped half-way. */
247
267
  iterations: unknown[][];
268
+ /** Present ONLY when a cancellation landed part-way through an
269
+ * iteration: the inner-step results of that INCOMPLETE iteration. Those
270
+ * steps really ran, so they are never discarded — they are just kept out
271
+ * of `iterations` / `iterations_completed` so those two keep meaning
272
+ * exactly "iterations that finished". */
273
+ partial_iteration?: unknown[];
248
274
  }
249
275
  export type FlowStep = {
250
276
  find: FindStepParams;
@@ -287,6 +313,45 @@ export interface FlowDeps {
287
313
  * `browser.drag` returns there. */
288
314
  performDrag(params: DragStepParams): Promise<ActionOutcome<DragStepResult>>;
289
315
  buildRecoverySnapshot(): Promise<unknown>;
316
+ /** Cooperative cancellation. Polled — never awaited — immediately before
317
+ * each dispatch point (see the module doc comment for the exact three).
318
+ * Returning `true` ends the flow cleanly with `stopped_by: 'cancelled'`.
319
+ *
320
+ * Optional so every existing caller and test keeps working unchanged: an
321
+ * absent `shouldCancel` means "this flow cannot be cancelled", which is
322
+ * exactly the pre-cancellation behaviour.
323
+ *
324
+ * MUST be cheap and synchronous — it runs once per step. The extension
325
+ * reads a boolean off its in-flight registry entry; the cdp-direct
326
+ * transport re-checks the cdp-direct gate, which is what makes
327
+ * `browser-link cdp revoke` a real kill switch on that path. */
328
+ shouldCancel?(): boolean;
329
+ /** Progress sink, called immediately BEFORE each step is dispatched (and
330
+ * before each repeat iteration), so a consumer polling it always sees
331
+ * the step that is currently running rather than the last one that
332
+ * finished. Optional, and never awaited — a throwing or slow consumer
333
+ * must not be able to affect the flow, so implementations do the
334
+ * cheapest possible thing (the extension assigns one object onto its
335
+ * registry entry).
336
+ *
337
+ * This is what the popup's "step 3/7" / "iteration 12/50" line reads. It
338
+ * lives here rather than being derived outside because only the runner
339
+ * knows which step it is about to dispatch. */
340
+ onProgress?(progress: FlowProgress): void;
341
+ }
342
+ /** Where a running flow is, right now. Indices are 1-BASED for display —
343
+ * this shape exists to be rendered ("step 3/7"), not to index an array. */
344
+ export interface FlowProgress {
345
+ /** 1-based index of the top-level step about to be dispatched. */
346
+ step: number;
347
+ /** Total number of top-level steps in this flow. */
348
+ steps: number;
349
+ /** 1-based index of the repeat iteration about to run. Present only
350
+ * while a `repeat` step is executing. */
351
+ iteration?: number;
352
+ /** The running repeat's `max_iterations` — the upper bound, which is the
353
+ * only total a repeat has (a `while_found` repeat may stop earlier). */
354
+ iterations?: number;
290
355
  }
291
356
  export interface FlowSuccess {
292
357
  ok: true;
@@ -296,6 +361,12 @@ export interface FlowSuccess {
296
361
  * and `steps_completed` is therefore 0. Omitted on a real run, so an
297
362
  * existing consumer sees a byte-identical success shape. */
298
363
  dry_run?: true;
364
+ /** Present ONLY when `deps.shouldCancel()` ended the run. A cancelled
365
+ * flow is deliberately `ok: true`: every step in `results` really
366
+ * executed, and the ONE thing the caller must not be told is that the
367
+ * partial work did not happen. Omitted on an uncancelled run, so an
368
+ * existing consumer sees a byte-identical success shape. */
369
+ stopped_by?: 'cancelled';
299
370
  }
300
371
  export interface FlowFailure {
301
372
  ok: false;
@@ -326,6 +397,11 @@ export interface RunFlowOptions {
326
397
  * and a malformed entry must fail the flow cleanly rather than throw.
327
398
  *
328
399
  * With `options.dryRun`, the flow is validated and projected instead:
329
- * nothing is dispatched, and each entry describes what WOULD run.
400
+ * nothing is dispatched, and each entry describes what WOULD run. A dry
401
+ * run ignores `deps.shouldCancel` — there is nothing to stop.
402
+ *
403
+ * With `deps.shouldCancel`, the run can be stopped between steps. It then
404
+ * resolves as a SUCCESS carrying `stopped_by: 'cancelled'` and the results
405
+ * of everything that already ran.
330
406
  */
331
407
  export declare function runFlow(steps: readonly FlowStep[], deps: FlowDeps, options?: RunFlowOptions): Promise<FlowResult>;
@@ -18,6 +18,18 @@
18
18
  * target — so nothing leaks across an iteration or across the repeat
19
19
  * boundary in either direction.
20
20
  *
21
+ * Cancellation is cooperative and lives entirely in this loop: the caller
22
+ * injects `deps.shouldCancel`, and the runner checks it before dispatching
23
+ * anything — at the top of every top-level step, at the top of every
24
+ * repeat iteration, and before every inner step of a repeat body. That is
25
+ * what makes the advertised worst-case latency ONE STEP real rather than
26
+ * aspirational, and it is why cancelling means "do not dispatch step N+1"
27
+ * instead of "interrupt whatever is in flight" (an in-flight CDP command
28
+ * cannot be un-sent). A cancelled flow is a SUCCESS carrying
29
+ * `stopped_by: 'cancelled'` and every result it collected — the actions
30
+ * really happened, so reporting them as an error, or dropping them, would
31
+ * both be lies.
32
+ *
21
33
  * Deliberately decoupled from any specific transport: every side effect
22
34
  * goes through the `FlowDeps` the caller injects. The concrete wiring —
23
35
  * `background.ts` in the extension, `cdp/transport.ts` on the server —
@@ -169,6 +181,9 @@ async function executeLeafStep(kind, stepRecord, state, deps) {
169
181
  if (!outcome.ok)
170
182
  return { ok: false, error: outcome.error + raceHint() };
171
183
  if (!outcome.result.matched) {
184
+ if (outcome.result.reason === 'cancelled') {
185
+ return { ok: false, error: 'wait_for: cancelled', cancelled: true };
186
+ }
172
187
  // No race hint here: a wait_for step IS the recommended remedy for
173
188
  // a navigation race — a plain timeout is a genuine non-match.
174
189
  return {
@@ -358,10 +373,20 @@ async function whileFoundStillMatches(selector, deps) {
358
373
  const outcome = await deps.performWaitFor({ selector, condition: 'visible', timeout_ms: 0 });
359
374
  if (!outcome.ok)
360
375
  return { ok: false, error: outcome.error };
376
+ // A cancellation that lands DURING the probe comes back as a non-match.
377
+ // Distinguishing it matters: reporting `stopped_by: 'condition'` would
378
+ // claim the page had drained when in fact the human hit Stop.
379
+ if (!outcome.result.matched && outcome.result.reason === 'cancelled') {
380
+ return { ok: true, matched: false, cancelled: true };
381
+ }
361
382
  return { ok: true, matched: outcome.result.matched };
362
383
  }
363
- /** Run one `repeat` step to completion. */
364
- async function executeRepeatStep(raw, deps) {
384
+ /** Run one `repeat` step to completion.
385
+ *
386
+ * `position` is the enclosing step's place in the top-level sequence,
387
+ * carried in only so the per-iteration `onProgress` calls can report
388
+ * "step 2/5 · iteration 12/50" instead of losing the outer coordinate. */
389
+ async function executeRepeatStep(raw, deps, position) {
365
390
  const normalized = normalizeRepeat(raw);
366
391
  if (!normalized.ok)
367
392
  return normalized;
@@ -381,12 +406,26 @@ async function executeRepeatStep(raw, deps) {
381
406
  }
382
407
  const iterations = [];
383
408
  let stoppedBy = 'max_iterations';
409
+ /** Inner results of an iteration that a cancellation cut short. Kept
410
+ * separate from `iterations` — see `RepeatStepResult.partial_iteration`. */
411
+ let partialIteration;
384
412
  for (let iter = 0; iter < maxIterations; iter++) {
413
+ // Checked BEFORE the while_found probe, so a cancelled repeat does not
414
+ // even spend a round trip discovering whether it should have run again.
415
+ if (deps.shouldCancel?.() === true) {
416
+ stoppedBy = 'cancelled';
417
+ break;
418
+ }
419
+ deps.onProgress?.({ ...position, iteration: iter + 1, iterations: maxIterations });
385
420
  if (whileFound !== undefined) {
386
421
  const probe = await whileFoundStillMatches(whileFound, deps);
387
422
  if (!probe.ok) {
388
423
  return { ok: false, error: `repeat: while_found probe failed — ${probe.error}` };
389
424
  }
425
+ if (probe.cancelled === true) {
426
+ stoppedBy = 'cancelled';
427
+ break;
428
+ }
390
429
  if (!probe.matched) {
391
430
  stoppedBy = 'condition';
392
431
  break;
@@ -397,6 +436,15 @@ async function executeRepeatStep(raw, deps) {
397
436
  const state = { implicitTarget: undefined, lastSettleReason: undefined };
398
437
  const entries = [];
399
438
  for (let j = 0; j < innerSteps.length; j++) {
439
+ // Re-checked per INNER step, not just per iteration: one iteration
440
+ // can hold a 30s wait_for or a 30s sleep, so an iteration-only check
441
+ // would quietly turn the advertised "worst case one step" bound into
442
+ // "worst case one whole iteration".
443
+ if (deps.shouldCancel?.() === true) {
444
+ stoppedBy = 'cancelled';
445
+ partialIteration = entries;
446
+ break;
447
+ }
400
448
  const innerRaw = innerSteps[j];
401
449
  // Re-derived per iteration rather than trusted from normalizeRepeat:
402
450
  // same defense-in-depth every other step shape gets.
@@ -409,6 +457,14 @@ async function executeRepeatStep(raw, deps) {
409
457
  }
410
458
  const outcome = await executeLeafStep(innerKind, innerRaw, state, deps);
411
459
  if (!outcome.ok) {
460
+ // A step cut short mid-flight by the cancel flag is not a failure
461
+ // of that step — stop the loop the same way an iteration-boundary
462
+ // cancellation does, keeping whatever this iteration completed.
463
+ if (outcome.cancelled === true) {
464
+ stoppedBy = 'cancelled';
465
+ partialIteration = entries;
466
+ break;
467
+ }
412
468
  return {
413
469
  ok: false,
414
470
  error: `repeat: iteration ${iter}, step ${j} (${innerKind}) failed — ${outcome.error}`,
@@ -416,6 +472,8 @@ async function executeRepeatStep(raw, deps) {
416
472
  }
417
473
  entries.push(outcome.entry);
418
474
  }
475
+ if (stoppedBy === 'cancelled')
476
+ break;
419
477
  iterations.push(entries);
420
478
  if (iterationDelayMs > 0) {
421
479
  await new Promise((resolve) => {
@@ -427,8 +485,13 @@ async function executeRepeatStep(raw, deps) {
427
485
  iterations_completed: iterations.length,
428
486
  stopped_by: stoppedBy,
429
487
  iterations,
488
+ // Only when a cancellation actually interrupted an iteration mid-way;
489
+ // an empty array would still be noise, so it is omitted too.
490
+ ...(partialIteration !== undefined && partialIteration.length > 0
491
+ ? { partial_iteration: partialIteration }
492
+ : {}),
430
493
  };
431
- return { ok: true, entry };
494
+ return { ok: true, entry, ...(stoppedBy === 'cancelled' ? { cancelled: true } : {}) };
432
495
  }
433
496
  /** Total step count INCLUDING a repeat's inner steps. A 20-step ceiling
434
497
  * that only counted the outer array would let a single repeat smuggle in
@@ -503,7 +566,12 @@ async function planFlow(steps, deps) {
503
566
  * and a malformed entry must fail the flow cleanly rather than throw.
504
567
  *
505
568
  * With `options.dryRun`, the flow is validated and projected instead:
506
- * nothing is dispatched, and each entry describes what WOULD run.
569
+ * nothing is dispatched, and each entry describes what WOULD run. A dry
570
+ * run ignores `deps.shouldCancel` — there is nothing to stop.
571
+ *
572
+ * With `deps.shouldCancel`, the run can be stopped between steps. It then
573
+ * resolves as a SUCCESS carrying `stopped_by: 'cancelled'` and the results
574
+ * of everything that already ran.
507
575
  */
508
576
  export async function runFlow(steps, deps, options = {}) {
509
577
  if (!Array.isArray(steps) || steps.length === 0) {
@@ -518,6 +586,14 @@ export async function runFlow(steps, deps, options = {}) {
518
586
  const results = [];
519
587
  const state = { implicitTarget: undefined, lastSettleReason: undefined };
520
588
  for (let i = 0; i < steps.length; i++) {
589
+ // Cancellation check #1 of 3 (the others are per repeat iteration and
590
+ // per inner repeat step). Deliberately BEFORE the step-shape check, so
591
+ // a cancelled flow stops rather than failing on a malformed step it
592
+ // was never going to dispatch anyway.
593
+ if (deps.shouldCancel?.() === true) {
594
+ return { ok: true, stopped_by: 'cancelled', steps_completed: results.length, results };
595
+ }
596
+ deps.onProgress?.({ step: i + 1, steps: steps.length });
521
597
  const raw = steps[i];
522
598
  const kind = stepKind(raw);
523
599
  if (!kind) {
@@ -525,7 +601,10 @@ export async function runFlow(steps, deps, options = {}) {
525
601
  }
526
602
  const stepRecord = raw;
527
603
  if (kind === 'repeat') {
528
- const outcome = await executeRepeatStep(stepRecord.repeat, deps);
604
+ const outcome = await executeRepeatStep(stepRecord.repeat, deps, {
605
+ step: i + 1,
606
+ steps: steps.length,
607
+ });
529
608
  if (!outcome.ok) {
530
609
  return withRecoverySnapshot(deps, i, kind, outcome.error, results.length);
531
610
  }
@@ -535,10 +614,22 @@ export async function runFlow(steps, deps, options = {}) {
535
614
  state.implicitTarget = undefined;
536
615
  state.lastSettleReason = undefined;
537
616
  results.push(outcome.entry);
617
+ // A repeat that stopped because of a cancellation ends the whole
618
+ // flow — its entry is already in `results`, so the iterations it DID
619
+ // complete are reported rather than thrown away.
620
+ if (outcome.cancelled === true) {
621
+ return { ok: true, stopped_by: 'cancelled', steps_completed: results.length, results };
622
+ }
538
623
  continue;
539
624
  }
540
625
  const outcome = await executeLeafStep(kind, stepRecord, state, deps);
541
626
  if (!outcome.ok) {
627
+ // Cut short mid-step by the cancel flag (a `wait_for` poll loop that
628
+ // honoured it): a cancellation, not a step failure — and therefore
629
+ // no recovery snapshot, since nothing went wrong with the page.
630
+ if (outcome.cancelled === true) {
631
+ return { ok: true, stopped_by: 'cancelled', steps_completed: results.length, results };
632
+ }
542
633
  return withRecoverySnapshot(deps, i, kind, outcome.error, results.length);
543
634
  }
544
635
  results.push(outcome.entry);