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,120 @@
1
+ import { readDiffSettings } from "../diff/settings.js";
2
+ import { runArbitration } from "./arbitration.js";
3
+ import { createPendingSeeds } from "./pending-seeds.js";
4
+ /**
5
+ * Bind a service to one store, one environment snapshot and one adapter.
6
+ *
7
+ * The settings are read from the environment **here, once**, and handed to
8
+ * every operation on its input. §6.3: one snapshot per process, so every rule
9
+ * inside one operation sees one configuration — a handler that read the
10
+ * environment itself would be a second snapshot taken at a different instant,
11
+ * inside a transaction other callers are waiting behind.
12
+ */
13
+ export function createBroker(options) {
14
+ const settings = {
15
+ tabBudget: options.environment.tabBudget,
16
+ leaseSeconds: options.environment.leaseSeconds,
17
+ queueSeconds: options.environment.queueSeconds,
18
+ regularBrowsers: options.environment.regularBrowsers,
19
+ privateBrowsers: options.environment.privateBrowsers,
20
+ };
21
+ // The same one-per-process snapshot the arbitration settings are, taken
22
+ // here for the same reason (§6.3). A bad value refuses the spawn rather
23
+ // than one capture, which is `readDiffSettings`'s own documented split.
24
+ const diffSettings = options.diffSettings ?? readDiffSettings();
25
+ const run = (name, input) => runArbitration({
26
+ store: options.store,
27
+ name,
28
+ adapter: options.adapter,
29
+ input,
30
+ ...(options.closeTab === undefined ? {} : { closeTab: options.closeTab }),
31
+ });
32
+ /**
33
+ * The browser connection, added to the six inputs that can use one.
34
+ *
35
+ * **Added here rather than by each adapter**, which is the same rule the
36
+ * bridge holds itself to: a route shapes arguments and names an operation,
37
+ * and whether a browser is reached is not an argument a caller passes. A
38
+ * surface that could omit it would be a surface on which a page verb
39
+ * silently did nothing, and the two surfaces would differ in what they
40
+ * actually did while agreeing on what they returned — the exact failure
41
+ * §8's parity assertion exists to catch.
42
+ */
43
+ /**
44
+ * Seeds a granted claim asked for, waiting for its tab's first open.
45
+ *
46
+ * **One store per broker, which is one per process** (§1.0 — the service is
47
+ * spawned by its caller and exits with it). The values are credentials and
48
+ * are deliberately never written to the store; `pending-seeds.ts` sets out
49
+ * why, and what a lease granted in one process and driven from another
50
+ * therefore does not get.
51
+ */
52
+ const pendingSeeds = createPendingSeeds();
53
+ const withBrowser = (input) => ({
54
+ ...input,
55
+ ...(options.session === undefined ? {} : { session: options.session }),
56
+ // Handed to every tab verb rather than only to the one that opens the
57
+ // page, because **which verb opens the page is not knowable here**: a
58
+ // lease's first call may be a navigate, a read, a capture or any other,
59
+ // and `pageFor` is what discovers it is the first. Supplying it only to
60
+ // one would make seeding depend on which verb a caller happened to reach
61
+ // for — the same class of defect as a rule holding on one route.
62
+ pendingSeeds,
63
+ });
64
+ /**
65
+ * The browser connection **and** somewhere to write, for the two verbs that
66
+ * produce a file: a capture always, and an evaluation whose result is past
67
+ * the inline cap (§3.10).
68
+ *
69
+ * Added here for the same reason {@link withBrowser} is: which store an
70
+ * artefact lands in is not an argument a caller passes, and a surface that
71
+ * could omit it would be a surface on which a large evaluation silently
72
+ * had nowhere to go.
73
+ */
74
+ const withBrowserAndArtifacts = (input) => ({
75
+ ...withBrowser(input),
76
+ ...(options.artifacts === undefined ? {} : { artifacts: options.artifacts }),
77
+ // Read once, above, and handed to every capture — never read inside the
78
+ // handler, for the reason this function's own header gives about the
79
+ // arbitration settings: a second snapshot taken at a different instant
80
+ // would let two rules inside one operation see two configurations.
81
+ diffSettings,
82
+ });
83
+ return {
84
+ claim: async (input) => {
85
+ const result = await run('claim', { ...input, settings });
86
+ // **After the call returns, so only a committed grant holds a seed.**
87
+ // A refused claim throws before reaching here and a queued one carries
88
+ // no entries (`claim.ts`: a queue placement has no tab to seed and the
89
+ // entries are deliberately dropped rather than held across an
90
+ // unbounded wait). So nothing is retained for a lease that does not
91
+ // exist, and a caller cannot leave values in this process by being
92
+ // refused repeatedly.
93
+ if (result.outcome === 'granted') {
94
+ pendingSeeds.put(result.claimId, result.storageSeed);
95
+ }
96
+ return result;
97
+ },
98
+ status: (input) => run('status', input),
99
+ release: (input) => run('release', {
100
+ ...input,
101
+ settings,
102
+ }),
103
+ navigate: (input) => run('navigate', withBrowser(input)),
104
+ act: (input) => run('act', withBrowser(input)),
105
+ read: (input) => run('read', withBrowser(input)),
106
+ evaluate: (input) => run('evaluate', {
107
+ ...withBrowser(input),
108
+ // The artifact store, and deliberately **not** `diffSettings`: an
109
+ // evaluation never diffs, and handing it a setting it cannot use
110
+ // would be the surface declaring an argument that does nothing.
111
+ ...(options.artifacts === undefined ? {} : { artifacts: options.artifacts }),
112
+ }),
113
+ capture: (input) => run('capture', withBrowserAndArtifacts(input)),
114
+ tab_replace: (input) => run('tab_replace', withBrowser(input)),
115
+ begin_sign_in: (input) => run('begin_sign_in', input),
116
+ end_sign_in: (input) => run('end_sign_in', input),
117
+ sign_in: (input) => run('sign_in', input),
118
+ sign_in_done: (input) => run('sign_in_done', input),
119
+ };
120
+ }
@@ -0,0 +1,269 @@
1
+ import path from 'node:path';
2
+ import { profileDirectory } from "../browser/discovery.js";
3
+ import { decideAdoption, recordLaunched, recordLaunchFailed, } from "../browser/adoption.js";
4
+ import { browserIsRunning, modeFor, RealBrowserDriver } from "../browser/real.js";
5
+ import { StartupRefusal } from "../errors.js";
6
+ /**
7
+ * **How a shipped binary gets a browser.** The join between the adoption
8
+ * arbitration and the driver that performs what it decides.
9
+ *
10
+ * ── Why this file exists, stated as the thing that was missing ───────────
11
+ *
12
+ * Every part of this was already built and tested. `decideAdoption` arbitrates
13
+ * the launch race in the same transaction that arbitrates claims;
14
+ * `RealBrowserDriver` attaches to a running browser or cold-starts a detached
15
+ * one; `recordLaunched` and `recordLaunchFailed` close the `starting` window
16
+ * either way. **Nothing in `src/` called any of them.** The adoption module
17
+ * had tests and no production caller, so the store's `browsers` table
18
+ * described a launch race that no shipped code ever ran.
19
+ *
20
+ * That is the gap this closes, and it is closed by *composing* those pieces
21
+ * rather than by writing a second launch path beside them.
22
+ *
23
+ * ── Lazy, and that is load-bearing rather than an optimisation ───────────
24
+ *
25
+ * **Nothing here touches a browser until a page verb actually needs one.** A
26
+ * spawn that acquired a browser eagerly would make every command that reaches
27
+ * the service — `claim`, `status`, `release`, `feedback`, a refusal — depend
28
+ * on a browser installation, and the two most common states of this service
29
+ * are *no browser installed* and *no page driven yet*. It would also make the
30
+ * continuous-integration `operations` job, which spawns the real executables
31
+ * on a runner with no browser at all, depend on one.
32
+ *
33
+ * So {@link browserSessionProvider} returns a function, and the function is
34
+ * what `operations/pages.ts` calls **inside an after-commit closure**. A
35
+ * caller that only ever claims and releases never causes a launch.
36
+ *
37
+ * ── Memoised per process, because adoption is not idempotent ─────────────
38
+ *
39
+ * The six page verbs each resolve a session, and a session is a live
40
+ * connection over the debugging protocol. Resolving one per call would open a
41
+ * new connection per verb and run the adoption transaction per verb — and the
42
+ * second of those is worse than wasteful: the race is decided in the store,
43
+ * so a process that re-entered it would keep re-answering a question it had
44
+ * already answered. One session per browser per process, held until the
45
+ * process exits.
46
+ *
47
+ * The memo holds the **promise**, not the resolved session, so two verbs
48
+ * racing in the same process await one acquisition rather than starting two.
49
+ *
50
+ * ── A failed acquisition is not cached ───────────────────────────────────
51
+ *
52
+ * If acquiring throws, the memo is cleared, so the next call tries again. The
53
+ * alternative — caching the rejection — turns one transient failure (a browser
54
+ * still finishing its start-up, a machine that was briefly out of memory) into
55
+ * a process that can never drive a page again, and the process serves a whole
56
+ * session.
57
+ */
58
+ /**
59
+ * How long a launch-race loser waits before giving up on the winner —
60
+ * documented here, not consulted here.
61
+ *
62
+ * **Not read by `waitForWinner`.** `environment.launchReadinessTimeoutSeconds`
63
+ * (`BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS`, `src/config/environment.ts`) is
64
+ * the number that actually governs a spawn — `environment` is a required
65
+ * field on {@link BrowserSessionProviderOptions}, so every caller that builds
66
+ * a provider supplies one. This constant has no production consumer; it is
67
+ * kept as a doc anchor equal to that variable's default (30s) so the two
68
+ * cannot silently drift apart, and a test may still use it as a readable
69
+ * stand-in for "the default" without hardcoding `30_000` a second time.
70
+ */
71
+ export const WAIT_TIMEOUT_MS = 30_000;
72
+ /** How often a launch-race loser re-asks whether the winner's browser is up. */
73
+ export const WAIT_POLL_INTERVAL_MS = 100;
74
+ export function browserSessionProvider(options) {
75
+ const driver = options.driver ??
76
+ new RealBrowserDriver({
77
+ ...(options.artifacts === undefined
78
+ ? {}
79
+ : // The driver names a file; the store decided the directory. See
80
+ // `RealDriverOptions.outputDirectory` for why the split is the rule
81
+ // rather than a preference, and {@link
82
+ // BrowserSessionProviderOptions.artifacts} for why this is the
83
+ // shared tree rather than one lease's.
84
+ { outputDirectory: path.join(options.artifacts.root, 'snapshots') }),
85
+ // **The signed-in engine, carried from this process's one environment
86
+ // snapshot** (§6.3, `DECISIONS.md` §13i). One driver serves every
87
+ // browser in this process, so the kind-specific engine cannot be chosen
88
+ // per session here — see the note on `RealDriverOptions.engine` for
89
+ // what the value does and does not do, which is what makes one engine
90
+ // per process the honest shape rather than a shortcut.
91
+ engine: options.environment.regularBrowserEngine,
92
+ });
93
+ const inFlight = new Map();
94
+ const settled = new Map();
95
+ const session = (browser) => {
96
+ const existing = inFlight.get(browser);
97
+ if (existing !== undefined) {
98
+ return existing;
99
+ }
100
+ const acquiring = acquire(driver, browser, options)
101
+ .then((acquired) => {
102
+ settled.set(browser, acquired);
103
+ return acquired;
104
+ })
105
+ .catch((error) => {
106
+ // Not cached. See the header: a cached rejection would end this
107
+ // process's ability to drive a page for the rest of its life.
108
+ inFlight.delete(browser);
109
+ throw error;
110
+ });
111
+ inFlight.set(browser, acquiring);
112
+ return acquiring;
113
+ };
114
+ return {
115
+ session,
116
+ close: async () => {
117
+ for (const open of settled.values()) {
118
+ try {
119
+ await open.detach();
120
+ }
121
+ catch {
122
+ // The browser is adopted and outlives this process regardless.
123
+ }
124
+ }
125
+ settled.clear();
126
+ inFlight.clear();
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Perform one adoption: ask the store what to do, then do it.
132
+ *
133
+ * ── The order is the rule, and it is the reason this is not two lines ────
134
+ *
135
+ * `SCHEMA.md` §2.4b: no browser work inside the arbitration transaction. So
136
+ * the observation is made **before** the transaction opens, the transaction
137
+ * decides, and the launch happens **after** it commits. `adoption.ts` says the
138
+ * same thing from the other side — its `runningRecord` is a parameter *"for
139
+ * one reason: reading it means talking to a browser, and this runs inside the
140
+ * transaction"*.
141
+ *
142
+ * The consequence, which `decideAdoption` also states: the observation can be
143
+ * stale by the time the transaction opens, and the `starting` state is what
144
+ * covers the gap rather than the observation being made more carefully.
145
+ */
146
+ async function acquire(driver, browser, options) {
147
+ const isRunning = options.isRunning ?? browserIsRunning;
148
+ const profileDir = profileDirectory(options.environment.profileRoot, browser);
149
+ const observed = await isRunning(profileDir);
150
+ const decision = await decideAdoption(options.store, browser, observed === undefined || observed.browserUuid === undefined
151
+ ? undefined
152
+ : { endpoint: observed.endpoint, browserUuid: observed.browserUuid });
153
+ return performDecision(driver, decision, profileDir, options);
154
+ }
155
+ /** Do what the transaction decided, outside it. */
156
+ async function performDecision(driver, decision, profileDir, options) {
157
+ const { browser } = decision;
158
+ if (decision.action === 'attach') {
159
+ return driver.attach(browser, {
160
+ endpoint: decision.endpoint,
161
+ browserUuid: decision.browserUuid,
162
+ });
163
+ }
164
+ if (decision.action === 'wait') {
165
+ return waitForWinner(driver, browser, profileDir, options);
166
+ }
167
+ // This caller won the race, and the row says `starting` until it says
168
+ // otherwise. **Both outcomes have to be recorded**: `adoption.ts` is
169
+ // explicit that a winner which fails and does not release the race leaves
170
+ // every later caller waiting for a launch that is never coming.
171
+ try {
172
+ const started = await driver.coldStart({
173
+ browser,
174
+ profileDirectory: profileDir,
175
+ mode: modeFor(browser),
176
+ });
177
+ const described = started.describe();
178
+ await recordLaunched(options.store, browser, {
179
+ pid: described.pid,
180
+ endpoint: described.discovery.endpoint,
181
+ // A session that connected has read the browser's own identifier, so
182
+ // this is the verified one rather than the one off disk.
183
+ browserUuid: described.discovery.browserUuid ?? '',
184
+ });
185
+ return started;
186
+ }
187
+ catch (error) {
188
+ await recordLaunchFailed(options.store, browser);
189
+ throw error;
190
+ }
191
+ }
192
+ /**
193
+ * Wait for the caller that won the race, and attach to what it started.
194
+ *
195
+ * ── Row #55, settled ──────────────────────────────────────────────────────
196
+ *
197
+ * `SCHEMA.md` §1.2b named two candidate moments and refused to conflate
198
+ * them: the winner **recording** that it launched, and the winner's
199
+ * debugging endpoint **accepting a connection**. Only the second is what a
200
+ * loser can actually attach to, so only the second is what this waits for.
201
+ *
202
+ * **The signal is `browserIsRunning`, unchanged** — §1.2c's own liveness
203
+ * (the endpoint answers) plus identity (the browser's own identifier
204
+ * matches, because ports are reused) check, the one every attach in this
205
+ * file already trusts. This row does not add a second verification: it is
206
+ * `readDiscoveryRecord` + `verifyDiscoveryRecord` under `src/browser/real.ts`,
207
+ * called here exactly as `acquire`'s own observation calls it. A caller that
208
+ * wrote a second check here would be trusting a claim by two different
209
+ * routes that could disagree; there is one route.
210
+ *
211
+ * **The bound is `environment.launchReadinessTimeoutSeconds`**
212
+ * (`BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS`, `.env.example`) — a poll
213
+ * ceiling, never a sleep. Somebody has to do something when the decision
214
+ * comes back `wait`, and there are exactly three options: return a session
215
+ * that is not one, block forever, or poll with a ceiling. The first is the
216
+ * dishonesty this whole row exists to remove and the second turns one failed
217
+ * launch elsewhere into a hung process here — so this polls, and when the
218
+ * ceiling is reached it **refuses, naming what it waited for**, rather than
219
+ * proceeding as if it had a browser. A `StartupRefusal` is the same shape
220
+ * every other refusal in this module takes, and its message says a caller is
221
+ * *still starting*, never that the browser *died* — those are different
222
+ * facts and this function has evidence for only one of them.
223
+ *
224
+ * It is deliberately the most minimal shape that can be correct:
225
+ *
226
+ * - **It launches nothing.** A loser that started its own browser would be the
227
+ * second process against one profile directory, which is the measured
228
+ * silent-collision failure the race exists to prevent.
229
+ * - **It concludes nothing from the ceiling.** Timing out is reported as *this
230
+ * caller stopped waiting*, not as *the winner failed*. Nothing here writes
231
+ * to the `browsers` row, so the winner keeps its race and the next caller
232
+ * asks the same question again.
233
+ * - **It is the ordinary positive test.** The loop asks `browserIsRunning`,
234
+ * which verifies the endpoint answers **and** that the browser identifies
235
+ * itself as the one on the record — the same check every other path here
236
+ * makes, and the reason a stale record plus a reused port cannot be mistaken
237
+ * for a live browser.
238
+ *
239
+ * `sleepImpl`/`nowImpl` are injected, following the seam convention
240
+ * `launch.ts` established for `fetchImpl`/`spawnImpl`/`killImpl`: a test that
241
+ * proves "refuses at the bound" by actually waiting out a 30-second default
242
+ * would be a slow, flaky proxy for the same assertion a fake clock makes
243
+ * instantly and exactly.
244
+ */
245
+ async function waitForWinner(driver, browser, profileDir, options) {
246
+ const isRunning = options.isRunning ?? browserIsRunning;
247
+ const timeoutMs = options.waitTimeoutMs ?? options.environment.launchReadinessTimeoutSeconds * 1000;
248
+ const intervalMs = options.waitPollIntervalMs ?? WAIT_POLL_INTERVAL_MS;
249
+ const sleep = options.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
250
+ const now = options.nowImpl ?? Date.now;
251
+ const deadline = now() + timeoutMs;
252
+ for (;;) {
253
+ // §1.2c's two checks, unchanged: liveness (the endpoint answers) and
254
+ // identity (the browser's own identifier matches the record, since ports
255
+ // are reused). A record that fails either is stale, and a stale record is
256
+ // reported here exactly as "not yet reachable" — indistinguishable from
257
+ // the winner still starting, which is the honest state: this function
258
+ // cannot tell "not up yet" from "up as something else" apart from "not up
259
+ // yet", and it does not pretend to.
260
+ const record = await isRunning(profileDir);
261
+ if (record !== undefined && record.browserUuid !== undefined) {
262
+ return driver.attach(browser, record);
263
+ }
264
+ if (now() >= deadline) {
265
+ throw new StartupRefusal('launch.explicit_profile_dir', `Another caller is starting the ${browser} browser and it did not become reachable within ${String(timeoutMs)}ms. This is a caller that is still starting, not a browser that has died: nothing here observed a failure, only the absence of a verified discovery record before the bound. Nothing was launched here either way: a second browser against one profile directory hands its address to the first and opens no endpoint of its own. Try again — the browser may still be starting.`);
266
+ }
267
+ await sleep(intervalMs);
268
+ }
269
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Admission: one integer against one integer.
3
+ *
4
+ * §2.3 reduces the whole capacity model to one sentence, and everything in
5
+ * this module is a consequence of it:
6
+ *
7
+ * > **Capacity, grants and tabs are the same integer.** The pool bound *is*
8
+ * > the tab budget *is* the count of live claims. **Need two tabs? Claim
9
+ * > twice.**
10
+ *
11
+ * ── The arithmetic a reader expects, and will not find ──────────────────
12
+ *
13
+ * Stated as an absence rather than left to be discovered, because each is a
14
+ * mechanism somebody may arrive looking for (§2.3, `MILESTONES.md` #12):
15
+ *
16
+ * - **No request size.** A claim is one tab. There is no requested-count term
17
+ * to add to the count, and anyone implementing a `tabs` argument on the
18
+ * claim call is implementing a design that was deleted rather than
19
+ * re-tuned.
20
+ * - **No per-lease allowance.** It would be the one number in this design
21
+ * answering to no prior decision, invented to stop one caller taking
22
+ * everything, and with a grant of one there is nothing for it to bound.
23
+ * - **No reservation.** Nothing is held for a tab that has not opened,
24
+ * because the claim row **is** the capacity. There is no window in which
25
+ * granted and existing could differ and therefore no pair of numbers that
26
+ * could disagree.
27
+ *
28
+ * **So there is one predicate and it is one comparison.** A function taking a
29
+ * count and a budget would be the natural place for a future size term to
30
+ * arrive; this one takes a database handle and reads the count itself, so the
31
+ * only thing a caller can vary is which store it asks.
32
+ */
33
+ /**
34
+ * Count the claims that hold a tab, which is what capacity is a count of.
35
+ *
36
+ * Reads the index-only partial index over the live claims (§1.11), which is
37
+ * why the filter is spelled to match it: the answer comes out of the index
38
+ * without touching the table, and this count is read inside the transaction
39
+ * every arbitration call opens, with every other caller on the machine
40
+ * waiting behind it.
41
+ */
42
+ export function countActiveClaims(db) {
43
+ const row = db.prepare("SELECT count(*) AS n FROM claims WHERE state = 'active'").get();
44
+ return row.n;
45
+ }
46
+ /**
47
+ * The admission predicate, entire: `count of live claims + 1 <= budget`.
48
+ *
49
+ * The `+ 1` is the claim being decided, and it is written out rather than
50
+ * folded into a `<` because §2.3 and `MILESTONES.md` #12 both spell it this
51
+ * way — the one place the arithmetic of this design appears, spelled as its
52
+ * specification spells it, so a reader comparing the two is comparing the
53
+ * same sentence.
54
+ *
55
+ * **This reads a count the same transaction has just reconciled** (§7.1,
56
+ * `capacity.admission`), so it can never admit against capacity held by a
57
+ * lease that has already lapsed. That property is the runner's — it sweeps
58
+ * before any handler runs — and not something this function can check.
59
+ */
60
+ export function admits(activeClaims, budget) {
61
+ return activeClaims + 1 <= budget;
62
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The seam between the comparison feature and the capture pipeline.
3
+ *
4
+ * ══════════════════════════════════════════════════════════════════════════
5
+ * WHY A SEAM AT ALL, NOW THAT THE CAPTURE PIPELINE EXISTS
6
+ * ══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * The capture pipeline is built (`src/capture/pipeline.ts`), so this is no
9
+ * longer standing in for something absent. It stays because **the direction of
10
+ * the dependency is a rule the build enforces**, and an interface is what makes
11
+ * the rule expressible.
12
+ *
13
+ * `capture.no_diff_dependency` (§7.3): "**no capture path reads anything
14
+ * belonging to the diff feature.** This is what keeps the sequencing property
15
+ * real rather than intended: diffing is built last, so a capture that consulted
16
+ * it would make the earlier work depend on the later."
17
+ *
18
+ * That rule points one way, and this interface is the other way stated
19
+ * explicitly: **the diff reads capture, capture never reads the diff.** A
20
+ * capture path can hand a comparison what it needs without knowing a comparison
21
+ * exists, and `scripts/check-capture-isolation.mjs` fails the build if that
22
+ * ever inverts — this module is on its diff-owned list precisely so it does.
23
+ *
24
+ * ── What the comparison needs, and what it deliberately does not take ───
25
+ *
26
+ * Two operations: find a capture, read its bytes. **Not the pipeline itself**,
27
+ * and not its result type. A diff runs against a capture that was taken at some
28
+ * earlier moment, quite possibly by an earlier process — so what it needs is a
29
+ * way to look one up, not a way to take one.
30
+ *
31
+ * ── Why this shape and not a direct query ───────────────────────────────
32
+ *
33
+ * The direction of the dependency is the whole point. §7.3 carries
34
+ * `capture.no_diff_dependency`: "**no capture path reads anything belonging to
35
+ * the diff feature**. This is what keeps the sequencing property real rather
36
+ * than intended: diffing is built last, so a capture that consulted it would
37
+ * make the earlier work depend on the later."
38
+ *
39
+ * That rule points one way, and this interface is what makes the other way
40
+ * explicit: **the diff reads capture, capture never reads the diff.** A capture
41
+ * row can hand this a source and get a diff back without capture knowing what a
42
+ * comparison is.
43
+ */
44
+ import fs from 'node:fs/promises';
45
+ const FIND_CAPTURE = `
46
+ SELECT id, claim_id, path, kind, width, height
47
+ FROM captures
48
+ WHERE id = @id
49
+ `;
50
+ /**
51
+ * The capture source backed by the store the capture pipeline writes to.
52
+ *
53
+ * **This is the join, and it lives here rather than being left to a caller.**
54
+ * The capture pipeline writes a `captures` row and a file through the artifact
55
+ * store; this reads that row back and resolves that file. Both halves use the
56
+ * same two facts — the table and the store — so there is no third place for
57
+ * them to disagree.
58
+ *
59
+ * The bytes are read through {@link ArtifactStore.resolve}, which is the only
60
+ * thing in this build that turns a recorded path into a location, and which
61
+ * refuses a path that escapes the root in **either** namespace. Reading the
62
+ * file directly would have meant a second resolver, and the second one is the
63
+ * one that would be missing a case.
64
+ */
65
+ export function captureSource(db, artifacts) {
66
+ return {
67
+ find: (captureId) => {
68
+ const row = db.prepare(FIND_CAPTURE).get({ id: captureId });
69
+ if (row === undefined) {
70
+ return null;
71
+ }
72
+ return {
73
+ id: row.id,
74
+ claimId: row.claim_id,
75
+ path: row.path,
76
+ kind: row.kind,
77
+ width: row.width,
78
+ height: row.height,
79
+ };
80
+ },
81
+ readBytes: async (capture) => new Uint8Array(await fs.readFile(artifacts.resolve(capture.path))),
82
+ };
83
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The `captures` row (§1.7), written by the layer that owns the transaction.
3
+ *
4
+ * ── Why this is a module and not two lines inside the capture handler ────
5
+ *
6
+ * `capture/pipeline.ts` says outright what it will not do: *"It does not write
7
+ * a database row"*, and gives the reason — keeping the split means the
8
+ * pipeline is testable against the fake driver with no store at all. So the
9
+ * pipeline computes {@link CaptureTelemetry} and somebody else writes it, and
10
+ * this is that somebody.
11
+ *
12
+ * ── The gap this closes, stated as the thing that was true ──────────────
13
+ *
14
+ * `takeCapture` had **no caller anywhere in `src/`**. `decideCapture` reached
15
+ * the browser directly and threw the picture away: no downscaling, no file,
16
+ * no row. The consequence was measurable and was measured — a caller driving
17
+ * the shipped binary got `capture -> {"outcome":"accepted",…}` while `SELECT
18
+ * count(*) FROM captures` returned **0**, which is the exact observation
19
+ * `TabOperationResult.pageDriven` was added to describe. `pageDriven` made the
20
+ * silence honest; this makes the picture exist.
21
+ *
22
+ * ── Written after the commit, and why that is correct rather than a compromise ──
23
+ *
24
+ * A capture is browser work: it settles the page, takes a picture and writes a
25
+ * file, none of which may happen inside the arbitration transaction (§2.4b).
26
+ * So the row is written on its own short statement afterwards, the same way
27
+ * `recordTabOpened` writes a driver name after a page is opened.
28
+ *
29
+ * **The row is the record that a file exists**, so writing it before the file
30
+ * would be the same class of error this whole area keeps producing: a store
31
+ * asserting something that did not happen. It is written last, from telemetry
32
+ * describing a file that is already on disk.
33
+ */
34
+ /**
35
+ * Append one capture row.
36
+ *
37
+ * `taken_at`, `created_at` and `updated_at` are left to their defaults except
38
+ * `taken_at`, which carries the pipeline's own instant: the pipeline stamps
39
+ * the file name from it, and a row whose timestamp disagreed with the name of
40
+ * the file it points at would be unreadable as a pair.
41
+ */
42
+ export function recordCapture(db, claimId, tabId, telemetry) {
43
+ db.prepare(`INSERT INTO captures (
44
+ id, claim_id, tab_id, taken_at, kind, tier, reason,
45
+ source_width, source_height, width, height, bytes,
46
+ path, selector, viewport_width, url, warned
47
+ ) VALUES (
48
+ @id, @claimId, @tabId, @takenAt, @kind, @tier, @reason,
49
+ @sourceWidth, @sourceHeight, @width, @height, @bytes,
50
+ @path, @selector, @viewportWidth, @url, @warned
51
+ )`).run({
52
+ id: telemetry.id,
53
+ claimId,
54
+ tabId,
55
+ takenAt: telemetry.takenAt.toISOString(),
56
+ kind: telemetry.kind,
57
+ tier: telemetry.tier,
58
+ // `null` rather than `undefined`: the driver refuses a bound parameter
59
+ // that is undefined, and the column is nullable precisely because a reason
60
+ // is owed only on the top tier.
61
+ reason: telemetry.reason ?? null,
62
+ sourceWidth: telemetry.sourceWidth,
63
+ sourceHeight: telemetry.sourceHeight,
64
+ width: telemetry.width,
65
+ height: telemetry.height,
66
+ bytes: telemetry.bytes,
67
+ path: telemetry.path,
68
+ selector: telemetry.selector ?? null,
69
+ viewportWidth: telemetry.viewportWidth,
70
+ url: telemetry.url,
71
+ // The column is an integer with a check constraint on (0, 1); the store is
72
+ // STRICT, so a boolean would be refused rather than coerced.
73
+ warned: telemetry.warned ? 1 : 0,
74
+ });
75
+ }
76
+ /**
77
+ * How many captures this lease has already taken.
78
+ *
79
+ * `takeCapture` takes this as an argument because *"counting is a query
80
+ * against the store and this module reaches no store"* — so the count is made
81
+ * here and handed in. It decides only the accounting warning, which is
82
+ * guidance and never a refusal (`capture/accounting.ts`), so a count read a
83
+ * moment before another process writes its own row costs a warning that fires
84
+ * one capture late. It is not a budget and nothing is denied on it.
85
+ */
86
+ export function capturesTakenBy(db, claimId) {
87
+ const row = db
88
+ .prepare('SELECT count(*) AS taken FROM captures WHERE claim_id = ?')
89
+ .get(claimId);
90
+ return row?.taken ?? 0;
91
+ }