browser-broker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,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
|
+
}
|