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,537 @@
1
+ import { append } from "./events.js";
2
+ import { decideClaim, } from "./operations/claim.js";
3
+ // The module is named for what the verb does rather than for the verb, and
4
+ // the reason is mechanical: the sibling scan in `check-arbitration.mjs` reads
5
+ // every string literal in this file looking for transaction-control SQL, and
6
+ // one of the keywords it looks for is the word this operation would
7
+ // otherwise be spelled with — a savepoint being one of the documented
8
+ // bypasses it exists to catch. An import path carrying that word fails the
9
+ // scan for a reason that has nothing to do with transactions. Renaming costs
10
+ // nothing; a waiver would silence the whole line permanently.
11
+ import { decideRelease } from "./operations/give-back.js";
12
+ import { decideAct, decideCapture, decideEvaluate, decideNavigate, decideRead, decideTabReplace, } from "./operations/pages.js";
13
+ import { decideBeginSignIn, decideEndSignIn, decideFinishSignIn, decideRequestSignIn, } from "./operations/sign-in.js";
14
+ import { decideStatus } from "./operations/status.js";
15
+ import { CallRefusal } from "./refusals.js";
16
+ function claimHandler(scope, input) {
17
+ return decideClaim(scope, input, input.settings);
18
+ }
19
+ function statusHandler(scope, input) {
20
+ return decideStatus(scope, input);
21
+ }
22
+ function releaseHandler(scope, input) {
23
+ return decideRelease(scope, input, input.settings);
24
+ }
25
+ /**
26
+ * The two sign-in handlers (SCHEMA.md 5.5.1).
27
+ *
28
+ * **Neither takes settings, and neither is keyed.** They are the one pair of
29
+ * operations a *person* performs rather than a caller: there is no lease to
30
+ * renew, no duration to report and no key to carry, because a person at a
31
+ * keyboard is not a caller and takes no tab budget. What they need from the
32
+ * transaction is the reconciled lease state the sweep produces, which every
33
+ * handler gets for free.
34
+ */
35
+ function beginSignInHandler(scope, input) {
36
+ return decideBeginSignIn(scope, input);
37
+ }
38
+ function endSignInHandler(scope, input) {
39
+ return decideEndSignIn(scope, input);
40
+ }
41
+ /**
42
+ * The caller-facing pair, and **both are keyed** — which is the difference
43
+ * from the two above rather than an inconsistency with them.
44
+ *
45
+ * The pair above is performed by a person, who holds no lease. This pair is
46
+ * performed by a caller on behalf of a person, and the lease is what makes
47
+ * that expressible: it is how the operation knows which lease to exempt from
48
+ * §5.5.1's live-lease refusal, and which lease is entitled to end the request
49
+ * it made. Take the key away and neither question has an answer.
50
+ */
51
+ function requestSignInHandler(scope, input) {
52
+ return decideRequestSignIn(scope, input);
53
+ }
54
+ function finishSignInHandler(scope, input) {
55
+ return decideFinishSignIn(scope, input);
56
+ }
57
+ /**
58
+ * The six tab-addressed handlers.
59
+ *
60
+ * **None of them takes settings**, for the reason `decideStatus` gives about
61
+ * itself: every duration they report comes off the lease's own row, because
62
+ * each of these renews the lease it names and a renewal has to extend by the
63
+ * duration the caller was already told about. A settings argument they did
64
+ * not use would invite exactly the re-read §6.3 forbids.
65
+ */
66
+ function navigateHandler(scope, input) {
67
+ return decideNavigate(scope, input);
68
+ }
69
+ function actHandler(scope, input) {
70
+ return decideAct(scope, input);
71
+ }
72
+ function readHandler(scope, input) {
73
+ return decideRead(scope, input);
74
+ }
75
+ function evaluateHandler(scope, input) {
76
+ return decideEvaluate(scope, input);
77
+ }
78
+ function captureHandler(scope, input) {
79
+ return decideCapture(scope, input);
80
+ }
81
+ function tabReplaceHandler(scope, input) {
82
+ return decideTabReplace(scope, input);
83
+ }
84
+ /**
85
+ * Every arbitration operation this build has.
86
+ *
87
+ * **This registry is the set `arbitration.no_read_only_path` walks**, and the
88
+ * reason it is one object in one file rather than a call to a `register()`
89
+ * function scattered across modules: a check that has to *find* the
90
+ * registrations can only find the ones written the way it expects, and the
91
+ * first one written differently is invisible to it. A single object literal
92
+ * is enumerable statically and at run time, and the two enumerations can be
93
+ * asserted equal.
94
+ *
95
+ * **Every operation here writes, without exception**, which is the property
96
+ * the registry exists to keep true. `claim` inserts a row; `release` updates
97
+ * one; `status` renews, which is row #14's point — a keyed call extends the
98
+ * lease it names, so the operation that looks read-only is a writer twice
99
+ * over, once for its own renewal and once for the sweep the runner ran
100
+ * before it.
101
+ *
102
+ * **The six tab-addressed operations are writers on the same grounds**, and
103
+ * it is worth being explicit because they are the ones that look least like
104
+ * it: `navigate`, `act`, `read`, `evaluate` and `capture` each read a page
105
+ * and change nothing about it, yet each is keyed, so each renews, and each
106
+ * records what it did. The browser work they cause is not part of the
107
+ * transaction at all — it is handed back as `afterCommit` and run once the
108
+ * commit is done (§2.4b), so what is inside the transaction is only ever the
109
+ * renewal, the ownership check and the ledger row.
110
+ *
111
+ * **The empty-registry exemption in `scripts/check-arbitration.mjs` is
112
+ * retired by this row**, which is what it named as the condition for its own
113
+ * removal. Every rule that check enforces is now an assertion over a
114
+ * non-empty set.
115
+ */
116
+ export const ARBITRATION_OPERATIONS = {
117
+ claim: {
118
+ kind: 'claim_requested',
119
+ summary: 'Ask for a lease over one tab: granted if capacity allows, queued at the back if not.',
120
+ handler: claimHandler,
121
+ },
122
+ status: {
123
+ kind: 'claim_renewed',
124
+ summary: 'Where this lease stands, and — like every keyed call — an extension of it.',
125
+ handler: statusHandler,
126
+ },
127
+ release: {
128
+ kind: 'claim_released',
129
+ summary: 'Give back whatever this lease holds: a tab, or a place in the queue.',
130
+ handler: releaseHandler,
131
+ },
132
+ navigate: {
133
+ kind: 'navigate',
134
+ summary: 'Point an owned tab at an address, having checked the scheme is one of the two.',
135
+ handler: navigateHandler,
136
+ },
137
+ act: {
138
+ kind: 'act',
139
+ summary: 'One interaction against an owned tab, from the thirteen the seam names.',
140
+ handler: actHandler,
141
+ },
142
+ read: {
143
+ kind: 'read',
144
+ summary: 'Collect artifacts from an owned tab; the page state is always among them.',
145
+ handler: readHandler,
146
+ },
147
+ evaluate: {
148
+ kind: 'evaluate',
149
+ summary: 'Run a bounded expression in an owned tab and dispose of what it returned.',
150
+ handler: evaluateHandler,
151
+ },
152
+ capture: {
153
+ kind: 'capture',
154
+ summary: 'Take an image of an owned tab, of the viewport or of the whole page.',
155
+ handler: captureHandler,
156
+ },
157
+ begin_sign_in: {
158
+ kind: 'browser_signin_began',
159
+ summary: 'Claim the signed-in browser for a person, refusing if any live lease holds a tab.',
160
+ handler: beginSignInHandler,
161
+ },
162
+ end_sign_in: {
163
+ kind: 'browser_signin_ended',
164
+ summary: 'Give the browser back after a person has signed in; queued callers kept their places.',
165
+ handler: endSignInHandler,
166
+ },
167
+ sign_in: {
168
+ kind: 'browser_signin_began',
169
+ summary: 'Ask a person to sign in on the tab this lease already holds, exempting only that lease.',
170
+ handler: requestSignInHandler,
171
+ },
172
+ sign_in_done: {
173
+ kind: 'browser_signin_ended',
174
+ summary: 'The person confirmed: give the browser back, and keep the lease and its tab.',
175
+ handler: finishSignInHandler,
176
+ },
177
+ tab_replace: {
178
+ kind: 'tab_closing',
179
+ summary: 'Give up this lease’s tab and take a fresh one, without the count dipping.',
180
+ handler: tabReplaceHandler,
181
+ },
182
+ };
183
+ /** The registered names as data, for the registry test and the ledger. */
184
+ export const ARBITRATION_NAMES = Object.keys(ARBITRATION_OPERATIONS);
185
+ /**
186
+ * Expire every lapsed claim and every lapsed queue entry, across the whole
187
+ * store, and collect the tabs they held.
188
+ *
189
+ * **This is what makes even a question a write** (§1.0a, §2.4). It is not
190
+ * conditional, not skippable and not scoped to the caller, and those three
191
+ * properties are the standing invariant rather than three separate choices.
192
+ *
193
+ * ── Why the lapse time is computed rather than stamped ──────────────────
194
+ *
195
+ * §2.4a: `claims.expired_at` is when the lease *lapsed*, which is not when
196
+ * the sweep noticed. A lease whose caller stopped talking lapsed at its own
197
+ * expiry, whether the next caller arrived one second later or forty minutes
198
+ * later. Stamping the sweep's own moment produces a record in which leases
199
+ * expire in clusters at instants when nothing happened to them — an artifact
200
+ * of the observer, and a bad kind, because it is a strong, clean, entirely
201
+ * fictitious pattern.
202
+ *
203
+ * So `expired_at` is set to the row's own `expires_at`, which is the last
204
+ * renewal plus the duration that was in force. The ledger row carries the
205
+ * sweep's moment, and says so by being a ledger row.
206
+ */
207
+ /**
208
+ * Give back a browser whose **requested** sign-in nobody answered.
209
+ *
210
+ * ── Why this is a second sweep and not a branch inside the first ────────
211
+ *
212
+ * The claim sweep reconciles leases: it reads `claims`, expires the lapsed
213
+ * ones and collects their tabs. This reconciles a **browser**, on a different
214
+ * table, against a different column, producing a different ledger kind. Folded
215
+ * into `sweep` it would make that function's contract two things — which is
216
+ * how its early return for `lapsed.length === 0` would have silently skipped
217
+ * this entirely, since a browser can be holding a stale sign-in on a store
218
+ * where no lease lapsed at all.
219
+ *
220
+ * ── Why it belongs in the transaction rather than on a timer ────────────
221
+ *
222
+ * §2.4's whole model: **capacity comes back lazily, globally, on every call.**
223
+ * Nothing here runs a scheduler, and there is no process to run one on — the
224
+ * service is spawned by its caller and exits with it (§1.0a). So a deadline is
225
+ * enforced the same way an expiry is: the next caller reconciles it, inside
226
+ * the transaction that has already opened, before any handler reads the row.
227
+ * That is what lets a handler read `state` directly and be right.
228
+ *
229
+ * ── What it will not do ─────────────────────────────────────────────────
230
+ *
231
+ * **It never touches a sign-in held by a process.** `signin_deadline` is null
232
+ * for every `broker login`, which is precisely the sign-in §5.5.1 says must
233
+ * not expire — *"a person takes as long as they take, and a timeout would end
234
+ * a sign-in that was going fine."* The column is the discriminator, and a
235
+ * sweep written against `state = 'signing-in'` alone would have ended exactly
236
+ * the sign-ins that rule protects.
237
+ *
238
+ * **And it never touches the asking lease.** A request that lapses gives the
239
+ * browser back and leaves the caller holding its tab, still live, still
240
+ * renewing. It has lost the request, not the work — the caller finds out by
241
+ * being refused the next time it tries to finish, and can ask again.
242
+ */
243
+ function lapseUnansweredSignIns(db, now, adapter) {
244
+ const lapsed = db
245
+ .prepare(`UPDATE browsers
246
+ SET state = CASE WHEN pid IS NULL THEN 'stopped' ELSE 'running' END,
247
+ signin_deadline = NULL,
248
+ signin_claim_id = NULL,
249
+ updated_at = @now
250
+ WHERE state = 'signing-in'
251
+ -- **Not null, which is the whole guard.** A sign-in held by a
252
+ -- process has no deadline, and must not acquire one here.
253
+ AND signin_deadline IS NOT NULL
254
+ AND signin_deadline <= @now
255
+ RETURNING id AS browserId, state AS nextState, signin_claim_id AS claimId`)
256
+ .all({ now });
257
+ for (const row of lapsed) {
258
+ // One row per decision (§1.6), and it says it was a lapse rather than a
259
+ // person finishing — a run of these reads as requests nobody answered,
260
+ // which is exactly the pattern somebody tuning the deadline would want.
261
+ append(db, {
262
+ kind: 'browser_signin_ended',
263
+ outcome: 'allow',
264
+ adapter,
265
+ browserId: row.browserId,
266
+ detail: {
267
+ lapsed: true,
268
+ state: row.nextState,
269
+ claimId: row.claimId,
270
+ reason: 'the sign-in this caller asked for was not answered before its deadline',
271
+ },
272
+ });
273
+ }
274
+ }
275
+ function sweep(db) {
276
+ const sweptAt = db.prepare("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now') AS now").get();
277
+ const now = sweptAt.now;
278
+ // Read before writing, inside the transaction. Which tabs the lapsed claims
279
+ // held cannot be read after the update on the claims alone — the claims are
280
+ // still there — but reading first keeps the two statements over one set of
281
+ // rows rather than over whatever the second statement re-derives.
282
+ const lapsed = db
283
+ .prepare(`SELECT id FROM claims
284
+ WHERE state IN ('queued', 'active') AND expires_at <= @now
285
+ ORDER BY id`)
286
+ .all({ now });
287
+ if (lapsed.length === 0) {
288
+ return { expiredClaimIds: [], orphanedTabs: [], sweptAt: now };
289
+ }
290
+ const placeholders = lapsed.map(() => '?').join(', ');
291
+ const ids = lapsed.map((row) => row.id);
292
+ const orphanedTabs = db
293
+ .prepare(`SELECT id AS tabId, claim_id AS claimId, browser_id AS browserId
294
+ FROM tabs
295
+ WHERE claim_id IN (${placeholders}) AND state IN ('opening', 'open')
296
+ ORDER BY id`)
297
+ .all(...ids);
298
+ // Positional parameters throughout, not a mix. The driver refuses a
299
+ // statement that carries both spellings, and the list of identifiers has to
300
+ // be positional because its length varies per call.
301
+ db.prepare(`UPDATE claims
302
+ SET state = 'expired',
303
+ -- Section 2.4a: when it lapsed, not when this noticed.
304
+ expired_at = expires_at,
305
+ ended_at = expires_at,
306
+ updated_at = ?
307
+ WHERE id IN (${placeholders})`).run(now, ...ids);
308
+ // The tab rows follow their claims, and which state they follow into
309
+ // depends on whether there is anything to ask a browser about.
310
+ //
311
+ // §1.4 defines the two precisely, and the definition decides this rather
312
+ // than a preference: `closing` is "the honest representation of *the tool
313
+ // was asked and has not answered*", and it is what stops **a page that may
314
+ // still exist** being counted as free.
315
+ //
316
+ // - **A tab with a driver name was opened.** A page exists, the tool will be
317
+ // asked to close it after the commit, and until it answers `closing` is
318
+ // the only honest thing to say.
319
+ // - **A tab with no driver name was never opened.** Nothing was asked,
320
+ // because there is nothing to ask about — no page exists and none ever
321
+ // did. Calling that `closing` would assert an outstanding round trip that
322
+ // is not outstanding, and would leave the row waiting forever for an
323
+ // answer nobody is coming to give.
324
+ //
325
+ // So the second goes straight to `closed`, which is also what the schema
326
+ // requires: `CHECK ((state = 'opening') = (driver_tab_id IS NULL))` permits
327
+ // a null driver name only on `opening`, and a `closed` row with one is
328
+ // exactly as consistent as an `open` row with one. **The constraint is
329
+ // right and it caught a genuine error**, rather than being an obstacle to
330
+ // route around — a tab moved to `closing` with nothing to close is a claim
331
+ // about the world that is false.
332
+ // What comes back is the subset a browser still owes an answer about. The
333
+ // rest are already `closed`, so scheduling a close for them would ask the
334
+ // driver to shut a page that never existed.
335
+ const pendingCloses = updateSweptTabs(db, orphanedTabs, now);
336
+ return { expiredClaimIds: ids, orphanedTabs: pendingCloses, sweptAt: now };
337
+ }
338
+ /**
339
+ * Move tabs out of a lease that has ended, into the state that is true of
340
+ * each.
341
+ *
342
+ * **Exported because release needs exactly this rule** (§3.4) and two writers
343
+ * spelling it separately is how they come to disagree. The defect this
344
+ * function exists to make impossible was precisely that: the sweep and
345
+ * release each moved every tab to `closing`, and every tab this build creates
346
+ * has no driver name, so both violated the schema's own check on the ordinary
347
+ * path.
348
+ *
349
+ * Returns the tabs that still need a browser round trip — which is **not**
350
+ * every tab handed in. A tab that never opened has nothing to close, so
351
+ * scheduling one would be asking the driver to close a page that does not
352
+ * exist.
353
+ */
354
+ export function updateSweptTabs(db, tabs, now) {
355
+ if (tabs.length === 0) {
356
+ return [];
357
+ }
358
+ const ids = tabs.map((tab) => tab.tabId);
359
+ const placeholders = ids.map(() => '?').join(', ');
360
+ // Opened, so a page exists and the tool has to be asked. `closing` until it
361
+ // answers.
362
+ db.prepare(`UPDATE tabs
363
+ SET state = 'closing', updated_at = ?
364
+ WHERE id IN (${placeholders})
365
+ AND state IN ('opening', 'open')
366
+ AND driver_tab_id IS NOT NULL`).run(now, ...ids);
367
+ // Never opened, so there is nothing to ask and nothing to wait for. The
368
+ // close time is this moment because the tab is over now, not when some
369
+ // round trip that will never happen would have returned.
370
+ db.prepare(`UPDATE tabs
371
+ SET state = 'closed', closed_at = ?, updated_at = ?
372
+ WHERE id IN (${placeholders})
373
+ AND state = 'opening'
374
+ AND driver_tab_id IS NULL`).run(now, now, ...ids);
375
+ // Only the ones a browser still owes an answer about.
376
+ const pending = db
377
+ .prepare(`SELECT id AS tabId, claim_id AS claimId, browser_id AS browserId
378
+ FROM tabs
379
+ WHERE id IN (${placeholders}) AND state = 'closing'
380
+ ORDER BY id`)
381
+ .all(...ids);
382
+ return pending;
383
+ }
384
+ /**
385
+ * Record what the sweep did, on the call that performed it.
386
+ *
387
+ * §1.6's `internal` adapter, and the reason it exists: "that last one is not a
388
+ * background job — with no long-lived process there is nothing running in the
389
+ * background — so a sweep is attributed to the call that performed it". The
390
+ * adapter recorded here is therefore the **caller's**, not `internal`; the
391
+ * kind is what says this was a sweep.
392
+ *
393
+ * **A sweep that found nothing writes no row.** The ledger records decisions,
394
+ * and finding nothing to expire is not one — a row per call on a quiet
395
+ * installation would be the ledger's largest category and would carry no
396
+ * information. What is recorded is each claim that actually expired, which is
397
+ * a decision about that lease.
398
+ */
399
+ function recordSweep(scope, swept) {
400
+ for (const claimId of swept.expiredClaimIds) {
401
+ append(scope.db, {
402
+ kind: 'claim_expired',
403
+ outcome: 'allow',
404
+ adapter: scope.adapter,
405
+ claimId,
406
+ detail: {
407
+ sweptAt: swept.sweptAt,
408
+ orphanedTabs: swept.orphanedTabs.filter((tab) => tab.claimId === claimId).length,
409
+ },
410
+ });
411
+ }
412
+ }
413
+ /**
414
+ * Dispatch one arbitration operation: sweep, answer, commit, then close.
415
+ *
416
+ * **The only way an arbitration operation is invoked.** Everything in the
417
+ * shape that must not vary lives here rather than in the operations — the
418
+ * transaction, the sweep, the ledger row for what the sweep did, and the
419
+ * ordering of all three — so an operation cannot get the order wrong by
420
+ * writing it differently. What an operation supplies is step 2 alone.
421
+ *
422
+ * The transaction is opened by `immediate` from `transaction.ts` and by
423
+ * nothing else in this module. There is no second path, no fast path and no
424
+ * option that skips it, which is the whole of what
425
+ * `arbitration.immediate_transaction` can be enforced against on this side —
426
+ * the check itself explains what it can and cannot prove.
427
+ */
428
+ export async function runArbitration(options) {
429
+ // Read as an untyped record so an unregistered name is a lookup returning
430
+ // nothing rather than a type error at this site: the refusal below is what
431
+ // handles it, and it has to be reachable at run time for a caller on a
432
+ // different build. The per-operation input types are preserved on the
433
+ // registry itself, which is what makes an internal call site type-safe.
434
+ const operation = ARBITRATION_OPERATIONS[options.name];
435
+ if (operation === undefined) {
436
+ // Refused before the transaction opens, deliberately. An unregistered
437
+ // name is not a decision about capacity and there is nothing to sweep on
438
+ // behalf of — opening a transaction to refuse it would serialise every
439
+ // caller on the machine behind a mistyped operation name.
440
+ throw new CallRefusal('unknown_operation', `There is no arbitration operation named ${JSON.stringify(options.name)}. This build registers ${ARBITRATION_NAMES.length === 0 ? 'none yet' : ARBITRATION_NAMES.join(', ')}.`, { detail: { requested: options.name, registered: ARBITRATION_NAMES } });
441
+ }
442
+ // Refusals collected inside the transaction and written after it rolls
443
+ // back. A refusal is a decision (§1.6) and it is the one thing about a
444
+ // refused call that was not undone, so it must outlive the rollback its own
445
+ // throw causes.
446
+ const refusals = [];
447
+ try {
448
+ return await options.store.immediate(async ({ db }) => {
449
+ // Step 1, always, before anything the operation does. Unconditional is
450
+ // the point: this is what makes the transaction a writer even when the
451
+ // operation only asks a question (section 1.0a).
452
+ const swept = sweep(db);
453
+ // And the second reconciliation, for the same reason and in the same
454
+ // place: a sign-in a caller asked for and nobody answered has lapsed,
455
+ // and a browser still reading `signing-in` because of it would refuse
456
+ // every caller over a person who is not coming. See its own header for
457
+ // why this is separate from the claim sweep rather than folded into it.
458
+ lapseUnansweredSignIns(db, swept.sweptAt, options.adapter);
459
+ // What the operation asks to have closed, collected inside and acted on
460
+ // outside. A list rather than a call: nothing here can reach a browser,
461
+ // so a handler cannot turn a schedule into a round trip.
462
+ const scheduled = [];
463
+ const scope = {
464
+ db,
465
+ swept,
466
+ adapter: options.adapter,
467
+ closeAfterCommit: (tab) => {
468
+ scheduled.push(tab);
469
+ },
470
+ recordRefusal: (event) => {
471
+ refusals.push(event);
472
+ },
473
+ };
474
+ recordSweep(scope, swept);
475
+ // Step 2: the operation answers from the reconciled state.
476
+ const outcome = (await operation.handler(scope, options.input));
477
+ // Step 3 is handed to the transaction helper, which runs it after the
478
+ // commit and outside every transaction. The sweep's own orphaned tabs
479
+ // are scheduled here rather than by the operation, because an operation
480
+ // that had to remember to close them is an operation that can forget —
481
+ // and the sweep is not its work in the first place.
482
+ //
483
+ // The sweep's closes go first: they are reclamation of capacity that
484
+ // has already come back, and an operation's own after-commit work may
485
+ // well be opening the tab that capacity is for.
486
+ const closeTab = options.closeTab;
487
+ const closes = closeTab === undefined
488
+ ? []
489
+ : [...swept.orphanedTabs, ...scheduled].map((tab) => () => closeTab(tab));
490
+ return {
491
+ value: outcome.value,
492
+ afterCommit: [...closes, ...(outcome.afterCommit ?? [])],
493
+ };
494
+ });
495
+ }
496
+ finally {
497
+ // **After the transaction, whichever way it went.** On the ordinary path
498
+ // this list is empty and the block does nothing. On a refusal the
499
+ // transaction has rolled back, so these rows are written on their own —
500
+ // which is what makes a refused decision recorded rather than erased by
501
+ // the very refusal it describes (§1.6).
502
+ //
503
+ // `finally` rather than a catch, because a guard is free to record a
504
+ // refusal and then let the call succeed anyway — the nudge is exactly
505
+ // that shape — and a catch would drop the row on the path that did not
506
+ // throw.
507
+ writeRefusals(options.store, refusals);
508
+ }
509
+ }
510
+ /**
511
+ * Write the collected refusal rows, outside the transaction that produced
512
+ * them.
513
+ *
514
+ * **Failure here is swallowed, deliberately, and this is a real trade rather
515
+ * than an oversight.** The caller is already receiving a refusal that names
516
+ * the rule and says what to do next; turning a failure to *record* that
517
+ * refusal into a second, different error would replace an actionable answer
518
+ * with an unactionable one, and the caller would be left unable to tell which
519
+ * of the two actually decided its call.
520
+ *
521
+ * **What that costs is a refusal missing from the ledger** under conditions
522
+ * that also lose ordinary writes. The alternative costs the caller its
523
+ * answer, which is worse.
524
+ */
525
+ function writeRefusals(store, refusals) {
526
+ if (refusals.length === 0) {
527
+ return;
528
+ }
529
+ try {
530
+ for (const refusal of refusals) {
531
+ append(store.db, refusal);
532
+ }
533
+ }
534
+ catch {
535
+ // See above: the caller's refusal is the more useful of the two answers.
536
+ }
537
+ }
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs/promises';
2
+ import { findComparison } from "./comparison-store.js";
3
+ /**
4
+ * The one sentence a caller gets for anything it is not entitled to see.
5
+ *
6
+ * §1.9: artifacts belonging to another lease are refused "with the same
7
+ * non-disclosing wording as an unknown tab (§7.1) **so probing cannot discover
8
+ * another lease's files**". §7.1 makes the same collapse for `tab.owned` and
9
+ * `tab.open` — two rules, one message — and the reason transfers exactly: a
10
+ * caller able to tell "not yours" from "does not exist" is a caller able to
11
+ * enumerate what exists.
12
+ *
13
+ * **So this string is used for both, and that is load-bearing rather than
14
+ * lazy.** A test asserts the two are byte-identical.
15
+ */
16
+ export const ARTIFACT_NOT_FOUND_MESSAGE = 'No artifact with that identifier belongs to this lease.';
17
+ function notFound() {
18
+ return { served: false, refusal: { reason: 'not_found', message: ARTIFACT_NOT_FOUND_MESSAGE } };
19
+ }
20
+ /**
21
+ * Resolve a request to the stored path it names, checking ownership.
22
+ *
23
+ * Split out from the read so the ownership check is a single expression per
24
+ * variant and visibly precedes every filesystem call — the `browser_read`
25
+ * shape §7.1 calls "the rejection asserts the physical side-effect": a path
26
+ * that is never resolved cannot be read by accident further down.
27
+ */
28
+ function resolveRequest(options) {
29
+ const { db, claimId, request } = options;
30
+ if (request.kind === 'capture') {
31
+ const capture = options.captures.find(request.captureId);
32
+ if (capture === null || capture.claimId !== claimId) {
33
+ return null;
34
+ }
35
+ return capture.path;
36
+ }
37
+ const comparison = findComparison(db, request.comparisonId);
38
+ if (comparison === null || comparison.claimId !== claimId) {
39
+ return null;
40
+ }
41
+ if (request.kind === 'overlay') {
42
+ return comparison.overlayPath;
43
+ }
44
+ const region = comparison.regions[request.index];
45
+ if (region === undefined) {
46
+ return null;
47
+ }
48
+ return request.side === 'before' ? region.beforePath : region.afterPath;
49
+ }
50
+ /**
51
+ * Serve one artifact's bytes.
52
+ *
53
+ * Returns an outcome rather than throwing, because "you named something that
54
+ * is not yours or is not there" is an ordinary answer on this surface and a
55
+ * caller has to be able to branch on it without catching.
56
+ */
57
+ export async function fetchArtifact(options) {
58
+ const stored = resolveRequest(options);
59
+ if (stored === null) {
60
+ return notFound();
61
+ }
62
+ // The one join of a stored path to a location in this file, and it goes
63
+ // through the artifact store so the containment assertion cannot be skipped
64
+ // and cannot be a second, weaker copy of itself. A throw from here is a
65
+ // constructed path that is wrong — our bug, not a caller's — and it is
66
+ // deliberately not caught.
67
+ const absolute = options.artifacts.resolve(stored);
68
+ try {
69
+ const bytes = await fs.readFile(absolute);
70
+ return { served: true, artifact: { bytes: new Uint8Array(bytes), path: stored } };
71
+ }
72
+ catch {
73
+ // A row exists and its file does not. §6.2 says nothing sweeps an image,
74
+ // so this is a tree somebody deleted by hand rather than an expiry — and
75
+ // it is worth a different sentence from "not found", because the caller
76
+ // named something real and there is nothing it can do differently.
77
+ return {
78
+ served: false,
79
+ refusal: {
80
+ reason: 'unreadable',
81
+ message: `The artifact at ${stored} is recorded but could not be read from the artifact root.`,
82
+ },
83
+ };
84
+ }
85
+ }