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,175 @@
1
+ /**
2
+ * Where each leased tab actually is, asked of the browsers at generation time
3
+ * (`MILESTONES.md` #70, `SCHEMA.md` §4.2a).
4
+ *
5
+ * ── Why this is a live read and why no column caches it ─────────────────
6
+ *
7
+ * §1.4: **there is no column recording where a tab is**, and its absence is
8
+ * "the single largest privacy improvement in this design". A table of
9
+ * addresses kept over months is a browsing history — with a retention setting
10
+ * to get wrong and a clear-history command to build. §4.2a completes the
11
+ * argument from the other side: the process generating this document **is
12
+ * attached to both browsers**, so the one moment anybody wants the answer is
13
+ * a moment when the source is right there. Asking is strictly better than
14
+ * storing, and it leaves nothing behind.
15
+ *
16
+ * **Do not reintroduce a stored address column.** Nothing in this module
17
+ * writes, and there is no store handle in scope to write with.
18
+ *
19
+ * ── The two rules, and neither is optional ──────────────────────────────
20
+ *
21
+ * §4.2a states them as requirements rather than suggestions:
22
+ *
23
+ * 1. **Every read carries a timeout.** A browser can hang — it accepts the
24
+ * request and never answers (§2.4b) — and a generator that inherits that
25
+ * hang produces nothing at all, which is worse than an incomplete
26
+ * document. The timeout is **per tab**, so one wedged page costs one entry
27
+ * rather than the whole run.
28
+ * 2. **A browser that does not answer renders as an explicit word.** Not
29
+ * blank, not omitted, not a placeholder address. "A missing address and an
30
+ * unanswered one are different facts, and the second is the one that
31
+ * indicates something wrong."
32
+ *
33
+ * The timeout is **not defaultable to absent**: {@link readAddresses} takes
34
+ * it as a required field on a required options object, so a caller cannot
35
+ * omit it and get an unbounded read. That is as far as a type can carry the
36
+ * rule; what it cannot prevent is somebody passing a number so large it is a
37
+ * timeout in name only, and saying so is better than implying otherwise.
38
+ *
39
+ * ── This is outside every transaction, and that is structural here ──────
40
+ *
41
+ * §2.4b: browser work never happens inside the arbitration transaction. This
42
+ * module is reachable only from the document generator, which runs it after
43
+ * the status read has returned and closed. Nothing in this file imports a
44
+ * store handle or a transaction, so there is no transaction in scope to be
45
+ * inside of.
46
+ */
47
+ /**
48
+ * The word an unanswered read renders as (§4.2a).
49
+ *
50
+ * A constant rather than a literal at each use, so the document, the tests
51
+ * and any later reader all name the same string — and so the mutation that
52
+ * removes it has exactly one place to be removed from and is caught.
53
+ */
54
+ export const UNREACHABLE = 'unreachable';
55
+ /** The default timer, wrapped so the injected one and the real one are one shape. */
56
+ function realTimer(fn, ms) {
57
+ const handle = setTimeout(fn, ms);
58
+ return {
59
+ cancel: () => {
60
+ clearTimeout(handle);
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * Race one read against the bound.
66
+ *
67
+ * `Promise.race` rather than an abort signal, because the seam's operations
68
+ * do not take one and inventing a parallel cancellation protocol here would
69
+ * be a change to somebody else's interface made from the outside. **The
70
+ * consequence is stated rather than hidden:** the underlying read is not
71
+ * cancelled, it is abandoned. A hung browser keeps whatever it was doing;
72
+ * this process stops waiting for it, which is the property the rule actually
73
+ * asks for — the generator must not inherit the hang. The abandoned promise's
74
+ * eventual rejection is swallowed so it cannot surface as an unhandled
75
+ * rejection long after the document was written.
76
+ */
77
+ async function withTimeout(work, timeoutMs, setTimer) {
78
+ let timer;
79
+ const timeout = new Promise((resolve) => {
80
+ timer = setTimer(() => {
81
+ resolve({ ok: false, reason: `no answer within ${String(timeoutMs)}ms` });
82
+ }, timeoutMs);
83
+ });
84
+ const answered = work.then((value) => ({ ok: true, value }), (error) => ({
85
+ ok: false,
86
+ reason: error instanceof Error ? error.message : 'the read failed',
87
+ }));
88
+ try {
89
+ return await Promise.race([answered, timeout]);
90
+ }
91
+ finally {
92
+ timer?.cancel();
93
+ }
94
+ }
95
+ /**
96
+ * Read every requested tab's address, each under its own timeout.
97
+ *
98
+ * **Sequential rather than concurrent**, and the reason is the bound: run in
99
+ * parallel, one wedged tab still costs only its own timeout, but so does a
100
+ * browser that is merely slow for all of them at once, and the document's
101
+ * total generation time stops being predictable from the number of tabs. At a
102
+ * tab budget of fifteen (§6.2) the worst case is fifteen timeouts, which is a
103
+ * bounded and explainable wait. If that ever becomes the wrong trade, it is a
104
+ * change here and nowhere else.
105
+ *
106
+ * **Never throws.** Every failure becomes an `unreachable` entry, because the
107
+ * one outcome §4.2a rules out is producing nothing at all.
108
+ */
109
+ export async function readAddresses(source, requests, options) {
110
+ const setTimer = options.setTimer ?? realTimer;
111
+ const addresses = new Map();
112
+ for (const request of requests) {
113
+ const result = await withTimeout(
114
+ // Invoked inside a promise rather than called directly, because a
115
+ // source that throws **synchronously** — before any promise exists —
116
+ // would otherwise escape this loop entirely and the run would produce
117
+ // no document at all. That is the one outcome §4.2a rules out, and a
118
+ // synchronous throw is an ordinary way for a driver to report a handle
119
+ // it does not recognise.
120
+ (async () => source.addressOf(request.handle))(), options.timeoutMs, setTimer);
121
+ addresses.set(request.tabId, result.ok
122
+ ? { kind: 'address', url: result.value.url, title: result.value.title }
123
+ : { kind: 'unreachable', reason: result.reason });
124
+ }
125
+ return addresses;
126
+ }
127
+ /**
128
+ * What the document prints for one tab.
129
+ *
130
+ * The single place the three outcomes become text, so the rule "never blank,
131
+ * never omitted, never a placeholder" is enforced at one point rather than at
132
+ * each use. A tab with no entry at all — which is what happens when the
133
+ * generator could not reach a browser to ask in the first place — renders as
134
+ * `unreachable` too: an address that was never obtained is not an address,
135
+ * and the absence of an entry is exactly the "omitted" case §4.2a forbids.
136
+ */
137
+ export function renderAddress(address) {
138
+ if (address === undefined) {
139
+ return UNREACHABLE;
140
+ }
141
+ switch (address.kind) {
142
+ case 'address':
143
+ return address.url;
144
+ case 'unreachable':
145
+ return UNREACHABLE;
146
+ case 'none':
147
+ // A lease with no tab. Not an address and not a failure — the word is
148
+ // about the lease's state, and the document's queue section already
149
+ // says why there is no page.
150
+ return 'no tab';
151
+ }
152
+ }
153
+ /**
154
+ * Turn a browser session into an {@link AddressSource}.
155
+ *
156
+ * Kept here rather than in the generator so that the one expression evaluated
157
+ * against a page in order to build this document lives beside the rule that
158
+ * bounds it. It reads two properties and nothing else.
159
+ */
160
+ export function addressSourceFromSession(session) {
161
+ return {
162
+ addressOf: async (tab) => {
163
+ const result = await session.evaluate(tab, '({ url: document.location.href, title: document.title })');
164
+ const value = result.value;
165
+ if (value === null || typeof value !== 'object') {
166
+ throw new Error('the page did not report an address');
167
+ }
168
+ const record = value;
169
+ return {
170
+ url: typeof record.url === 'string' ? record.url : '',
171
+ title: typeof record.title === 'string' ? record.title : '',
172
+ };
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The expiry derivation, on its own, so that everything reporting state
3
+ * applies the same one.
4
+ *
5
+ * ── Why this is a module and not three lines inlined at each reader ──────
6
+ *
7
+ * `SCHEMA.md` §2.4 states the standing rule this whole file exists to serve:
8
+ *
9
+ * > **Stored state is provisional, derived state is the truth.**
10
+ *
11
+ * Nothing expires on a timer, because there is nothing running to hold one
12
+ * (§1.0a). A row saying `active` whose expiry has elapsed is **not** an
13
+ * active lease — it is a lease that lapsed and has not been swept yet, and
14
+ * **the difference is invisible in the row.** So a reader that renders
15
+ * `claims.state` reports leases that do not exist, and does it *most* on the
16
+ * busiest installation, where the gap between lapsing and being noticed is
17
+ * exactly the interval somebody is looking at the document.
18
+ *
19
+ * §2.4 names this as "the rule most likely to be broken by a well-meaning
20
+ * addition, because reading a column is the obvious thing to do and it is
21
+ * wrong here", and §4.5 binds the generated operations document to it
22
+ * explicitly: it "is a reader like any other".
23
+ *
24
+ * Keeping the derivation in one named function is what makes that rule
25
+ * **checkable** rather than merely written down: a reader either calls
26
+ * {@link deriveClaimState} or it does not, and a test can delete this
27
+ * function's comparison and watch a rendering test fail.
28
+ *
29
+ * ── What this does not do, said plainly ─────────────────────────────────
30
+ *
31
+ * **It does not sweep, and it writes nothing.** Sweeping is the arbitration
32
+ * transaction's job (§2.4) and belongs to the service layer. This is the
33
+ * read-side half: given a row and an instant, say what is *true* about that
34
+ * row at that instant. The two must agree, and they agree because they apply
35
+ * the same comparison — but a document generator must never be the thing that
36
+ * expires somebody's lease as a side effect of being looked at.
37
+ *
38
+ * **It cannot stop a reader going around it.** Nothing in a type signature
39
+ * prevents a module selecting `state` and printing it. What this buys is that
40
+ * the correct path is one import away and named for what it does, and that
41
+ * the mutation tests over the document renderer have a single line to kill.
42
+ */
43
+ /**
44
+ * Comparing two of the store's timestamps.
45
+ *
46
+ * `SCHEMA.md` §1.1: time is "stored in a single fixed textual form that sorts
47
+ * in chronological order, so a comparison is a comparison and needs no
48
+ * conversion". So this is a string comparison **on purpose** — parsing to a
49
+ * date here would introduce a second interpretation of the same column, and a
50
+ * time zone for it to be wrong in.
51
+ */
52
+ function hasElapsed(expiresAt, at) {
53
+ return expiresAt <= at;
54
+ }
55
+ /**
56
+ * What a lease's state actually is at instant `at`.
57
+ *
58
+ * **A final state is final** (§2.1): released, expired and revoked are
59
+ * outcomes that already happened, and no passage of time changes one. Only
60
+ * the two live states — `queued` and `active` — can have lapsed without
61
+ * anybody noticing.
62
+ *
63
+ * The boundary is **inclusive**: a lease whose expiry is exactly now has
64
+ * elapsed. That matches the sweep, which selects the live rows at or past
65
+ * their expiry, and the two must not disagree by one instant — a document
66
+ * saying `active` for a lease the very next call will expire is the failure
67
+ * this module exists to prevent, in miniature.
68
+ */
69
+ export function deriveClaimState(claim, at) {
70
+ if (claim.state !== 'queued' && claim.state !== 'active') {
71
+ return claim.state;
72
+ }
73
+ return hasElapsed(claim.expires_at, at) ? 'expired' : claim.state;
74
+ }
75
+ /**
76
+ * Whether a lease is live at instant `at` — holding a tab, or holding a
77
+ * place in the queue.
78
+ *
79
+ * This is the predicate the budget count is over, and it is derived for the
80
+ * same reason everything else here is: counting rows whose stored state is
81
+ * live would count leases that have lapsed, and would report an installation
82
+ * as fuller than it is.
83
+ */
84
+ export function isLive(claim, at) {
85
+ const derived = deriveClaimState(claim, at);
86
+ return derived === 'queued' || derived === 'active';
87
+ }
88
+ /**
89
+ * Whole seconds between two of the store's timestamps, `later - earlier`.
90
+ *
91
+ * Negative when `later` is before `earlier`, which is not guarded against:
92
+ * the callers here are reporting how long something has been waiting or how
93
+ * long until it lapses, and a negative number is the honest answer to "how
94
+ * long until an expiry that has already passed". Clamping it would hide
95
+ * exactly the case §2.4 is about.
96
+ *
97
+ * Parsing is safe here in a way it is not in {@link hasElapsed}, because the
98
+ * question is arithmetic rather than ordering — but the values still come
99
+ * from one clock (the database's own, §1.1), so the subtraction is between
100
+ * two readings of the same clock and not across two machines'.
101
+ */
102
+ export function secondsBetween(earlier, later) {
103
+ const from = Date.parse(earlier);
104
+ const to = Date.parse(later);
105
+ if (Number.isNaN(from) || Number.isNaN(to)) {
106
+ return 0;
107
+ }
108
+ return Math.round((to - from) / 1000);
109
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A view of the ledger (`MILESTONES.md` #47, `SCHEMA.md` §1.6).
3
+ *
4
+ * **One query.** §1.6: "The ledger is one stream with a cursor, so a page
5
+ * over it is a page over one query" — and the table's shape is what keeps it
6
+ * that way. A column per kind would be a wide, mostly-empty table; a table
7
+ * per kind would turn every read into a fifteen-way union. So a slice is a
8
+ * `WHERE` over one table, and this module's whole job is to build that
9
+ * `WHERE` without ever building it out of caller-supplied SQL.
10
+ *
11
+ * ── The cursor is not invented here ─────────────────────────────────────
12
+ *
13
+ * §1.6: `events.id` is "**also** the 'everything since here' cursor for
14
+ * anything reading a slice of the ledger". It counts upward, so a reader that
15
+ * remembers the highest id it has seen can ask for everything after it, and
16
+ * the answer is stable regardless of what has been written in between. There
17
+ * is no separate cursor table, no opaque token to encode, and no offset —
18
+ * an offset re-reads rows when the earlier ones have grown, which is the
19
+ * failure a counter key exists to avoid.
20
+ *
21
+ * ── Read-only, and it does not sweep ────────────────────────────────────
22
+ *
23
+ * §5.2 records that "any command that goes through arbitration performs the
24
+ * lazy sweep", which is correct and surprising the first time a listing
25
+ * command closes somebody's tabs. **Reading the ledger is not an arbitration
26
+ * path**: it decides nothing, grants nothing and refuses nothing, so it
27
+ * neither needs the reconciled state nor is entitled to expire anybody's
28
+ * lease. It is a plain read over history — and history is the one thing in
29
+ * this store that is already settled.
30
+ *
31
+ * That is a narrower claim than it may look, and it is worth stating: the
32
+ * *ledger* needs no derivation because every row in it records a decision
33
+ * that was made at a moment that has passed. The **claims** do need one, and
34
+ * that is `derive.ts`'s job. Do not read this file as a precedent for
35
+ * reading `claims.state` directly.
36
+ */
37
+ /** The event kinds, exactly as the schema's check constraint spells them (§1.6). */
38
+ export const EVENT_KINDS = [
39
+ 'claim_requested',
40
+ 'claim_granted',
41
+ 'claim_queued',
42
+ 'claim_promoted',
43
+ 'claim_renewed',
44
+ 'claim_released',
45
+ 'claim_expired',
46
+ 'claim_revoked',
47
+ 'tab_opening',
48
+ 'tab_open_failed',
49
+ 'tab_closing',
50
+ 'navigate',
51
+ 'act',
52
+ 'read',
53
+ 'evaluate',
54
+ 'capture',
55
+ 'compare',
56
+ 'browser_launched',
57
+ 'browser_adopted',
58
+ 'browser_exited',
59
+ 'launch_race_lost',
60
+ 'sweep',
61
+ ];
62
+ /** `allow` or `deny`, separate from the kind (§1.6). */
63
+ export const EVENT_OUTCOMES = ['allow', 'deny'];
64
+ /** The default page. Small enough to read, large enough to be a page. */
65
+ export const DEFAULT_LEDGER_LIMIT = 20;
66
+ /**
67
+ * The most rows one call will return.
68
+ *
69
+ * A bound rather than an unbounded read because the ledger is kept forever by
70
+ * default (§6.2) and one of its two callers is assembling a self-contained
71
+ * HTML file — a document that inlined a million rows would be unopenable, and
72
+ * the failure would arrive long after the decision that caused it.
73
+ */
74
+ export const MAXIMUM_LEDGER_LIMIT = 500;
75
+ /**
76
+ * Build the `WHERE` clause.
77
+ *
78
+ * **Every caller value is a bound parameter and none is interpolated.** The
79
+ * only strings that reach the SQL text are column names written in this file.
80
+ * That is not a general-purpose defence so much as a structural one: there is
81
+ * no code path here that concatenates a caller's string into a statement, so
82
+ * there is nothing to review for whether a particular value was escaped.
83
+ */
84
+ function buildConditions(query) {
85
+ const clauses = [];
86
+ const parameters = [];
87
+ const kinds = query.kinds?.filter((kind) => kind !== '') ?? [];
88
+ if (kinds.length > 0) {
89
+ clauses.push(`kind IN (${kinds.map(() => '?').join(', ')})`);
90
+ parameters.push(...kinds);
91
+ }
92
+ if (query.outcome !== undefined) {
93
+ clauses.push('outcome = ?');
94
+ parameters.push(query.outcome);
95
+ }
96
+ if (query.guard !== undefined) {
97
+ clauses.push('guard = ?');
98
+ parameters.push(query.guard);
99
+ }
100
+ if (query.sessionId !== undefined) {
101
+ clauses.push('session_id = ?');
102
+ parameters.push(query.sessionId);
103
+ }
104
+ if (query.claimId !== undefined) {
105
+ clauses.push('claim_id = ?');
106
+ parameters.push(query.claimId);
107
+ }
108
+ if (query.since !== undefined) {
109
+ clauses.push('id > ?');
110
+ parameters.push(query.since);
111
+ }
112
+ if (query.before !== undefined) {
113
+ clauses.push('id < ?');
114
+ parameters.push(query.before);
115
+ }
116
+ return {
117
+ sql: clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`,
118
+ parameters,
119
+ };
120
+ }
121
+ function toEntry(row) {
122
+ return {
123
+ id: row.id,
124
+ at: row.at,
125
+ kind: row.kind,
126
+ outcome: row.outcome,
127
+ guard: row.guard,
128
+ claimId: row.claim_id,
129
+ tabId: row.tab_id,
130
+ sessionId: row.session_id,
131
+ adapter: row.adapter,
132
+ browserId: row.browser_id,
133
+ detail: row.detail,
134
+ };
135
+ }
136
+ /**
137
+ * Clamp the page size.
138
+ *
139
+ * A limit below one is a caller asking for nothing, which is almost always a
140
+ * mistake in arithmetic rather than an intention, so it becomes the default
141
+ * rather than an empty page that looks like an empty ledger. Above the
142
+ * maximum it is capped rather than refused: a slice is a convenience read and
143
+ * failing a report over a page size would be a poor trade.
144
+ */
145
+ export function clampLimit(limit) {
146
+ if (limit === undefined || !Number.isFinite(limit) || limit < 1) {
147
+ return DEFAULT_LEDGER_LIMIT;
148
+ }
149
+ return Math.min(Math.floor(limit), MAXIMUM_LEDGER_LIMIT);
150
+ }
151
+ /**
152
+ * Read a slice of the ledger.
153
+ *
154
+ * Two statements rather than one, and the second is the count. That is a
155
+ * deliberate departure from "one query" in the narrow sense and not in the
156
+ * sense §1.6 means it: the *slice* is one query over one table with no joins
157
+ * and no union, which is the property the table's shape was designed to buy.
158
+ * The count answers "how many matched" — which a page of twenty cannot say
159
+ * about a filter over a ledger kept forever — and it runs over the same
160
+ * predicate and the same indexes.
161
+ */
162
+ export function readLedger(db, query = {}) {
163
+ const conditions = buildConditions(query);
164
+ const limit = clampLimit(query.limit);
165
+ const direction = query.order === 'oldest' ? 'ASC' : 'DESC';
166
+ const rows = db
167
+ .prepare(`SELECT id, at, kind, outcome, guard, claim_id, tab_id, session_id, adapter, browser_id, detail
168
+ FROM events${conditions.sql}
169
+ ORDER BY id ${direction}
170
+ LIMIT ?`)
171
+ .all(...conditions.parameters, limit);
172
+ const counted = db
173
+ .prepare(`SELECT COUNT(*) AS total FROM events${conditions.sql}`)
174
+ .get(...conditions.parameters);
175
+ const entries = rows.map(toEntry);
176
+ const cursor = entries.reduce((highest, entry) => (highest === null || entry.id > highest ? entry.id : highest), null);
177
+ return { entries, cursor, total: counted?.total ?? 0 };
178
+ }
179
+ /**
180
+ * How often each rule has refused something.
181
+ *
182
+ * §1.6: "the question that *is* asked — 'has this rule ever fired' — is
183
+ * answered by the refusals", and the partial index on `guard` exists for
184
+ * exactly this read. Denials are rare, so the index is small and this is
185
+ * cheap; that is why the ledger records allows and denials alike rather than
186
+ * only the interesting half.
187
+ */
188
+ export function countByGuard(db, query = {}) {
189
+ const conditions = buildConditions({ ...query, guard: undefined });
190
+ const where = conditions.sql === '' ? ' WHERE guard IS NOT NULL' : `${conditions.sql} AND guard IS NOT NULL`;
191
+ return db
192
+ .prepare(`SELECT guard, COUNT(*) AS count FROM events${where} GROUP BY guard ORDER BY count DESC, guard ASC`)
193
+ .all(...conditions.parameters);
194
+ }
@@ -0,0 +1,197 @@
1
+ import { deriveClaimState, isLive, secondsBetween } from "./derive.js";
2
+ import { countByGuard, readLedger } from "./ledger.js";
3
+ /**
4
+ * Read the database's own clock in the same textual form every timestamp
5
+ * column defaults to.
6
+ *
7
+ * The format string is repeated from the schema step rather than imported
8
+ * from it, and that is a considered choice: the step is **history the moment
9
+ * it has run anywhere** and must never be edited, so importing a constant out
10
+ * of it would make a file that may not change a dependency of one that will.
11
+ * A test asserts the two agree, which is the check that keeps a repetition
12
+ * honest.
13
+ */
14
+ export const STORE_CLOCK_SQL = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
15
+ export function readStoreClock(db) {
16
+ const row = db.prepare(`SELECT ${STORE_CLOCK_SQL} AS now`).get();
17
+ if (row === undefined) {
18
+ throw new Error('The store returned no answer when asked for its own clock.');
19
+ }
20
+ return row.now;
21
+ }
22
+ /**
23
+ * Read the recorded tab budget, or null when nothing has recorded one.
24
+ *
25
+ * The row §1.10 describes is written by the first process to open the store —
26
+ * `agreeOnTabBudget`, on the spawn path — into the `tab_budget` table that
27
+ * `schema/step-002-tab-budget.ts` owns. Null means no process has opened this
28
+ * store yet, which the document renders as "not recorded".
29
+ *
30
+ * ── Why this names `tab_budget` and probes for nothing ──────────────────
31
+ *
32
+ * §1.10 **deletes** the settings table rather than giving the budget a key in
33
+ * one, so `tab_budget` is the only place the value lives and this is a direct
34
+ * read of it. A read pointed anywhere else is worse than wrong: it returns
35
+ * null, which this function's own contract renders as "nothing has recorded a
36
+ * budget", and that is a plausible sentence about a healthy store. It would
37
+ * silence two things without either looking broken — `broker doctor`'s
38
+ * `config.tab_budget_agrees` would answer `unknown` forever, so **the one
39
+ * check that exists to catch a budget disagreement could never catch one**,
40
+ * and `status.limit` below would report null instead of the ceiling in force.
41
+ *
42
+ * **The table's absence is deliberately not tolerated.** `tab_budget` arrives
43
+ * in `schema/step-002-tab-budget.ts` and every caller here holds a stepped
44
+ * store, so a missing table is a broken installation and this raises rather
45
+ * than answering null about it — an existence probe here converts that fault
46
+ * into the same believable "not recorded". An **empty** table does read as
47
+ * null, because a store nothing has opened yet is an ordinary state.
48
+ *
49
+ * A test for this has to let the product create the table. One that creates a
50
+ * budget table itself is a fixture seeding a state the product cannot reach,
51
+ * and it passes whether or not this read names the right one.
52
+ */
53
+ export function readTabBudget(db) {
54
+ const row = db.prepare(`SELECT tabs FROM tab_budget WHERE only_row = 1`).get();
55
+ if (row === undefined) {
56
+ return null;
57
+ }
58
+ const value = Number(row.tabs);
59
+ return Number.isFinite(value) ? value : null;
60
+ }
61
+ /**
62
+ * Take the picture.
63
+ *
64
+ * Every claim-derived number in it goes through `derive.ts`. There is no
65
+ * branch in this file that reads `claims.state` and reports it, and that is
66
+ * the property the mutation tests are aimed at.
67
+ */
68
+ export function readOperationsStatus(db, options = {}) {
69
+ const at = options.now ?? readStoreClock(db);
70
+ const browserRows = db
71
+ .prepare(`SELECT id, state, restart_count, pid, launched_at, endpoint, browser_uuid
72
+ FROM browsers ORDER BY id`)
73
+ .all();
74
+ // Every claim whose *stored* state is live. A claim whose stored state is
75
+ // final cannot become live again, so this is the complete candidate set —
76
+ // the derivation can only move a row out of the live set, never into it.
77
+ const claimRows = db
78
+ .prepare(`SELECT c.id, c.session_id, c.browser_id, c.state, c.purpose, c.expires_at,
79
+ c.renew_count, c.created_at, c.activated_at,
80
+ (SELECT t.id FROM tabs t
81
+ WHERE t.claim_id = c.id AND t.state IN ('opening', 'open', 'closing')
82
+ ORDER BY t.created_at LIMIT 1) AS tab_id
83
+ FROM claims c
84
+ WHERE c.state IN ('queued', 'active')
85
+ ORDER BY c.created_at, c.id`)
86
+ .all();
87
+ const live = claimRows.filter((claim) => isLive(claim, at));
88
+ const leases = live
89
+ .filter((claim) => deriveClaimState(claim, at) === 'active')
90
+ .map((claim) => ({
91
+ claimId: claim.id,
92
+ sessionId: claim.session_id,
93
+ browserId: claim.browser_id,
94
+ purpose: claim.purpose,
95
+ state: deriveClaimState(claim, at),
96
+ expiresAt: claim.expires_at,
97
+ secondsUntilExpiry: secondsBetween(at, claim.expires_at),
98
+ renewCount: claim.renew_count,
99
+ createdAt: claim.created_at,
100
+ activatedAt: claim.activated_at,
101
+ tabId: claim.tab_id,
102
+ }));
103
+ const sessions = groupBySession(leases);
104
+ // Ordered by created_at then id, which is §1.5's ordering exactly. The
105
+ // SELECT above already applies it, so position is the index in that order
106
+ // among the entries still queued once derived.
107
+ const queue = live
108
+ .filter((claim) => deriveClaimState(claim, at) === 'queued')
109
+ .map((claim, index) => ({
110
+ claimId: claim.id,
111
+ sessionId: claim.session_id,
112
+ browserId: claim.browser_id,
113
+ purpose: claim.purpose,
114
+ position: index + 1,
115
+ waitedSeconds: secondsBetween(claim.created_at, at),
116
+ expiresAt: claim.expires_at,
117
+ secondsUntilExpiry: secondsBetween(at, claim.expires_at),
118
+ }));
119
+ const liveTabsByBrowser = new Map();
120
+ for (const lease of leases) {
121
+ if (lease.tabId !== null) {
122
+ liveTabsByBrowser.set(lease.browserId, (liveTabsByBrowser.get(lease.browserId) ?? 0) + 1);
123
+ }
124
+ }
125
+ const browsers = browserRows.map((row) => ({
126
+ id: row.id,
127
+ state: row.state,
128
+ restartCount: row.restart_count,
129
+ pid: row.pid,
130
+ launchedAt: row.launched_at,
131
+ discoveryRecorded: row.endpoint !== null,
132
+ identityRecorded: row.browser_uuid !== null,
133
+ liveTabs: liveTabsByBrowser.get(row.id) ?? 0,
134
+ }));
135
+ const leakedRows = db
136
+ .prepare(`SELECT id, browser_id, claim_id, state, close_attempts, updated_at
137
+ FROM tabs WHERE close_failed = 1 ORDER BY updated_at DESC`)
138
+ .all();
139
+ const feedbackRows = db
140
+ .prepare(`SELECT id, at, session_id, rating, category, note, last_guard
141
+ FROM feedback ORDER BY at DESC, id DESC LIMIT ?`)
142
+ .all(options.feedbackLimit ?? 10);
143
+ return {
144
+ at,
145
+ browsers,
146
+ budget: {
147
+ limit: readTabBudget(db),
148
+ used: live.length,
149
+ active: leases.length,
150
+ queued: queue.length,
151
+ keeperTabsExpected: browserRows.length,
152
+ },
153
+ sessions,
154
+ queue,
155
+ leakedTabs: leakedRows.map((row) => ({
156
+ tabId: row.id,
157
+ browserId: row.browser_id,
158
+ claimId: row.claim_id,
159
+ state: row.state,
160
+ closeAttempts: row.close_attempts,
161
+ updatedAt: row.updated_at,
162
+ })),
163
+ recentEvents: readLedger(db, { limit: options.eventLimit ?? 20 }).entries,
164
+ refusalsByGuard: countByGuard(db),
165
+ feedback: feedbackRows.map((row) => ({
166
+ id: row.id,
167
+ at: row.at,
168
+ sessionId: row.session_id,
169
+ rating: row.rating,
170
+ category: row.category,
171
+ note: row.note,
172
+ lastGuard: row.last_guard,
173
+ })),
174
+ };
175
+ }
176
+ /**
177
+ * Group live leases by session, "so one caller holding several reads as one
178
+ * caller" (§4.2).
179
+ *
180
+ * Insertion order is preserved, which means sessions appear in the order
181
+ * their earliest lease was created — the same ordering the query applied.
182
+ * Sorting by session identity instead would order the document by a value
183
+ * another system minted, which carries no meaning to a reader.
184
+ */
185
+ function groupBySession(leases) {
186
+ const grouped = new Map();
187
+ for (const lease of leases) {
188
+ const existing = grouped.get(lease.sessionId);
189
+ if (existing === undefined) {
190
+ grouped.set(lease.sessionId, [lease]);
191
+ }
192
+ else {
193
+ existing.push(lease);
194
+ }
195
+ }
196
+ return [...grouped].map(([sessionId, sessionLeases]) => ({ sessionId, leases: sessionLeases }));
197
+ }