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,692 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { BROWSER_CHOICE_GUIDANCE } from "../../browser/driver.js";
|
|
3
|
+
import { admits, countActiveClaims } from "../capacity.js";
|
|
4
|
+
import { append } from "../events.js";
|
|
5
|
+
import { hashKey, mintKey } from "../keys.js";
|
|
6
|
+
import { nudgeIfOwnObstacle } from "../nudge.js";
|
|
7
|
+
import { queuePosition, waitEstimateSeconds } from "../queue.js";
|
|
8
|
+
import { CallRefusal } from "../refusals.js";
|
|
9
|
+
import { StorageSeedRefusal, seedRecord, validateStorageSeed, } from "../storage-seed.js";
|
|
10
|
+
/**
|
|
11
|
+
* `browser_claim` (§3.2) — atomic grant-or-queue for exactly one tab.
|
|
12
|
+
*
|
|
13
|
+
* **One claim, one tab, one row** (`MILESTONES.md` #13). There is no `tabs`
|
|
14
|
+
* argument and its absence is the model (§2.3): a caller that wants three
|
|
15
|
+
* calls this three times, and should read §2.3a first, because doing so while
|
|
16
|
+
* other callers do the same is a named limit rather than a solved problem.
|
|
17
|
+
*
|
|
18
|
+
* ── Why grant and queue are one operation rather than two ───────────────
|
|
19
|
+
*
|
|
20
|
+
* They are the two outcomes of one decision, taken against a count the same
|
|
21
|
+
* transaction has just reconciled (§7.1 `capacity.admission`). Splitting them
|
|
22
|
+
* would mean a caller asking "is there room" and then asking to be granted,
|
|
23
|
+
* with every other process free to take the room in between — which is the
|
|
24
|
+
* read-then-write window §1.0a measures as failing. **The count is read and
|
|
25
|
+
* the row is written inside one transaction, or the answer is a guess.**
|
|
26
|
+
*
|
|
27
|
+
* ── What is not decided here ────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* The sweep. It has already run by the time this handler is called, because
|
|
30
|
+
* the runner sweeps unconditionally before any handler (`arbitration.ts`), so
|
|
31
|
+
* every count below is over reconciled state. A handler cannot skip it, opt
|
|
32
|
+
* out of it, or run before it — which is the standing invariant made
|
|
33
|
+
* mechanical rather than remembered.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The purpose bound (§1.3), stated here because this is where it is enforced.
|
|
37
|
+
*
|
|
38
|
+
* **The same two numbers as `claims.purpose`'s `CHECK`**, and they have to
|
|
39
|
+
* stay the same two: the guard's job is to make sure no caller ever reaches
|
|
40
|
+
* that constraint, which it can only do if it refuses exactly what the column
|
|
41
|
+
* would have refused. A guard with a *wider* bound would let a caller back
|
|
42
|
+
* through to the crash it exists to prevent; a narrower one would refuse
|
|
43
|
+
* purposes the store would have accepted.
|
|
44
|
+
*
|
|
45
|
+
* They are not imported from the schema module because the schema is SQL text
|
|
46
|
+
* — there is nothing there to import — and duplicating the pair with the
|
|
47
|
+
* reason stated is honest, where deriving one from a parse of the other would
|
|
48
|
+
* be fragile in the direction that fails silently. A test pins them against
|
|
49
|
+
* the live column instead (`tests/operations/claim-purpose.test.ts`), so the
|
|
50
|
+
* two moving apart is a failing test rather than a returning crash.
|
|
51
|
+
*/
|
|
52
|
+
export const PURPOSE_MINIMUM = 3;
|
|
53
|
+
/** @see PURPOSE_MINIMUM */
|
|
54
|
+
export const PURPOSE_MAXIMUM = 200;
|
|
55
|
+
/**
|
|
56
|
+
* The session-identity bound (§1.3), enforced here for the same reason the
|
|
57
|
+
* purpose bound above is — and with one difference that makes it worse.
|
|
58
|
+
*
|
|
59
|
+
* **`claims.session_id` is `TEXT NOT NULL` with no `CHECK` at all.** The
|
|
60
|
+
* purpose had a column constraint standing behind it, so the missing guard
|
|
61
|
+
* showed itself as a crash: ugly, but loud, and fixed within a day of being
|
|
62
|
+
* seen. An empty session identity satisfies `NOT NULL`, so there was nothing
|
|
63
|
+
* behind it to fail — **the lease was granted, and the defect was silent.**
|
|
64
|
+
*
|
|
65
|
+
* ── Why an anonymous lease is not a cosmetic problem ────────────────────
|
|
66
|
+
*
|
|
67
|
+
* §1.3 makes `session_id` the attribution key, and §1.6 puts it on **every
|
|
68
|
+
* refusal row** precisely so a denied request is not anonymous. An empty one
|
|
69
|
+
* defeats both: the ledger records a grant and a denial with nothing saying
|
|
70
|
+
* who. Worse, the you-are-your-own-obstacle nudge (§2.3a) selects live claims
|
|
71
|
+
* by `session_id = @sessionId`, so **every anonymous caller matches every
|
|
72
|
+
* other anonymous caller** — the nudge would list one caller's leases to a
|
|
73
|
+
* different caller and advise it to release them.
|
|
74
|
+
*
|
|
75
|
+
* ── The bound, and why there is no maximum ──────────────────────────────
|
|
76
|
+
*
|
|
77
|
+
* A minimum of one: the identifier is opaque to this service (§1.3 calls it
|
|
78
|
+
* "a key another system owns"), so its *content* is not this service's to
|
|
79
|
+
* judge. What is judgeable is whether one arrived at all, which is the whole
|
|
80
|
+
* defect. No maximum is imposed because the column imposes none, and a guard
|
|
81
|
+
* that refused what the store would have accepted would be inventing a rule
|
|
82
|
+
* rather than enforcing one — the mirror of the reasoning that keeps
|
|
83
|
+
* {@link PURPOSE_MAXIMUM} pinned to its column's `CHECK`.
|
|
84
|
+
*/
|
|
85
|
+
export const SESSION_ID_MINIMUM = 1;
|
|
86
|
+
/**
|
|
87
|
+
* How the refusal describes a missing session identity.
|
|
88
|
+
*
|
|
89
|
+
* Two cases rather than one, for the reason {@link describePurpose} gives:
|
|
90
|
+
* "was not supplied" and "is blank" are the same mistake from the caller's
|
|
91
|
+
* side, but a value that is present and of the wrong *type* is a different
|
|
92
|
+
* one, and telling them apart is what stops a caller re-sending the same
|
|
93
|
+
* malformed argument.
|
|
94
|
+
*/
|
|
95
|
+
function describeSessionId(sessionId) {
|
|
96
|
+
if (typeof sessionId !== 'string') {
|
|
97
|
+
return sessionId === undefined
|
|
98
|
+
? 'was not supplied'
|
|
99
|
+
: `arrived as ${typeof sessionId} rather than as text`;
|
|
100
|
+
}
|
|
101
|
+
return 'is empty';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* How the refusal describes what was wrong, in the caller's own terms.
|
|
105
|
+
*
|
|
106
|
+
* Three sentences rather than one, because "missing", "too short" and "too
|
|
107
|
+
* long" are three different mistakes with three different fixes, and a single
|
|
108
|
+
* "invalid purpose" would make the caller work out which it made.
|
|
109
|
+
*/
|
|
110
|
+
function describePurpose(purpose) {
|
|
111
|
+
if (typeof purpose !== 'string' || purpose.length === 0) {
|
|
112
|
+
// Covers both the argument that was never supplied and the empty string a
|
|
113
|
+
// surface produces from a missing one — indistinguishable by the time
|
|
114
|
+
// they arrive here, and the same mistake from the caller's side.
|
|
115
|
+
return 'is missing';
|
|
116
|
+
}
|
|
117
|
+
if (purpose.length < PURPOSE_MINIMUM) {
|
|
118
|
+
return `is ${String(purpose.length)} character${purpose.length === 1 ? '' : 's'} long`;
|
|
119
|
+
}
|
|
120
|
+
return `is ${String(purpose.length)} characters long`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* How far under the lifetime a caller is told to check back.
|
|
124
|
+
*
|
|
125
|
+
* Nine parts in ten, which against the ten-minute default is the nine minutes
|
|
126
|
+
* §2.5 and `MILESTONES.md` #17 both name. **Expressed as a fraction rather
|
|
127
|
+
* than as a fixed sixty seconds** so it stays *under* the lifetime when the
|
|
128
|
+
* lifetime is configured shorter — a fixed subtraction would become zero or
|
|
129
|
+
* negative on a short queue place, and would tell a caller to check back in
|
|
130
|
+
* the past.
|
|
131
|
+
*/
|
|
132
|
+
export const CHECK_BACK_FRACTION = 0.9;
|
|
133
|
+
/** The check-back deadline in seconds, always at least one. */
|
|
134
|
+
export function checkBackSeconds(lifetimeSeconds) {
|
|
135
|
+
return Math.max(1, Math.floor(lifetimeSeconds * CHECK_BACK_FRACTION));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* What a caller asked for, turned into the browser it gets (§3.2).
|
|
139
|
+
*
|
|
140
|
+
* **Three forms, and a name is checked last rather than first.** The two kind
|
|
141
|
+
* words are the ones the default configuration also uses as names, so an
|
|
142
|
+
* installation that has renamed nothing resolves `regular` by kind and lands
|
|
143
|
+
* on the browser called `regular` either way — the orders agree. Where they
|
|
144
|
+
* would disagree is an installation whose signed-in list does not contain a
|
|
145
|
+
* browser named `regular`, and there the kind reading is the one that keeps
|
|
146
|
+
* working, which is why it wins.
|
|
147
|
+
*
|
|
148
|
+
* That ordering has a consequence worth stating rather than discovering: a
|
|
149
|
+
* browser **named** `regular` sitting in the *private* list would be
|
|
150
|
+
* unreachable by name. It cannot arise, because a name in both lists is
|
|
151
|
+
* refused at startup and `regular` in the private list still resolves as a
|
|
152
|
+
* kind — but the reason it cannot arise is the startup refusal, not this
|
|
153
|
+
* function.
|
|
154
|
+
*
|
|
155
|
+
* Returns `undefined` for a name no configured browser has, which the caller
|
|
156
|
+
* turns into the refusal; this function does not refuse, so that the ledger
|
|
157
|
+
* write and the refusal text stay in one place.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveBrowser(requested, settings) {
|
|
160
|
+
// Nothing stated: the first signed-in browser. **Not a guess between two
|
|
161
|
+
// symmetric wrongs** (`DECISIONS.md` §13i) — defaulting to clean-room when
|
|
162
|
+
// a sign-in was wanted returns a login redirect, a wrong page that looks
|
|
163
|
+
// like a right one; defaulting to signed-in when clean-room was wanted
|
|
164
|
+
// returns a personalised page, which is the page most callers asked for.
|
|
165
|
+
if (requested === undefined) {
|
|
166
|
+
return settings.regularBrowsers[0];
|
|
167
|
+
}
|
|
168
|
+
if (requested === 'regular') {
|
|
169
|
+
return settings.regularBrowsers[0];
|
|
170
|
+
}
|
|
171
|
+
if (requested === 'private') {
|
|
172
|
+
return settings.privateBrowsers[0];
|
|
173
|
+
}
|
|
174
|
+
if (settings.regularBrowsers.includes(requested) ||
|
|
175
|
+
settings.privateBrowsers.includes(requested)) {
|
|
176
|
+
return requested;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
/** Every configured browser, both kinds, for a refusal that lists them. */
|
|
181
|
+
export function configuredBrowsers(settings) {
|
|
182
|
+
return [...settings.regularBrowsers, ...settings.privateBrowsers];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Decide one claim: grant it a tab, or put it at the back of the queue.
|
|
186
|
+
*
|
|
187
|
+
* The order of the two refusals below is deliberate. **An unknown browser is
|
|
188
|
+
* refused before anything is written**, because nothing will ever make it
|
|
189
|
+
* valid (§2.2) — waiting does not help and a queue entry would be a promise
|
|
190
|
+
* the service cannot keep. Both refusals leave the ledger with a row and the
|
|
191
|
+
* claims table without one, which is what makes a refused request anonymous
|
|
192
|
+
* without `events.session_id` (§1.6) and is why that column exists.
|
|
193
|
+
*/
|
|
194
|
+
export function decideClaim(scope, input, settings) {
|
|
195
|
+
const { db, swept, adapter } = scope;
|
|
196
|
+
// **The session identity, checked here rather than by the column** (§7.1
|
|
197
|
+
// `claim.session_bounded`).
|
|
198
|
+
//
|
|
199
|
+
// ── Why this is first, ahead of even the browser check ────────────────
|
|
200
|
+
//
|
|
201
|
+
// Every refusal below records `sessionId: input.sessionId` on the ledger
|
|
202
|
+
// row, because §1.6 exists so that a denied request is attributable. If the
|
|
203
|
+
// identity is the thing that is missing, those rows are anonymous — so the
|
|
204
|
+
// ordering that puts this first is not a preference, it is what makes every
|
|
205
|
+
// *other* refusal on this operation carry a caller. The purpose guard sits
|
|
206
|
+
// after the browser guard because a caller that named a browser that does
|
|
207
|
+
// not exist should hear about the name first; nothing outranks knowing who
|
|
208
|
+
// is asking.
|
|
209
|
+
//
|
|
210
|
+
// ── What the store would have done, which is the point ────────────────
|
|
211
|
+
//
|
|
212
|
+
// Accepted it. `claims.session_id` is `TEXT NOT NULL` with no `CHECK`, and
|
|
213
|
+
// `''` satisfies `NOT NULL` — so unlike the purpose, there is no backstop
|
|
214
|
+
// here and never was. Before this guard, `broker claim` with no
|
|
215
|
+
// `--session-id` returned a granted lease with a real key and a real tab,
|
|
216
|
+
// and wrote `session_id = ''` to the claims row and to all three of its
|
|
217
|
+
// ledger events. Nothing failed, which is why it lasted.
|
|
218
|
+
//
|
|
219
|
+
// Refused **before the first insert and before the arrival counter is
|
|
220
|
+
// allocated**, so a refused caller is not charged capacity, holds no key,
|
|
221
|
+
// and has nothing to release — the position every other argument refusal
|
|
222
|
+
// on this operation takes, for that same reason.
|
|
223
|
+
if (typeof input.sessionId !== 'string' || input.sessionId.length < SESSION_ID_MINIMUM) {
|
|
224
|
+
scope.recordRefusal({
|
|
225
|
+
kind: 'claim_requested',
|
|
226
|
+
outcome: 'deny',
|
|
227
|
+
guard: 'claim.session_bounded',
|
|
228
|
+
adapter,
|
|
229
|
+
// Deliberately **not** `sessionId: input.sessionId`. Every other
|
|
230
|
+
// refusal here attaches the caller; this is the one call where doing so
|
|
231
|
+
// would write the empty string this guard exists to keep out, turning
|
|
232
|
+
// the record of the defect into an instance of it. The column is
|
|
233
|
+
// nullable on `events` (§1.6), so absent is expressible and honest.
|
|
234
|
+
detail: {
|
|
235
|
+
supplied: typeof input.sessionId === 'string' ? input.sessionId.length : null,
|
|
236
|
+
minimum: SESSION_ID_MINIMUM,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
throw new CallRefusal('session_id_missing',
|
|
240
|
+
// Names the argument, says what is wrong with it, and says what it is
|
|
241
|
+
// *for* — a caller that thinks this is a formality supplies a constant
|
|
242
|
+
// and defeats the attribution just as thoroughly as omitting it.
|
|
243
|
+
`A claim carries the identity of the session asking for it, and this one ${describeSessionId(input.sessionId)}. Pass a stable identifier for your session: it is what attributes this lease and every refusal on it in the ledger, and it is how the service can tell you when you are waiting on capacity you are already holding.`, {
|
|
244
|
+
detail: {
|
|
245
|
+
supplied: typeof input.sessionId === 'string' ? input.sessionId.length : null,
|
|
246
|
+
minimum: SESSION_ID_MINIMUM,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// **Resolved rather than merely checked** (§3.2, `DECISIONS.md` §13i).
|
|
251
|
+
// Nothing stated resolves to the first signed-in browser, a kind word to
|
|
252
|
+
// the first of that kind, and a name to that browser exactly.
|
|
253
|
+
const resolvedBrowser = resolveBrowser(input.browser, settings);
|
|
254
|
+
if (resolvedBrowser === undefined) {
|
|
255
|
+
const known = configuredBrowsers(settings);
|
|
256
|
+
scope.recordRefusal({
|
|
257
|
+
kind: 'claim_requested',
|
|
258
|
+
outcome: 'deny',
|
|
259
|
+
guard: 'claim.browser_known',
|
|
260
|
+
adapter,
|
|
261
|
+
sessionId: input.sessionId,
|
|
262
|
+
detail: { requested: input.browser, known },
|
|
263
|
+
});
|
|
264
|
+
// **The refusal carries the choice guidance** (§3.2, row #66): a caller
|
|
265
|
+
// re-reading a refusal is a caller re-making this decision, and telling
|
|
266
|
+
// it only which names exist leaves it to guess which one it wanted.
|
|
267
|
+
throw new CallRefusal('unknown_browser', `There is no browser named ${JSON.stringify(input.browser)}. This service has ${known.join(', ')}. ${BROWSER_CHOICE_GUIDANCE}`, { detail: { requested: input.browser, known } });
|
|
268
|
+
}
|
|
269
|
+
// **The purpose, checked here rather than by the column** (§7.1
|
|
270
|
+
// `claim.purpose_bounded`).
|
|
271
|
+
//
|
|
272
|
+
// ── Why this guard exists, given the column already had a CHECK ────────
|
|
273
|
+
//
|
|
274
|
+
// Because a `CHECK` is not a refusal. Before this guard the bound was
|
|
275
|
+
// enforced only by `claims.purpose`'s own
|
|
276
|
+
// `CHECK (length(purpose) BETWEEN 3 AND 200)`, which fires *after* the
|
|
277
|
+
// insert is handed to the store — so the command line died with an
|
|
278
|
+
// unhandled `SqliteError` and exit 1, and the tool surface answered
|
|
279
|
+
// `unexpected_failure` with the constraint text in it. A caller was told
|
|
280
|
+
// the name of a database constraint rather than the name of the argument
|
|
281
|
+
// it got wrong, on the first command anybody runs.
|
|
282
|
+
//
|
|
283
|
+
// The column keeps its CHECK, and that is deliberate: it is the backstop
|
|
284
|
+
// for a writer that is not this function. What changes is that no caller
|
|
285
|
+
// reaches it any more, because the argument is refused before a statement
|
|
286
|
+
// is built.
|
|
287
|
+
//
|
|
288
|
+
// ── The position ──────────────────────────────────────────────────────
|
|
289
|
+
//
|
|
290
|
+
// **Before the first insert and before the arrival counter is allocated**,
|
|
291
|
+
// for the reason the seed check below gives: a refused caller is not
|
|
292
|
+
// charged capacity, holds no key, and has nothing to release. It sits
|
|
293
|
+
// *after* the browser check because a caller that named a browser that does
|
|
294
|
+
// not exist should hear about the name first — the same ordering, and the
|
|
295
|
+
// same reasoning, that puts the sign-in check after both.
|
|
296
|
+
//
|
|
297
|
+
// Length is counted in UTF-16 code units, which is what SQLite's `length()`
|
|
298
|
+
// counts over a `TEXT` value and therefore what the column would have
|
|
299
|
+
// measured. Counting anything else here would refuse strings the store
|
|
300
|
+
// would have taken, or take strings it would have refused.
|
|
301
|
+
if (typeof input.purpose !== 'string' ||
|
|
302
|
+
input.purpose.length < PURPOSE_MINIMUM ||
|
|
303
|
+
input.purpose.length > PURPOSE_MAXIMUM) {
|
|
304
|
+
scope.recordRefusal({
|
|
305
|
+
kind: 'claim_requested',
|
|
306
|
+
outcome: 'deny',
|
|
307
|
+
guard: 'claim.purpose_bounded',
|
|
308
|
+
adapter,
|
|
309
|
+
sessionId: input.sessionId,
|
|
310
|
+
// The length and the bounds, and **never the purpose itself** — the
|
|
311
|
+
// same discipline the seed refusal keeps, for the same reason: a
|
|
312
|
+
// refusal's detail is read by whoever reads the ledger.
|
|
313
|
+
detail: {
|
|
314
|
+
length: typeof input.purpose === 'string' ? input.purpose.length : null,
|
|
315
|
+
minimum: PURPOSE_MINIMUM,
|
|
316
|
+
maximum: PURPOSE_MAXIMUM,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
throw new CallRefusal('purpose_out_of_bounds',
|
|
320
|
+
// Names the argument and what is wrong with it, and says what the
|
|
321
|
+
// purpose is *for* — an operator deciding whether to revoke this lease
|
|
322
|
+
// reads it, so "one line about the work" is the guidance that produces
|
|
323
|
+
// a useful one rather than three filler characters.
|
|
324
|
+
`A claim carries a purpose of ${String(PURPOSE_MINIMUM)} to ${String(PURPOSE_MAXIMUM)} characters, and this one ${describePurpose(input.purpose)}. Say what the lease is for in one line — it is what an operator reads when deciding whether to revoke it, so name the work rather than the tool.`, {
|
|
325
|
+
detail: {
|
|
326
|
+
length: typeof input.purpose === 'string' ? input.purpose.length : null,
|
|
327
|
+
minimum: PURPOSE_MINIMUM,
|
|
328
|
+
maximum: PURPOSE_MAXIMUM,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Validated **before the first insert**, so a refused seed leaves no lease
|
|
333
|
+
// behind: the caller is not charged capacity for it, holds no key, and has
|
|
334
|
+
// nothing to release. This is the position the unknown-browser refusal
|
|
335
|
+
// occupies, and it is here for the same reason (§2.2) — nothing about
|
|
336
|
+
// waiting makes a malformed argument valid.
|
|
337
|
+
//
|
|
338
|
+
// The fifth refusal §3.2 lists — **any entry on a lease that is not the
|
|
339
|
+
// caller's** — needs no check here and could not be written as one: the
|
|
340
|
+
// seed is an argument on the claim, so it applies once, to the tab that
|
|
341
|
+
// claim grants. There is no parameter naming another lease, so there is no
|
|
342
|
+
// path to seeding somebody else's. That is structural, and it is stated
|
|
343
|
+
// rather than implemented because implementing it would mean inventing the
|
|
344
|
+
// parameter first.
|
|
345
|
+
//
|
|
346
|
+
// **The refusal is recorded through `recordRefusal`, not `append`**, for
|
|
347
|
+
// the reason that scope explains at length: a refusal throws, the throw
|
|
348
|
+
// rolls the transaction back, and an ordinary append goes with it — leaving
|
|
349
|
+
// a ledger of grants that can never show a guard firing. §1.6 requires
|
|
350
|
+
// every decision, allowed and refused alike.
|
|
351
|
+
let storageSeed;
|
|
352
|
+
try {
|
|
353
|
+
storageSeed = validateStorageSeed(input.storageSeed);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (error instanceof StorageSeedRefusal) {
|
|
357
|
+
scope.recordRefusal({
|
|
358
|
+
kind: 'claim_requested',
|
|
359
|
+
outcome: 'deny',
|
|
360
|
+
guard: error.rule,
|
|
361
|
+
adapter,
|
|
362
|
+
sessionId: input.sessionId,
|
|
363
|
+
// The refusal's own detail, which carries counts, byte sizes, the
|
|
364
|
+
// scheme and the area — **and never a value**, because nothing in
|
|
365
|
+
// this module ever puts one in a refusal's detail.
|
|
366
|
+
detail: { ...error.detail },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
const browserId = resolvedBrowser;
|
|
372
|
+
// **The row for a configured browser is created here, on the way to its
|
|
373
|
+
// first launch** (§1.2, `DECISIONS.md` §13i).
|
|
374
|
+
//
|
|
375
|
+
// ── Why a row has to exist before the insert below ────────────────────
|
|
376
|
+
//
|
|
377
|
+
// `claims.browser_id` is `REFERENCES browsers (id)`, and `tabs` carries a
|
|
378
|
+
// composite key back to `(claims.id, claims.browser_id)`. Those constraints
|
|
379
|
+
// are what keep a claim pointing at a browser this service actually
|
|
380
|
+
// manages, and they are worth keeping. Without a row, a claim on a
|
|
381
|
+
// configured browser fails as a foreign-key violation — a database error
|
|
382
|
+
// naming neither the browser nor the argument, which is precisely the
|
|
383
|
+
// shape of failure the `claim.browser_known` guard above exists to replace.
|
|
384
|
+
//
|
|
385
|
+
// ── Why this is not seeding from configuration ────────────────────────
|
|
386
|
+
//
|
|
387
|
+
// §13i rules that rows are created **on first launch of a named browser,
|
|
388
|
+
// not from configuration at startup**, so that two processes holding
|
|
389
|
+
// different lists only ever create rows for browsers somebody actually
|
|
390
|
+
// asked for. This is that rule, at the moment it applies: a claim is what
|
|
391
|
+
// causes a launch — §2.4b puts the launch after the commit — so a granted
|
|
392
|
+
// claim is the first point at which a browser is genuinely going to be
|
|
393
|
+
// started. **No row is created for a browser merely because configuration
|
|
394
|
+
// names it**, and the resolution above has already refused any name the
|
|
395
|
+
// configured lists do not carry, so nothing here can mint a row for a
|
|
396
|
+
// browser nobody configured.
|
|
397
|
+
//
|
|
398
|
+
// ── Why the race needs nothing new ────────────────────────────────────
|
|
399
|
+
//
|
|
400
|
+
// §1.2a already arbitrates the launch race through this same transaction —
|
|
401
|
+
// *"one row, one winner"*. Two processes claiming the same new browser at
|
|
402
|
+
// once are serialised here like every other writer, and `OR IGNORE` makes
|
|
403
|
+
// the loser's insert a no-op rather than an error: the row it wanted
|
|
404
|
+
// exists, which is the outcome it was asking for.
|
|
405
|
+
//
|
|
406
|
+
// The kind is written from the list the name was found in, which is the
|
|
407
|
+
// only place it is known — the name itself carries no kind once names are
|
|
408
|
+
// configured, which is why the column exists (schema step nine).
|
|
409
|
+
const configuredKind = settings.privateBrowsers.includes(browserId) ? 'private' : 'regular';
|
|
410
|
+
db.prepare("INSERT OR IGNORE INTO browsers (id, kind, state) VALUES (@id, @kind, 'stopped')").run({ id: browserId, kind: configuredKind });
|
|
411
|
+
// **A browser being signed into is not available, and this is what makes
|
|
412
|
+
// that state mean anything** (§5.5.1 step 2: "From that moment, requests
|
|
413
|
+
// for it are refused with a retry hint").
|
|
414
|
+
//
|
|
415
|
+
// ── Why refused here, and why refused rather than queued ──────────────
|
|
416
|
+
//
|
|
417
|
+
// The position is load-bearing in both directions. It is **after** the
|
|
418
|
+
// argument checks, because nothing about a sign-in makes a malformed
|
|
419
|
+
// request valid — a caller naming a browser that does not exist should hear
|
|
420
|
+
// about the name, not about somebody's sign-in. It is **before** the
|
|
421
|
+
// admission arithmetic and before any row is written, so a refused caller
|
|
422
|
+
// is not charged capacity, holds no key, and has nothing to release: the
|
|
423
|
+
// same position, and the same reason, as the two refusals above it.
|
|
424
|
+
//
|
|
425
|
+
// Refused rather than queued because §2.2 draws that line by what the queue
|
|
426
|
+
// promises: **the queue's promise is that capacity frees up**, and a browser
|
|
427
|
+
// handed to a person is not a capacity shortage. Queuing here would give a
|
|
428
|
+
// caller a place in a line whose length says nothing about how long a human
|
|
429
|
+
// takes to type a password. So it is `retryable`, with the hint on the
|
|
430
|
+
// sentence, and the caller decides when to come back.
|
|
431
|
+
//
|
|
432
|
+
// **Queued callers already waiting are untouched** — this reads the browser
|
|
433
|
+
// row and writes nothing, so §5.5.1's "queued callers keep their places and
|
|
434
|
+
// their timers" holds by there being no code here that could take one.
|
|
435
|
+
const browserState = db
|
|
436
|
+
.prepare('SELECT state, kind FROM browsers WHERE id = @id')
|
|
437
|
+
.get({ id: browserId });
|
|
438
|
+
// **§7.1 `claim.browser_kind_agrees`, and §13i's one nameable exception**
|
|
439
|
+
// (`DECISIONS.md` §13i: "every disagreement is a nameable refusal rather
|
|
440
|
+
// than a silently broken invariant"). `INSERT OR IGNORE` above is a no-op
|
|
441
|
+
// when a row for this name already exists, whatever kind it was created
|
|
442
|
+
// with — that is what makes it safe under the launch race (§1.2a), and
|
|
443
|
+
// also exactly what makes it unable to tell "the row I wanted already
|
|
444
|
+
// exists" from "a row with this name but a different kind already exists".
|
|
445
|
+
// A second process that configured this name under the other kind would
|
|
446
|
+
// otherwise be handed a browser whose profile persists (or does not) in a
|
|
447
|
+
// way its own configuration never asked for, in total silence. This reads
|
|
448
|
+
// the row back — inside the same transaction, so nothing can change it
|
|
449
|
+
// between the insert and this check — and refuses by name rather than
|
|
450
|
+
// adopting it.
|
|
451
|
+
if (browserState !== undefined && browserState.kind !== configuredKind) {
|
|
452
|
+
scope.recordRefusal({
|
|
453
|
+
kind: 'claim_requested',
|
|
454
|
+
outcome: 'deny',
|
|
455
|
+
guard: 'claim.browser_kind_agrees',
|
|
456
|
+
adapter,
|
|
457
|
+
sessionId: input.sessionId,
|
|
458
|
+
detail: { browser: browserId, storedKind: browserState.kind, configuredKind },
|
|
459
|
+
});
|
|
460
|
+
throw new CallRefusal('browser_kind_mismatch', `The ${browserId} browser is recorded as ${browserState.kind} but this process has it configured as ${configuredKind}. Another process on this machine holds a different configuration for the same name; reconcile the two rather than retrying, because nothing here changes with time.`, { detail: { browser: browserId, storedKind: browserState.kind, configuredKind } });
|
|
461
|
+
}
|
|
462
|
+
if (browserState?.state === 'signing-in') {
|
|
463
|
+
scope.recordRefusal({
|
|
464
|
+
kind: 'claim_requested',
|
|
465
|
+
outcome: 'deny',
|
|
466
|
+
guard: 'browser.serving',
|
|
467
|
+
adapter,
|
|
468
|
+
sessionId: input.sessionId,
|
|
469
|
+
detail: { browser: browserId, state: browserState.state },
|
|
470
|
+
});
|
|
471
|
+
throw new CallRefusal('browser_unavailable', `The ${browserId} browser is being signed into by a person right now, so it is not serving callers. This is a pause rather than a fault: it will serve again as soon as they are finished, and a place in the queue would not help because nothing here is waiting on capacity. Try again shortly.`, { detail: { browser: browserId, state: browserState.state } });
|
|
472
|
+
}
|
|
473
|
+
const now = swept.sweptAt;
|
|
474
|
+
const claimId = randomUUID();
|
|
475
|
+
const key = mintKey();
|
|
476
|
+
// Admission: one integer against one integer, over a count this same
|
|
477
|
+
// transaction reconciled (§7.1 `capacity.admission`). This is the whole of
|
|
478
|
+
// the capacity model — there is no request size to add and no reservation
|
|
479
|
+
// to arithmetic against.
|
|
480
|
+
const granted = admits(countActiveClaims(db), settings.tabBudget);
|
|
481
|
+
const ttlSeconds = granted ? settings.leaseSeconds : settings.queueSeconds;
|
|
482
|
+
// The arrival counter, allocated inside this transaction and therefore
|
|
483
|
+
// serialised with every other caller by construction (§1.0a). It is what
|
|
484
|
+
// orders the queue: `created_at` has millisecond resolution and callers
|
|
485
|
+
// arriving inside one millisecond are ordinary at this rate, so ordering by
|
|
486
|
+
// it would leave the order between them to a random identifier — and a
|
|
487
|
+
// position decided that way can get *worse*, which §2.5 promises it never
|
|
488
|
+
// does.
|
|
489
|
+
const arrival = db
|
|
490
|
+
.prepare('UPDATE claim_arrival SET next = next + 1 WHERE only_row = 1 RETURNING next')
|
|
491
|
+
.get().next;
|
|
492
|
+
db.prepare(`INSERT INTO claims
|
|
493
|
+
(id, key_hash, session_id, browser_id, state, purpose,
|
|
494
|
+
expires_at, ttl_seconds, activated_at, arrival, created_at, updated_at)
|
|
495
|
+
VALUES
|
|
496
|
+
(@id, @keyHash, @sessionId, @browserId, @state, @purpose,
|
|
497
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', @now, @extend), @ttl, @activatedAt, @arrival, @now, @now)`).run({
|
|
498
|
+
arrival,
|
|
499
|
+
id: claimId,
|
|
500
|
+
keyHash: hashKey(key),
|
|
501
|
+
sessionId: input.sessionId,
|
|
502
|
+
browserId,
|
|
503
|
+
state: granted ? 'active' : 'queued',
|
|
504
|
+
purpose: input.purpose,
|
|
505
|
+
now,
|
|
506
|
+
extend: `+${String(ttlSeconds)} seconds`,
|
|
507
|
+
ttl: ttlSeconds,
|
|
508
|
+
// Null forever on a lease that expired while waiting, and set at the
|
|
509
|
+
// moment a lease stops waiting (§1.3). A queued lease has never had a tab.
|
|
510
|
+
activatedAt: granted ? now : null,
|
|
511
|
+
});
|
|
512
|
+
append(db, {
|
|
513
|
+
kind: 'claim_requested',
|
|
514
|
+
outcome: 'allow',
|
|
515
|
+
adapter,
|
|
516
|
+
claimId,
|
|
517
|
+
sessionId: input.sessionId,
|
|
518
|
+
browserId,
|
|
519
|
+
detail: { purpose: input.purpose, granted },
|
|
520
|
+
});
|
|
521
|
+
return granted
|
|
522
|
+
? grant({ scope, input, settings, claimId, key, browserId, now, storageSeed })
|
|
523
|
+
: // A queued lease has no tab, so there is nothing to seed into and the
|
|
524
|
+
// entries are deliberately dropped rather than held. §2.5 is why: a
|
|
525
|
+
// place that is promoted is promoted by a **fresh** call carrying the
|
|
526
|
+
// key, and holding a caller's credential across an unbounded wait to
|
|
527
|
+
// replay it later is a longer custody than this service should have.
|
|
528
|
+
queue({ scope, input, settings, claimId, key, browserId, now });
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The grant: the lease is active, so it has a tab.
|
|
532
|
+
*
|
|
533
|
+
* **The tab row is created `opening` and no browser is called.** §2.4b is
|
|
534
|
+
* absolute about this — a round trip to a browser inside the transaction lets
|
|
535
|
+
* one wedged browser block every arbitration call on the machine, because
|
|
536
|
+
* every caller is serialised behind the same writer. `opening` is the honest
|
|
537
|
+
* state for a tab the tool has been asked for and has not answered about.
|
|
538
|
+
*
|
|
539
|
+
* **Capacity is taken by the claim row, not by the tab** (§2.3): the claim
|
|
540
|
+
* *is* the capacity, so there is no window in which capacity is reserved for
|
|
541
|
+
* a tab that does not exist yet. If the tab fails to open, the lease ends and
|
|
542
|
+
* the count follows immediately, because it is a count of claims.
|
|
543
|
+
*/
|
|
544
|
+
function grant(branch) {
|
|
545
|
+
const { scope, input, settings, claimId, key, browserId, now } = branch;
|
|
546
|
+
const storageSeed = branch.storageSeed ?? [];
|
|
547
|
+
const tabId = randomUUID();
|
|
548
|
+
scope.db
|
|
549
|
+
.prepare(`INSERT INTO tabs (id, claim_id, browser_id, state, created_at, updated_at)
|
|
550
|
+
VALUES (@tabId, @claimId, @browserId, 'opening', @now, @now)`)
|
|
551
|
+
.run({ tabId, claimId, browserId, now });
|
|
552
|
+
const expiresAt = readExpiry(scope, claimId);
|
|
553
|
+
append(scope.db, {
|
|
554
|
+
kind: 'claim_granted',
|
|
555
|
+
outcome: 'allow',
|
|
556
|
+
adapter: scope.adapter,
|
|
557
|
+
claimId,
|
|
558
|
+
tabId,
|
|
559
|
+
sessionId: input.sessionId,
|
|
560
|
+
browserId,
|
|
561
|
+
detail: { expiresAt, leaseSeconds: settings.leaseSeconds },
|
|
562
|
+
});
|
|
563
|
+
append(scope.db, {
|
|
564
|
+
kind: 'tab_opening',
|
|
565
|
+
outcome: 'allow',
|
|
566
|
+
adapter: scope.adapter,
|
|
567
|
+
claimId,
|
|
568
|
+
tabId,
|
|
569
|
+
sessionId: input.sessionId,
|
|
570
|
+
browserId,
|
|
571
|
+
});
|
|
572
|
+
if (storageSeed.length > 0) {
|
|
573
|
+
// **Origins and keys, never values** (§3.2). The redaction is
|
|
574
|
+
// `seedRecord`'s and it is structural — the type it returns has no field
|
|
575
|
+
// a value could live in — so this call site cannot leak one by being
|
|
576
|
+
// written carelessly, and a later edit here cannot either.
|
|
577
|
+
//
|
|
578
|
+
// ── What this row asserts, and what it deliberately does not ──────────
|
|
579
|
+
//
|
|
580
|
+
// **It records that a seed was ACCEPTED, not that storage was written**,
|
|
581
|
+
// and it stays that way now that the write exists. The claim path still
|
|
582
|
+
// cannot do the writing: it runs inside the arbitration transaction, and
|
|
583
|
+
// §2.4b keeps browser work outside it — the tab is a row in `opening`
|
|
584
|
+
// with no page behind it yet.
|
|
585
|
+
//
|
|
586
|
+
// What changed is that there is now a **second** row. `pageFor` opens the
|
|
587
|
+
// page on the tab's first use, seeds it before any navigation, and
|
|
588
|
+
// appends `seed: 'applied'` once the driver has returned
|
|
589
|
+
// (`operations/pages.ts`). So the pair reads as a request and its
|
|
590
|
+
// outcome, and a lease that was granted a seed which never reached a
|
|
591
|
+
// browser has the first row and not the second.
|
|
592
|
+
//
|
|
593
|
+
// That asymmetry is the point. A row saying the lease *started life
|
|
594
|
+
// holding a credential* on the strength of the ask alone would assert
|
|
595
|
+
// something the system may not have done — a ledger that overstates is
|
|
596
|
+
// worse than one that is silent, because the question §3.2 wants answered
|
|
597
|
+
// is a security question and a false negative in it is read as an
|
|
598
|
+
// all-clear. `requested` is the true fact available at this point;
|
|
599
|
+
// `applied` is the true fact available at the other one.
|
|
600
|
+
append(scope.db, {
|
|
601
|
+
kind: 'storage_seeded',
|
|
602
|
+
outcome: 'allow',
|
|
603
|
+
adapter: scope.adapter,
|
|
604
|
+
claimId,
|
|
605
|
+
tabId,
|
|
606
|
+
sessionId: input.sessionId,
|
|
607
|
+
browserId,
|
|
608
|
+
detail: { entries: seedRecord(storageSeed), seed: 'requested' },
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// A granted caller may still be its own obstacle — it has just taken the
|
|
612
|
+
// last unit and the rest of its work is now queued behind other callers.
|
|
613
|
+
// §2.3a scopes the nudge to a refusal or a queue placement, so nothing is
|
|
614
|
+
// attached here; the ledger already records the grant.
|
|
615
|
+
return {
|
|
616
|
+
value: {
|
|
617
|
+
outcome: 'granted',
|
|
618
|
+
claimId,
|
|
619
|
+
key,
|
|
620
|
+
browserId,
|
|
621
|
+
tabId,
|
|
622
|
+
expiresAt,
|
|
623
|
+
leaseSeconds: settings.leaseSeconds,
|
|
624
|
+
storageSeed,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* The queue placement: a lease and a key, and no tab.
|
|
630
|
+
*
|
|
631
|
+
* The response carries the position, a weak estimate labelled as one, and —
|
|
632
|
+
* the substance of `MILESTONES.md` #17 rather than its wording — **the
|
|
633
|
+
* mechanism**: check back at just under the lifetime, because a check
|
|
634
|
+
* scheduled exactly at the deadline races the sweep and loses about half the
|
|
635
|
+
* time.
|
|
636
|
+
*/
|
|
637
|
+
function queue(branch) {
|
|
638
|
+
const { scope, input, settings, claimId, key, browserId, now } = branch;
|
|
639
|
+
const position = queuePosition(scope.db, claimId);
|
|
640
|
+
const estimate = waitEstimateSeconds(scope.db, position);
|
|
641
|
+
const expiresAt = readExpiry(scope, claimId);
|
|
642
|
+
const checkBack = checkBackSeconds(settings.queueSeconds);
|
|
643
|
+
append(scope.db, {
|
|
644
|
+
kind: 'claim_queued',
|
|
645
|
+
outcome: 'allow',
|
|
646
|
+
adapter: scope.adapter,
|
|
647
|
+
claimId,
|
|
648
|
+
sessionId: input.sessionId,
|
|
649
|
+
browserId,
|
|
650
|
+
detail: { position, expiresAt, queueSeconds: settings.queueSeconds, queuedAt: now },
|
|
651
|
+
});
|
|
652
|
+
const nudge = nudgeIfOwnObstacle(scope.db, {
|
|
653
|
+
sessionId: input.sessionId,
|
|
654
|
+
claimId,
|
|
655
|
+
adapter: scope.adapter,
|
|
656
|
+
outcome: 'queued',
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
value: {
|
|
660
|
+
outcome: 'queued',
|
|
661
|
+
claimId,
|
|
662
|
+
key,
|
|
663
|
+
browserId,
|
|
664
|
+
position,
|
|
665
|
+
queueSeconds: settings.queueSeconds,
|
|
666
|
+
expiresAt,
|
|
667
|
+
...(estimate === undefined ? {} : { waitEstimateSeconds: estimate }),
|
|
668
|
+
checkBackSeconds: checkBack,
|
|
669
|
+
checkBack: `Call in with this key at least every ${String(checkBack)} seconds, or this place is lost and a fresh request queues at the back with a new key. ` +
|
|
670
|
+
`The place lives ${String(settings.queueSeconds)} seconds; set up a recurring check at ${String(checkBack)} seconds rather than at the deadline, because a check made exactly at the deadline races the reclamation and loses about half the time. ` +
|
|
671
|
+
'Any call carrying this key extends the place — asking where you stand is how you hold it.',
|
|
672
|
+
...(nudge === undefined ? {} : { nudge }),
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Read the expiry back out of the row rather than recomputing it.
|
|
678
|
+
*
|
|
679
|
+
* The value was written by the database's own clock arithmetic, and computing
|
|
680
|
+
* the same instant a second time in this process would produce a string that
|
|
681
|
+
* agrees under one set of rounding rules and disagrees under another. What
|
|
682
|
+
* the caller is told is therefore read back from the row, so it is exactly
|
|
683
|
+
* what the sweep will compare against.
|
|
684
|
+
*/
|
|
685
|
+
function readExpiry(scope, claimId) {
|
|
686
|
+
const row = scope.db
|
|
687
|
+
.prepare('SELECT expires_at AS expiresAt FROM claims WHERE id = @claimId')
|
|
688
|
+
.get({
|
|
689
|
+
claimId,
|
|
690
|
+
});
|
|
691
|
+
return row.expiresAt;
|
|
692
|
+
}
|