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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. 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
+ }