@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.
- package/dist/agent-instructions/content.js +11 -3
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/cdp/flow.d.ts +76 -8
- package/dist/cdp/flow.js +91 -8
- package/dist/cdp/flow.js.map +1 -1
- package/dist/cdp/transport.js +42 -3
- package/dist/cdp/transport.js.map +1 -1
- package/dist/extension/background.js +201 -16
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-registry.d.ts +162 -0
- package/dist/extension/flow-registry.js +260 -0
- package/dist/extension/flow-registry.js.map +1 -0
- package/dist/extension/flow.d.ts +81 -5
- package/dist/extension/flow.js +96 -5
- package/dist/extension/flow.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +123 -0
- package/dist/extension/popup.js +138 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/messages.d.ts +28 -1
- package/dist/tools/browser-definitions.js +2 -1
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.js +51 -5
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/package.json +1 -1
|
@@ -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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"}
|
package/dist/extension/flow.d.ts
CHANGED
|
@@ -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).
|
|
244
|
-
*
|
|
245
|
-
|
|
246
|
-
|
|
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>;
|
package/dist/extension/flow.js
CHANGED
|
@@ -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
|
-
|
|
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);
|