browser-broker 0.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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { updateSweptTabs } from "../arbitration.js";
|
|
2
|
+
import { append } from "../events.js";
|
|
3
|
+
import { extendLease, resolveLease } from "../leases.js";
|
|
4
|
+
import { resolveOwnedTabOrRefuse } from "../ownership.js";
|
|
5
|
+
import { disposeEvaluationResult, MAX_INLINE_RESULT_BYTES, validateCaptureMode, resolveReadArtifacts, validateAction, validateExpression, validateNavigationTarget, } from "../pages.js";
|
|
6
|
+
import { recordTabOpened, reserveTab } from "../tabs.js";
|
|
7
|
+
import { seedRecord } from "../storage-seed.js";
|
|
8
|
+
import { BrokerError } from "../../errors.js";
|
|
9
|
+
import { sanitiseLabel, stampFromInstant } from "../../artifacts/names.js";
|
|
10
|
+
import { takeCapture } from "../../capture/pipeline.js";
|
|
11
|
+
import { capturesTakenBy, recordCapture } from "../capture-store.js";
|
|
12
|
+
import { captureSource } from "../capture-seam.js";
|
|
13
|
+
import { insertComparison } from "../comparison-store.js";
|
|
14
|
+
import { runComparison } from "../comparison.js";
|
|
15
|
+
import { DEFAULT_DIFF_SETTINGS } from "../../diff/settings.js";
|
|
16
|
+
/**
|
|
17
|
+
* Steps 1 to 3, which are identical for all six and therefore written once.
|
|
18
|
+
*
|
|
19
|
+
* Written as one function rather than copied into each handler because the
|
|
20
|
+
* order is the rule: a handler that validated before renewing, or resolved
|
|
21
|
+
* the tab before validating, would refuse in a different order than every
|
|
22
|
+
* other operation, and the difference would be invisible until a caller
|
|
23
|
+
* depended on it.
|
|
24
|
+
*/
|
|
25
|
+
function admit(scope, input, kind) {
|
|
26
|
+
const { db, adapter, swept } = scope;
|
|
27
|
+
const lease = resolveLease(db, input.key, {
|
|
28
|
+
adapter,
|
|
29
|
+
kind: 'claim_renewed',
|
|
30
|
+
recordRefusal: scope.recordRefusal,
|
|
31
|
+
});
|
|
32
|
+
const expiresAt = extendLease(db, lease, { adapter, now: swept.sweptAt });
|
|
33
|
+
const tab = resolveOwnedTabOrRefuse(db, lease, input.tabId, {
|
|
34
|
+
adapter,
|
|
35
|
+
kind,
|
|
36
|
+
recordRefusal: scope.recordRefusal,
|
|
37
|
+
});
|
|
38
|
+
return { lease, tab, expiresAt };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get the page this tab names, opening it if it has never been opened.
|
|
42
|
+
*
|
|
43
|
+
* ── Why the page is opened here rather than when the lease was granted ──
|
|
44
|
+
*
|
|
45
|
+
* Granting a lease reserves a tab **row** — `opening`, with no driver name,
|
|
46
|
+
* because §1.4 requires a tab to carry a driver name only once a page
|
|
47
|
+
* genuinely exists. Nothing about granting capacity requires a page to exist
|
|
48
|
+
* yet, and it would be the wrong moment to make one: the grant happens inside
|
|
49
|
+
* the arbitration transaction, and opening a page there is precisely the
|
|
50
|
+
* browser I/O §2.4b forbids.
|
|
51
|
+
*
|
|
52
|
+
* So the page is opened the first time somebody actually addresses the tab,
|
|
53
|
+
* after that call's commit, by the caller that has the browser connection.
|
|
54
|
+
* That is also the only moment at which a session is guaranteed to be
|
|
55
|
+
* available: capacity can be granted to a caller that has not connected to
|
|
56
|
+
* anything, and refusing to grant it until one had would make the queue
|
|
57
|
+
* depend on the caller's own connection state.
|
|
58
|
+
*
|
|
59
|
+
* **The driver name is written on its own statement, outside the arbitration
|
|
60
|
+
* transaction that has already committed.** It is a single-row update against
|
|
61
|
+
* a row nobody else can address — the tab belongs to one lease, and this runs
|
|
62
|
+
* only for the caller holding that lease's key.
|
|
63
|
+
*/
|
|
64
|
+
async function pageFor(scope, session, tab, input, claimId) {
|
|
65
|
+
if (tab.driverTabId !== null) {
|
|
66
|
+
return { browser: tab.browserId, driverTabId: tab.driverTabId };
|
|
67
|
+
}
|
|
68
|
+
const opened = await session.openTab();
|
|
69
|
+
recordTabOpened(scope.db, tab.tabId, opened.driverTabId);
|
|
70
|
+
// ── The seed, here and nowhere else (§3.2, row #65) ───────────────────
|
|
71
|
+
//
|
|
72
|
+
// **This is the only moment that satisfies "before the tab's first
|
|
73
|
+
// navigation".** The page has just been created and no caller has
|
|
74
|
+
// addressed it yet — the verb that triggered this open has not run its own
|
|
75
|
+
// work, because `pageFor` is awaited first. A seed written any later would
|
|
76
|
+
// be written after the load it exists to precede, which is the whole
|
|
77
|
+
// feature; a seed written any earlier has no page to write into.
|
|
78
|
+
//
|
|
79
|
+
// It is also correctly outside the arbitration transaction: this runs
|
|
80
|
+
// inside an after-commit closure (§2.4b), which is why `openTab` above is
|
|
81
|
+
// allowed to be here at all.
|
|
82
|
+
await applyPendingSeed(scope, session, opened, tab, input, claimId);
|
|
83
|
+
return opened;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write a granted lease's seed into its brand-new page, and record what was
|
|
87
|
+
* actually written.
|
|
88
|
+
*
|
|
89
|
+
* ── Why the ledger row is written here rather than at claim time ────────
|
|
90
|
+
*
|
|
91
|
+
* The claim appends a `storage_seeded` row saying a seed was **requested**,
|
|
92
|
+
* which is the whole of what is true at that point: the claim is decided
|
|
93
|
+
* inside the arbitration transaction, and §2.4b keeps browser work outside
|
|
94
|
+
* it, so the tab is a row with no page behind it. This row says what was
|
|
95
|
+
* **applied**, and the distinction is the point: §3.2 wants *"which leases
|
|
96
|
+
* started life already holding a credential"* answerable, and a request is
|
|
97
|
+
* not an answer to that. A ledger that recorded the ask and not the act
|
|
98
|
+
* overstates, and a security question answered by an overstatement is read as
|
|
99
|
+
* an all-clear.
|
|
100
|
+
*
|
|
101
|
+
* **Origins and keys, never values**, through `seedRecord` — the same
|
|
102
|
+
* structural redaction the claim's row uses, so neither call site can leak a
|
|
103
|
+
* value by being written carelessly.
|
|
104
|
+
*
|
|
105
|
+
* ── A seed that fails is a seed that is not recorded ────────────────────
|
|
106
|
+
*
|
|
107
|
+
* The throw propagates. It is not caught here, and that is deliberate: the
|
|
108
|
+
* caller is `pageFor`, inside the after-commit closure `afterCommitWork`
|
|
109
|
+
* built, whose `catch` records the reason and rethrows for the runner to
|
|
110
|
+
* swallow (§2.4b). So a browser that refuses the write leaves the lease real,
|
|
111
|
+
* the decision committed, `pageDriven: false`, a reason the caller can read —
|
|
112
|
+
* **and no `applied` row**, because the row is written after the write
|
|
113
|
+
* returns. A caller told its page was not driven has been told its seed did
|
|
114
|
+
* not land.
|
|
115
|
+
*/
|
|
116
|
+
async function applyPendingSeed(scope, session, page, tab, input, claimId) {
|
|
117
|
+
const entries = input.pendingSeeds?.take(claimId) ?? [];
|
|
118
|
+
if (entries.length === 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// **The driver seam, with the entries as data.** `seedStorage` takes a list
|
|
122
|
+
// of origin/area/key/string — there is no position in that signature in
|
|
123
|
+
// which a caller's bytes could be read as a program, which is the entire
|
|
124
|
+
// safety argument for this feature. Nothing here builds an init script, a
|
|
125
|
+
// template or any other source text out of an entry, and doing so would
|
|
126
|
+
// rebuild the interpreting position §3.2 exists to avoid.
|
|
127
|
+
await session.seedStorage(page, entries);
|
|
128
|
+
append(scope.db, {
|
|
129
|
+
kind: 'storage_seeded',
|
|
130
|
+
outcome: 'allow',
|
|
131
|
+
adapter: scope.adapter,
|
|
132
|
+
claimId,
|
|
133
|
+
tabId: tab.tabId,
|
|
134
|
+
browserId: tab.browserId,
|
|
135
|
+
detail: { entries: seedRecord(entries), seed: 'applied' },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Schedule one piece of browser work, if the caller brought a browser.
|
|
140
|
+
*
|
|
141
|
+
* **Returns the fact alongside the work, because this is the only place that
|
|
142
|
+
* knows it.** Handing `pageDriven` back next to `afterCommit` means the report
|
|
143
|
+
* and the reality are computed once, from the same place, and no handler can
|
|
144
|
+
* spell the answer differently from the work it scheduled.
|
|
145
|
+
*
|
|
146
|
+
* ── Why this is a getter and not a plain boolean ────────────────────────
|
|
147
|
+
*
|
|
148
|
+
* It used to answer `input.session === undefined`, which was exactly right
|
|
149
|
+
* while nothing ever supplied a session: with no session there is no browser,
|
|
150
|
+
* and `false` was a fact at the moment it was computed.
|
|
151
|
+
*
|
|
152
|
+
* **The moment a session is supplied, that same expression becomes a
|
|
153
|
+
* prediction rather than a fact** — and predicting `true` before the browser
|
|
154
|
+
* has been touched reintroduces the precise defect this field was added to
|
|
155
|
+
* remove, only harder to see. Every failure in after-commit work is swallowed
|
|
156
|
+
* by design (§2.4b), so a browser that is not installed, refuses to launch,
|
|
157
|
+
* loses the launch race, or dies mid-operation produces **no error anywhere a
|
|
158
|
+
* caller can see it**. A caller would be told `accepted` with `pageDriven:
|
|
159
|
+
* true` for a navigation that never happened. That is the same lie as the
|
|
160
|
+
* capture that wrote no row, told with more confidence.
|
|
161
|
+
*
|
|
162
|
+
* So the answer is not settled until the work has either run or failed. The
|
|
163
|
+
* runner awaits every after-commit action **before** the value is returned
|
|
164
|
+
* (`store/transaction.ts`), so by the time any caller can read this field the
|
|
165
|
+
* work is over — and the field reads a flag the work itself set.
|
|
166
|
+
*
|
|
167
|
+
* **Report and reality are still one expression**, which was the property
|
|
168
|
+
* worth keeping: `driven` is written in exactly one place, the last statement
|
|
169
|
+
* of the closure that does the driving. It cannot be set by a handler, it
|
|
170
|
+
* cannot be set on a path that skipped the work, and it cannot be set by the
|
|
171
|
+
* failure path, because the throw happens first.
|
|
172
|
+
*
|
|
173
|
+
* ── Every failure is still swallowed, and now it is also reported ───────
|
|
174
|
+
*
|
|
175
|
+
* The swallowing is unchanged and required: the transaction has committed, the
|
|
176
|
+
* decision stands, capacity was taken, and a driver that will not answer
|
|
177
|
+
* cannot be allowed to unmake it. What the swallowing does not do is tell the
|
|
178
|
+
* caller the page moved. It gets `accepted` — because the arbitration half
|
|
179
|
+
* genuinely happened and is genuinely durable — carrying `pageDriven: false`,
|
|
180
|
+
* which is the honest description of *your lease is real and your page is
|
|
181
|
+
* not*. What it costs is visible in the store rather than hidden: a tab whose
|
|
182
|
+
* page could not be opened keeps its `opening` row and no driver name.
|
|
183
|
+
*/
|
|
184
|
+
function afterCommitWork(scope, input, tab, work, claimId) {
|
|
185
|
+
const source = input.session;
|
|
186
|
+
if (source === undefined) {
|
|
187
|
+
return {
|
|
188
|
+
afterCommit: [],
|
|
189
|
+
pageDriven: false,
|
|
190
|
+
notDrivenReason: 'This build has no browser to drive: the call was decided, recorded and its lease renewed, but no page was touched.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// The one mutable cell, written by the one statement below and read by the
|
|
194
|
+
// getter. Not exposed: a handler receives the getter and never this.
|
|
195
|
+
let driven = false;
|
|
196
|
+
// ── Why the failure's reason is kept, and not only the fact of it ───────
|
|
197
|
+
//
|
|
198
|
+
// `pageDriven: false` is honest and it is **not actionable**: it says the
|
|
199
|
+
// page did not move and says nothing about whether the caller can do
|
|
200
|
+
// anything about that. The causes want opposite responses — a browser that
|
|
201
|
+
// will not launch is not the caller's to fix, while a stale element
|
|
202
|
+
// reference is fixed by reading the page again, which is a thing a caller
|
|
203
|
+
// can do unattended and immediately.
|
|
204
|
+
//
|
|
205
|
+
// The failure is still swallowed (§2.4b) and the outcome is still
|
|
206
|
+
// `accepted`, both for the reasons above; what changes is that the reason
|
|
207
|
+
// travels with the report instead of dying in the runner's empty `catch`.
|
|
208
|
+
// A refusal's `rule` is carried when there is one, because that is the
|
|
209
|
+
// half a caller can branch on without matching on English.
|
|
210
|
+
let notDrivenReason;
|
|
211
|
+
return {
|
|
212
|
+
afterCommit: [
|
|
213
|
+
async () => {
|
|
214
|
+
try {
|
|
215
|
+
const session = await source(tab.browserId);
|
|
216
|
+
await work(session, await pageFor(scope, session, tab, input, claimId));
|
|
217
|
+
// **Last, deliberately.** Anything above this that throws leaves the
|
|
218
|
+
// flag false, which is what makes a browser failing mid-operation
|
|
219
|
+
// report as the page not having been driven rather than as success.
|
|
220
|
+
driven = true;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// Recorded, then **rethrown unchanged**. The runner's swallow is
|
|
224
|
+
// what keeps a committed decision from being unmade by a browser
|
|
225
|
+
// that will not answer, and catching here without rethrowing would
|
|
226
|
+
// quietly take that path over — including for the callers of this
|
|
227
|
+
// helper that are not about references at all.
|
|
228
|
+
notDrivenReason =
|
|
229
|
+
error instanceof BrokerError
|
|
230
|
+
? `${error.rule}: ${error.message}`
|
|
231
|
+
: error instanceof Error
|
|
232
|
+
? error.message
|
|
233
|
+
: String(error);
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
get pageDriven() {
|
|
239
|
+
return driven;
|
|
240
|
+
},
|
|
241
|
+
get notDrivenReason() {
|
|
242
|
+
return notDrivenReason;
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Attach the scheduled work's live answer to a result that carries everything
|
|
248
|
+
* else.
|
|
249
|
+
*
|
|
250
|
+
* ── Why a helper and not `pageDriven: work.pageDriven` at each site ─────
|
|
251
|
+
*
|
|
252
|
+
* That spelling **reads the getter immediately** and copies the boolean into
|
|
253
|
+
* the object, which puts the answer back where it was: decided before the
|
|
254
|
+
* browser was touched. The bug would be invisible — the property is there, it
|
|
255
|
+
* is the right name, it holds a value from the right place — and it would
|
|
256
|
+
* report `true` for every failed navigation.
|
|
257
|
+
*
|
|
258
|
+
* Composing the getter through instead means the six results share one
|
|
259
|
+
* definition of the field, so it cannot hold on five verbs and be a stale copy
|
|
260
|
+
* on the sixth. That is the same reason {@link TabOperationResult} declares it
|
|
261
|
+
* on the shared base rather than on each verb.
|
|
262
|
+
*/
|
|
263
|
+
function withPageDriven(value, work) {
|
|
264
|
+
const withFlag = Object.defineProperty(value, 'pageDriven', {
|
|
265
|
+
get: () => work.pageDriven,
|
|
266
|
+
enumerable: true,
|
|
267
|
+
});
|
|
268
|
+
// Composed through the same way and for the same reason: read eagerly it
|
|
269
|
+
// would always be `undefined`, because nothing has run yet.
|
|
270
|
+
return Object.defineProperty(withFlag, 'notDrivenReason', {
|
|
271
|
+
get: () => work.notDrivenReason,
|
|
272
|
+
enumerable: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* `navigate` (§3.5) — point an owned tab at an address.
|
|
277
|
+
*
|
|
278
|
+
* The address is checked against the scheme allowlist before anything is
|
|
279
|
+
* written, so a refused scheme leaves no trace but the refusal row.
|
|
280
|
+
*/
|
|
281
|
+
export function decideNavigate(scope, input) {
|
|
282
|
+
const url = validateNavigationTarget(input.url);
|
|
283
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'navigate');
|
|
284
|
+
append(scope.db, {
|
|
285
|
+
kind: 'navigate',
|
|
286
|
+
outcome: 'allow',
|
|
287
|
+
adapter: scope.adapter,
|
|
288
|
+
claimId: lease.claimId,
|
|
289
|
+
tabId: tab.tabId,
|
|
290
|
+
sessionId: lease.sessionId,
|
|
291
|
+
browserId: tab.browserId,
|
|
292
|
+
detail: { url },
|
|
293
|
+
});
|
|
294
|
+
const work = afterCommitWork(scope, input, tab, (session, page) => session.navigate(page, url), lease.claimId);
|
|
295
|
+
return {
|
|
296
|
+
value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, url }, work),
|
|
297
|
+
afterCommit: work.afterCommit,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* `act` (§3.6) — one interaction against an owned tab.
|
|
302
|
+
*
|
|
303
|
+
* The argument shape is turned from `unknown` into the driver's discriminated
|
|
304
|
+
* union by `validateAction`, which is the boundary the driver seam asks for:
|
|
305
|
+
* no cast, and thirteen actions each with their own required fields.
|
|
306
|
+
*/
|
|
307
|
+
export function decideAct(scope, input) {
|
|
308
|
+
const request = validateAction(input.request);
|
|
309
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'act');
|
|
310
|
+
append(scope.db, {
|
|
311
|
+
kind: 'act',
|
|
312
|
+
outcome: 'allow',
|
|
313
|
+
adapter: scope.adapter,
|
|
314
|
+
claimId: lease.claimId,
|
|
315
|
+
tabId: tab.tabId,
|
|
316
|
+
sessionId: lease.sessionId,
|
|
317
|
+
browserId: tab.browserId,
|
|
318
|
+
detail: { action: request.action },
|
|
319
|
+
});
|
|
320
|
+
const work = afterCommitWork(scope, input, tab, (session, page) => session.act(page, request), lease.claimId);
|
|
321
|
+
return {
|
|
322
|
+
value: withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, action: request.action }, work),
|
|
323
|
+
afterCommit: work.afterCommit,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* `read` (§3.7) — collect artifacts from an owned tab.
|
|
328
|
+
*/
|
|
329
|
+
export function decideRead(scope, input) {
|
|
330
|
+
const artifacts = resolveReadArtifacts(input.artifacts);
|
|
331
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'read');
|
|
332
|
+
append(scope.db, {
|
|
333
|
+
kind: 'read',
|
|
334
|
+
outcome: 'allow',
|
|
335
|
+
adapter: scope.adapter,
|
|
336
|
+
claimId: lease.claimId,
|
|
337
|
+
tabId: tab.tabId,
|
|
338
|
+
sessionId: lease.sessionId,
|
|
339
|
+
browserId: tab.browserId,
|
|
340
|
+
detail: { artifacts: [...artifacts] },
|
|
341
|
+
});
|
|
342
|
+
// Set inside the after-commit closure and read by the getter below — the
|
|
343
|
+
// same arrangement `decideCapture` uses for `written`, and for the same
|
|
344
|
+
// reason: the paths do not exist until the work has run, so a value read
|
|
345
|
+
// eagerly here would always be empty.
|
|
346
|
+
let collected;
|
|
347
|
+
const work = afterCommitWork(scope, input, tab, async (session, page) => {
|
|
348
|
+
const results = await session.read(page, artifacts);
|
|
349
|
+
// **The return value is what carries the paths.** The driver knows them;
|
|
350
|
+
// a handler that ignores this leaves the caller unable to open anything
|
|
351
|
+
// it just collected.
|
|
352
|
+
collected = results.map((result) => ({
|
|
353
|
+
artifact: result.artifact,
|
|
354
|
+
path: result.path,
|
|
355
|
+
bytes: result.bytes,
|
|
356
|
+
}));
|
|
357
|
+
}, lease.claimId);
|
|
358
|
+
const value = withPageDriven({ claimId: lease.claimId, tabId: tab.tabId, expiresAt, artifacts }, work);
|
|
359
|
+
return {
|
|
360
|
+
// Enumerable, deliberately: both surfaces serialise their result, and a
|
|
361
|
+
// getter that is not enumerable is invisible to `JSON.stringify` — present
|
|
362
|
+
// in process, absent on the wire.
|
|
363
|
+
value: Object.defineProperty(value, 'collected', {
|
|
364
|
+
get: () => collected,
|
|
365
|
+
enumerable: true,
|
|
366
|
+
}),
|
|
367
|
+
afterCommit: work.afterCommit,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* `evaluate` (§3.9) — run an expression in an owned tab.
|
|
372
|
+
*
|
|
373
|
+
* The expression is bounded and otherwise uninspected: there is no allowlist
|
|
374
|
+
* and no filtering, which `pages.ts` states as a deliberate position rather
|
|
375
|
+
* than an omission. **This handler adds no target, context, world or scope
|
|
376
|
+
* argument to that path**, which that file's comment names as the specific
|
|
377
|
+
* way the capability it declined would arrive back by accident.
|
|
378
|
+
*/
|
|
379
|
+
export function decideEvaluate(scope, input) {
|
|
380
|
+
const expression = validateExpression(input.expression);
|
|
381
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'evaluate');
|
|
382
|
+
const expressionBytes = Buffer.byteLength(expression, 'utf8');
|
|
383
|
+
append(scope.db, {
|
|
384
|
+
kind: 'evaluate',
|
|
385
|
+
outcome: 'allow',
|
|
386
|
+
adapter: scope.adapter,
|
|
387
|
+
claimId: lease.claimId,
|
|
388
|
+
tabId: tab.tabId,
|
|
389
|
+
sessionId: lease.sessionId,
|
|
390
|
+
browserId: tab.browserId,
|
|
391
|
+
// The expression itself is not recorded. It is caller code, it can be
|
|
392
|
+
// four kilobytes of it, and the ledger is read by people.
|
|
393
|
+
detail: { expressionBytes },
|
|
394
|
+
});
|
|
395
|
+
// Set inside the after-commit closure and read by the getter below — the
|
|
396
|
+
// arrangement `decideCapture` uses for `written`, and for the same reason:
|
|
397
|
+
// the value does not exist until the work has run, so a copy taken here
|
|
398
|
+
// would always be absent. **This is row #24's missing half.** The
|
|
399
|
+
// evaluation already happened in after-commit, correctly (§2.4b); what was
|
|
400
|
+
// absent was any path by which its value reached the caller.
|
|
401
|
+
let evaluated;
|
|
402
|
+
const artifacts = input.artifacts;
|
|
403
|
+
const work = afterCommitWork(scope, input, tab, async (session, page) => {
|
|
404
|
+
const result = await session.evaluate(page, expression);
|
|
405
|
+
// The spill decision is made against the result the page actually
|
|
406
|
+
// produced, which is only knowable here. `disposeEvaluationResult` is
|
|
407
|
+
// what decides it, and it is the same function the seam's own tests
|
|
408
|
+
// measure.
|
|
409
|
+
const disposition = disposeEvaluationResult(result.value);
|
|
410
|
+
if (!disposition.spill) {
|
|
411
|
+
evaluated = { spilled: false, value: disposition.serialised, bytes: disposition.bytes };
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// ── Past the cap, and nowhere to put it ──────────────────────────────
|
|
415
|
+
//
|
|
416
|
+
// **Thrown rather than returned**, for the reason `decideCapture` gives
|
|
417
|
+
// where it is handed a browser and no store: running to completion here
|
|
418
|
+
// would report `pageDriven: true` for a call whose value the caller
|
|
419
|
+
// cannot reach, in either place §3.10 says it may be. Throwing takes the
|
|
420
|
+
// ordinary after-commit failure path — swallowed by the runner, ledger
|
|
421
|
+
// row and decision still committed — so the caller is told plainly, and
|
|
422
|
+
// `notDrivenReason` carries which of the two it was.
|
|
423
|
+
if (artifacts === undefined) {
|
|
424
|
+
throw new BrokerError('evaluate.result_serialisable', `That expression produced ${String(disposition.bytes)} bytes, past the ${String(MAX_INLINE_RESULT_BYTES)}-byte inline limit, and this call supplied no artifact store to spill it into. Nothing was returned.`);
|
|
425
|
+
}
|
|
426
|
+
// The same store, the same refusal and the same relative-path form every
|
|
427
|
+
// other artefact uses (§1.7a). Written through `ArtifactStore.write` and
|
|
428
|
+
// not `writeFileSync`, because that method is the single implementation
|
|
429
|
+
// that refuses a name resolving outside the root.
|
|
430
|
+
const stored = artifacts.write(lease.claimId, 'snapshots', evaluationFileName(new Date(), lease.claimId), Buffer.from(disposition.serialised, 'utf8'));
|
|
431
|
+
evaluated = { spilled: true, path: stored.relativePath, bytes: disposition.bytes };
|
|
432
|
+
}, lease.claimId);
|
|
433
|
+
return {
|
|
434
|
+
value: withPageDriven({
|
|
435
|
+
claimId: lease.claimId,
|
|
436
|
+
tabId: tab.tabId,
|
|
437
|
+
expiresAt,
|
|
438
|
+
expressionBytes,
|
|
439
|
+
// A getter for the same reason `pageDriven` is one — see
|
|
440
|
+
// {@link withPageDriven}. Read eagerly it would always be absent.
|
|
441
|
+
get result() {
|
|
442
|
+
return evaluated;
|
|
443
|
+
},
|
|
444
|
+
}, work),
|
|
445
|
+
afterCommit: work.afterCommit,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* What a spilled evaluation is called on disk.
|
|
450
|
+
*
|
|
451
|
+
* Every part is derived rather than supplied: an instant and the claim, both
|
|
452
|
+
* of which this service generates. **Nothing a caller sent reaches the name**
|
|
453
|
+
* — not the expression, not the value — which is `names.ts`'s rule one
|
|
454
|
+
* (a file name travels further than a column does) applied to the one
|
|
455
|
+
* artefact whose contents are entirely the caller's.
|
|
456
|
+
*
|
|
457
|
+
* `.json` because the contents are exactly what `JSON.stringify` produced,
|
|
458
|
+
* and a reader opening the file should be able to tell.
|
|
459
|
+
*/
|
|
460
|
+
function evaluationFileName(when, claimId) {
|
|
461
|
+
return `evaluation-${stampFromInstant(when)}-${sanitiseLabel(claimId)}.json`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* `capture` (§3.10) — take an image of an owned tab.
|
|
465
|
+
*
|
|
466
|
+
* **`fullPage` defaults to false rather than being required**, which is the
|
|
467
|
+
* one reading this handler had to choose: `CaptureRequest` makes the field
|
|
468
|
+
* mandatory on the seam, and no validator for it exists anywhere in
|
|
469
|
+
* `pages.ts`. A viewport capture is the cheaper and more common of the two
|
|
470
|
+
* and is what a caller who did not think about it almost certainly wants, so
|
|
471
|
+
* that is the default. The choice is recorded in the ledger either way, so a
|
|
472
|
+
* caller surprised by it can see what was actually taken.
|
|
473
|
+
*/
|
|
474
|
+
export function decideCapture(scope, input) {
|
|
475
|
+
const fullPage = input.fullPage === true;
|
|
476
|
+
// **Before `admit`, like every other argument validation in this file.** A
|
|
477
|
+
// capture that contradicts itself is refused before the lease is resolved,
|
|
478
|
+
// before ownership is checked and before a single row is written, so the
|
|
479
|
+
// refusal leaves nothing behind but its own ledger entry.
|
|
480
|
+
validateCaptureMode({ fullPage, selector: input.selector });
|
|
481
|
+
const request = {
|
|
482
|
+
fullPage,
|
|
483
|
+
...(input.selector === undefined ? {} : { selector: input.selector }),
|
|
484
|
+
};
|
|
485
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'capture');
|
|
486
|
+
append(scope.db, {
|
|
487
|
+
kind: 'capture',
|
|
488
|
+
outcome: 'allow',
|
|
489
|
+
adapter: scope.adapter,
|
|
490
|
+
claimId: lease.claimId,
|
|
491
|
+
tabId: tab.tabId,
|
|
492
|
+
sessionId: lease.sessionId,
|
|
493
|
+
browserId: tab.browserId,
|
|
494
|
+
detail: { fullPage },
|
|
495
|
+
});
|
|
496
|
+
// Read inside the transaction, where every other read this handler makes
|
|
497
|
+
// happens. It decides the accounting warning only — never a refusal — so
|
|
498
|
+
// reading it here rather than in the closure costs nothing but keeps the
|
|
499
|
+
// store access on the transaction's side of §2.4b.
|
|
500
|
+
const takenBefore = input.artifacts === undefined ? 0 : capturesTakenBy(scope.db, lease.claimId);
|
|
501
|
+
let written;
|
|
502
|
+
// Set inside the after-commit closure, read by the getter below — the same
|
|
503
|
+
// arrangement `written` uses and for the same reason: neither value exists
|
|
504
|
+
// until the work has run.
|
|
505
|
+
let compared;
|
|
506
|
+
const artifacts = input.artifacts;
|
|
507
|
+
const compareTo = input.compareTo;
|
|
508
|
+
const work = afterCommitWork(scope, input, tab, async (session, page) => {
|
|
509
|
+
if (artifacts === undefined) {
|
|
510
|
+
// A browser but nowhere to put the picture. Taking one and dropping it
|
|
511
|
+
// is precisely the behaviour this handler exists to stop, so the shutter
|
|
512
|
+
// is not pressed at all.
|
|
513
|
+
//
|
|
514
|
+
// **Thrown rather than returned**, and the difference is the honesty of
|
|
515
|
+
// the answer. Returning would leave the closure to run to completion and
|
|
516
|
+
// report `pageDriven: true` — for a call that reached no page, wrote no
|
|
517
|
+
// file and left `captures` empty, which is the precise combination this
|
|
518
|
+
// field exists to make impossible. Throwing takes the same path a
|
|
519
|
+
// browser failure takes: swallowed by the runner (§2.4b), the decision
|
|
520
|
+
// and its ledger row still committed, and the caller told plainly that
|
|
521
|
+
// nothing was driven.
|
|
522
|
+
throw new BrokerError('capture.arguments_consistent', 'A capture needs somewhere to write the image, and this call supplied a browser without one. No picture was taken.');
|
|
523
|
+
}
|
|
524
|
+
// **The pipeline, not `session.capture` directly.** Reaching the seam here
|
|
525
|
+
// was the defect: it skipped the settle, the downscale to the requested
|
|
526
|
+
// rung, and the write through the artifact store — the only thing that
|
|
527
|
+
// decides where a file may go — and then discarded the bytes. Everything
|
|
528
|
+
// the `captures` row needs comes back as telemetry.
|
|
529
|
+
const taken = await takeCapture({ tabs: session, artifacts }, lease.claimId, page, {
|
|
530
|
+
fullPage,
|
|
531
|
+
...(request.selector === undefined ? {} : { selector: request.selector }),
|
|
532
|
+
}, takenBefore);
|
|
533
|
+
// The row last, describing a file that is already on disk. See
|
|
534
|
+
// `capture-store.ts` for why that order is the rule and not a preference.
|
|
535
|
+
recordCapture(scope.db, lease.claimId, tab.tabId, taken.telemetry);
|
|
536
|
+
written = {
|
|
537
|
+
captureId: taken.captureId,
|
|
538
|
+
path: taken.path,
|
|
539
|
+
width: taken.width,
|
|
540
|
+
height: taken.height,
|
|
541
|
+
bytes: taken.bytes,
|
|
542
|
+
};
|
|
543
|
+
// ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
|
|
544
|
+
//
|
|
545
|
+
// **Here, and not one line earlier.** Three separate rules put it at this
|
|
546
|
+
// exact point and they agree:
|
|
547
|
+
//
|
|
548
|
+
// 1. §2.4b — never browser I/O inside the arbitration transaction. This
|
|
549
|
+
// whole closure is after-commit, so the shutter above already obeyed
|
|
550
|
+
// that. The comparison itself is arithmetic over two decoded images
|
|
551
|
+
// and some file writes: no browser, no seam method, nothing that could
|
|
552
|
+
// reintroduce the thing that rule forbids.
|
|
553
|
+
// 2. §1.7 order — the `captures` row is written above, *before* this
|
|
554
|
+
// runs, because the comparison names that capture as its source and a
|
|
555
|
+
// row referencing one that does not exist yet is a foreign key waiting
|
|
556
|
+
// to fail.
|
|
557
|
+
// 3. `capture.no_diff_dependency` (§7.3) — the direction still runs one
|
|
558
|
+
// way. This module reads the diff feature; the diff feature does not
|
|
559
|
+
// read this. `takeCapture` was handed no comparison argument and
|
|
560
|
+
// returned before any of this was considered, so the pipeline remains
|
|
561
|
+
// a module that could be built with the diff feature deleted.
|
|
562
|
+
//
|
|
563
|
+
// **Nothing here can fail the capture.** `runComparison` throws only on a
|
|
564
|
+
// programming mistake and returns an explanation for every caller-caused
|
|
565
|
+
// failure, so a diff that cannot run leaves `written` exactly as it is
|
|
566
|
+
// above and the caller still gets its picture — which is §3.11's rule that
|
|
567
|
+
// an optional argument may not withhold the thing it is optional on.
|
|
568
|
+
if (compareTo !== undefined) {
|
|
569
|
+
const source = captureSource(scope.db, artifacts);
|
|
570
|
+
const justTaken = {
|
|
571
|
+
id: taken.captureId,
|
|
572
|
+
claimId: lease.claimId,
|
|
573
|
+
path: taken.path,
|
|
574
|
+
kind: taken.telemetry.kind,
|
|
575
|
+
width: taken.width,
|
|
576
|
+
height: taken.height,
|
|
577
|
+
};
|
|
578
|
+
compared = await runComparison({
|
|
579
|
+
capture: justTaken,
|
|
580
|
+
// **Read back through the seam rather than kept from the pipeline.**
|
|
581
|
+
// `takeCapture` returns `bytes` as a *file size*, not the image, and
|
|
582
|
+
// deliberately so — §3.11 is emphatic that a capture result carries
|
|
583
|
+
// "a path, the dimensions … **Never the image**", and `CaptureResult`
|
|
584
|
+
// has no field that could hold pixels. So the bytes are read from the
|
|
585
|
+
// file just written, through `ArtifactStore.resolve` — the single
|
|
586
|
+
// implementation that refuses a path escaping the root in either
|
|
587
|
+
// namespace. Reading the file directly would have meant a second
|
|
588
|
+
// resolver, and the second one is the one missing a case.
|
|
589
|
+
captureBytes: await source.readBytes(justTaken),
|
|
590
|
+
targetCaptureId: compareTo,
|
|
591
|
+
source,
|
|
592
|
+
settings: input.diffSettings ?? DEFAULT_DIFF_SETTINGS,
|
|
593
|
+
artifacts,
|
|
594
|
+
// The row is written through the same handle every other write in
|
|
595
|
+
// this closure uses, so a comparison and the capture it describes
|
|
596
|
+
// cannot end up in different states of the store.
|
|
597
|
+
writeRow: (row) => insertComparison(scope.db, row),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}, lease.claimId);
|
|
601
|
+
return {
|
|
602
|
+
value: withPageDriven({
|
|
603
|
+
claimId: lease.claimId,
|
|
604
|
+
tabId: tab.tabId,
|
|
605
|
+
expiresAt,
|
|
606
|
+
fullPage,
|
|
607
|
+
// A getter for the same reason `pageDriven` is one: the value is not
|
|
608
|
+
// known until the after-commit work has run, and a copy taken here
|
|
609
|
+
// would always be absent.
|
|
610
|
+
get capture() {
|
|
611
|
+
return written;
|
|
612
|
+
},
|
|
613
|
+
// A getter for the same reason `capture` is one. Stays `undefined`
|
|
614
|
+
// when no diff was asked for, which is what distinguishes "you did
|
|
615
|
+
// not ask" from a comparison that ran and found nothing.
|
|
616
|
+
get comparison() {
|
|
617
|
+
return compared;
|
|
618
|
+
},
|
|
619
|
+
}, work),
|
|
620
|
+
afterCommit: work.afterCommit,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* `tab_replace` (§3.11) — give up this lease's tab and take a fresh one.
|
|
625
|
+
*
|
|
626
|
+
* ── Why this is one operation and not a release followed by a claim ─────
|
|
627
|
+
*
|
|
628
|
+
* A lease is one tab (§2.3), so a caller wanting a clean page has to give up
|
|
629
|
+
* the one it has. Doing that as two calls means dropping to zero tabs in
|
|
630
|
+
* between — at which point the capacity it just freed is fair game for
|
|
631
|
+
* whoever is at the front of the queue, and a caller tidying up its own page
|
|
632
|
+
* can lose its lease for doing so. Inside one transaction the count never
|
|
633
|
+
* dips, and nothing can be promoted into the gap because there is no gap.
|
|
634
|
+
*
|
|
635
|
+
* Closing the tab being given up is after-commit work, like every other
|
|
636
|
+
* close: the row goes to `closing` inside the transaction, which is §1.4's
|
|
637
|
+
* honest representation of *the tool was asked and has not answered*, and a
|
|
638
|
+
* tab that does not close is a leaked tab rather than a leaked lease.
|
|
639
|
+
*/
|
|
640
|
+
export function decideTabReplace(scope, input) {
|
|
641
|
+
const { db, adapter } = scope;
|
|
642
|
+
const { lease, tab, expiresAt } = admit(scope, input, 'tab_closing');
|
|
643
|
+
const browser = tab.browserId;
|
|
644
|
+
// Out first, in second, both inside the one transaction. The order matters
|
|
645
|
+
// only for the ledger reading sensibly; the count never changes, because
|
|
646
|
+
// the reservation is written before the commit that would have let anyone
|
|
647
|
+
// else see the tab go.
|
|
648
|
+
//
|
|
649
|
+
// **Which state the tab goes out through is not this operation's rule to
|
|
650
|
+
// invent**, and writing it here was a real defect the schema caught: a tab
|
|
651
|
+
// still `opening` has no page, and moving one to `closing` asserts an
|
|
652
|
+
// outstanding round trip that nobody is coming to answer — which the
|
|
653
|
+
// `(state = 'opening') = (driver_tab_id IS NULL)` constraint refuses
|
|
654
|
+
// outright. `updateSweptTabs` is the one place that rule is written, it is
|
|
655
|
+
// exported precisely so the sweep and release cannot spell it differently,
|
|
656
|
+
// and this is the third caller that needs exactly it. What comes back is
|
|
657
|
+
// the subset a browser still owes an answer about.
|
|
658
|
+
const pendingCloses = updateSweptTabs(db, [{ tabId: tab.tabId, claimId: lease.claimId, browserId: browser }], scope.swept.sweptAt);
|
|
659
|
+
append(db, {
|
|
660
|
+
kind: 'tab_closing',
|
|
661
|
+
outcome: 'allow',
|
|
662
|
+
adapter,
|
|
663
|
+
claimId: lease.claimId,
|
|
664
|
+
tabId: tab.tabId,
|
|
665
|
+
sessionId: lease.sessionId,
|
|
666
|
+
browserId: tab.browserId,
|
|
667
|
+
detail: { givenUpFor: 'a fresh tab', pageToClose: pendingCloses.length === 1 },
|
|
668
|
+
});
|
|
669
|
+
const replacementId = reserveTab(db, lease.claimId, browser);
|
|
670
|
+
append(db, {
|
|
671
|
+
kind: 'tab_opening',
|
|
672
|
+
outcome: 'allow',
|
|
673
|
+
adapter,
|
|
674
|
+
claimId: lease.claimId,
|
|
675
|
+
tabId: replacementId,
|
|
676
|
+
sessionId: lease.sessionId,
|
|
677
|
+
browserId: tab.browserId,
|
|
678
|
+
detail: { takenOverFrom: tab.tabId },
|
|
679
|
+
});
|
|
680
|
+
const source = input.session;
|
|
681
|
+
// **The same live answer the other five verbs give, not a second spelling of
|
|
682
|
+
// it.** This handler cannot use `afterCommitWork` — its work closes one page
|
|
683
|
+
// and opens another rather than driving one, so it does not take that
|
|
684
|
+
// helper's shape — and the previous arrangement answered `source !==
|
|
685
|
+
// undefined` here instead. That was a *second* computation of a field whose
|
|
686
|
+
// whole value is that it is computed once: correct while nothing supplied a
|
|
687
|
+
// session, and a prediction the moment something did.
|
|
688
|
+
//
|
|
689
|
+
// It matters more here than anywhere else, for the reason
|
|
690
|
+
// {@link TabReplaceResult.pageDriven} gives: this verb exchanges the tab in
|
|
691
|
+
// the store regardless, so a caller told the swap succeeded believes it holds
|
|
692
|
+
// a clean page. If the browser could not be reached, it holds a fresh
|
|
693
|
+
// identifier over a row that is still `opening` with nothing under it.
|
|
694
|
+
//
|
|
695
|
+
// So the flag is declared here and written by the closure below, and the
|
|
696
|
+
// field is composed by {@link withPageDriven} — the same function the other
|
|
697
|
+
// five go through, which is what stops the two answers drifting.
|
|
698
|
+
let driven = false;
|
|
699
|
+
// Carried for the same reason and by the same rule as in
|
|
700
|
+
// {@link afterCommitWork}: the fact that the swap did not reach a browser is
|
|
701
|
+
// not actionable on its own. This verb addresses no element, so the stale
|
|
702
|
+
// reference case cannot arise here — what it reports is a browser that could
|
|
703
|
+
// not be reached or a page that would not open.
|
|
704
|
+
let notDrivenReason = source === undefined
|
|
705
|
+
? 'This build has no browser to drive: the tab was exchanged in the store, but no page was opened for it.'
|
|
706
|
+
: undefined;
|
|
707
|
+
const work = {
|
|
708
|
+
get pageDriven() {
|
|
709
|
+
return driven;
|
|
710
|
+
},
|
|
711
|
+
get notDrivenReason() {
|
|
712
|
+
return notDrivenReason;
|
|
713
|
+
},
|
|
714
|
+
afterCommit: source === undefined
|
|
715
|
+
? []
|
|
716
|
+
: [
|
|
717
|
+
async () => {
|
|
718
|
+
try {
|
|
719
|
+
const session = await source(browser);
|
|
720
|
+
// The tab being given up is closed first, and only if a page
|
|
721
|
+
// was ever opened for it. If it will not close it is a leaked
|
|
722
|
+
// tab; the fresh one is owed either way, and making it wait on
|
|
723
|
+
// a page that is refusing to die is how one stuck close turns
|
|
724
|
+
// into a lease with no tab at all.
|
|
725
|
+
// At most one, and empty when no page was ever opened for this
|
|
726
|
+
// tab — in which case there is nothing to ask a browser about
|
|
727
|
+
// and the row is already `closed`.
|
|
728
|
+
if (pendingCloses.length > 0 && tab.driverTabId !== null) {
|
|
729
|
+
try {
|
|
730
|
+
await session.closeTab({ browser, driverTabId: tab.driverTabId });
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
// Best effort (§2.4b). The row stays `closing`, which is
|
|
734
|
+
// what the administrative clear-a-leaked-tab operation
|
|
735
|
+
// selects on.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const opened = await session.openTab();
|
|
739
|
+
// Recorded on its own connection-free path: this runs after the
|
|
740
|
+
// commit, so it opens its own short write rather than
|
|
741
|
+
// reaching back into a transaction that is gone.
|
|
742
|
+
recordTabOpened(scope.db, replacementId, opened.driverTabId);
|
|
743
|
+
// Last, for the same reason it is last in `afterCommitWork`: a
|
|
744
|
+
// throw above leaves this false, so a browser that failed
|
|
745
|
+
// partway through reports the page as not driven.
|
|
746
|
+
driven = true;
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
// Recorded and rethrown unchanged, so the runner's swallow
|
|
750
|
+
// (§2.4b) still governs what a failure costs.
|
|
751
|
+
notDrivenReason =
|
|
752
|
+
error instanceof BrokerError
|
|
753
|
+
? `${error.rule}: ${error.message}`
|
|
754
|
+
: error instanceof Error
|
|
755
|
+
? error.message
|
|
756
|
+
: String(error);
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
};
|
|
762
|
+
return {
|
|
763
|
+
value: withPageDriven({
|
|
764
|
+
claimId: lease.claimId,
|
|
765
|
+
previousTabId: tab.tabId,
|
|
766
|
+
tabId: replacementId,
|
|
767
|
+
expiresAt,
|
|
768
|
+
}, work),
|
|
769
|
+
afterCommit: work.afterCommit,
|
|
770
|
+
};
|
|
771
|
+
}
|