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,280 @@
1
+ import { estimateTokens } from "../capture/tiers.js";
2
+ import { clampLimit } from "./ledger.js";
3
+ /**
4
+ * The capture telemetry rollups (`MILESTONES.md` #37, `SCHEMA.md` §1.7, §1.9).
5
+ *
6
+ * What pictures cost, and what diffs did — read back from the two tables that
7
+ * record it. This is the **reader** half of the measurement work. The study
8
+ * that compares one policy against another over real review work is a
9
+ * different thing and is not here (#37a): a study needs traffic to exist
10
+ * before it can be run, whereas these reads are what make the capture policy's
11
+ * effect legible at all, and they stand on their own the moment a single
12
+ * capture has been taken.
13
+ *
14
+ * ── The shape of these queries was decided by the indexes ────────────────
15
+ *
16
+ * This is worth stating plainly, because it is the constraint that chose every
17
+ * grouping below. `step-001-initial.ts` creates three indexes whose comments
18
+ * name this row's job before this row existed:
19
+ *
20
+ * | Index | Its own comment |
21
+ * |---|---|
22
+ * | `captures_taken_at (taken_at)` | "Listing, and the rollup." |
23
+ * | `comparisons_source (source_capture_id, at DESC)` | "The diffs run from one capture" |
24
+ * | `comparisons_target (target_capture_id)` | "and the diffs run against one — which is what tuning reads." |
25
+ *
26
+ * So a window over `taken_at` is an index range scan, and both directions of
27
+ * the diff question are single-key lookups. **A rollup that forced a full scan
28
+ * when an index for it existed would be the wrong shape**, and a rollup that
29
+ * needed an index that does not exist would be a reason to re-read the schema
30
+ * rather than to add one.
31
+ *
32
+ * The two groupings — `tier` and `kind` — are deliberately *not* indexed and
33
+ * are deliberately still here. Both are enum columns of three values each
34
+ * (§1.7), so grouping them is a pass over rows the window has already narrowed
35
+ * to, not a scan of the table. An index on a three-value column would be a
36
+ * write cost on every capture buying nothing a range scan does not already
37
+ * have.
38
+ *
39
+ * ── Read-only, and it does not sweep ────────────────────────────────────
40
+ *
41
+ * The same standing that `ledger.ts` claims and for the same reason: these
42
+ * decide nothing, grant nothing and refuse nothing, so they are not
43
+ * arbitration paths and are not entitled to expire anybody's lease (§5.2).
44
+ * Both tables record things that happened at a moment that has passed, so
45
+ * unlike `claims` there is nothing here to derive — a capture's size does not
46
+ * change because a lease lapsed.
47
+ *
48
+ * ── Estimated tokens are computed, never stored ─────────────────────────
49
+ *
50
+ * §1.7 deletes an `estimated_tokens` column on purpose and says why: it "is a
51
+ * calculation over two columns on the same row", and freezing it into a column
52
+ * would let it disagree with the dimensions beside it. The same section then
53
+ * requires it "appears on every capture response and **every rollup**". So it
54
+ * is computed here, by {@link estimateTokens}, from the dimensions the rows
55
+ * carry — one formula in `tiers.ts`, used by the capture path and by this
56
+ * reader, with no second copy to drift.
57
+ */
58
+ /** The resolution rungs, exactly as the schema's check constraint spells them (§1.7). */
59
+ export const CAPTURE_TIERS = ['default', 'detail', 'max'];
60
+ /** The kinds of picture, exactly as the schema's check constraint spells them (§1.7). */
61
+ export const CAPTURE_KINDS = ['viewport', 'element', 'full_page'];
62
+ /**
63
+ * Build the `WHERE` clause for a capture window.
64
+ *
65
+ * **Every caller value is a bound parameter and none is interpolated**, on the
66
+ * same structural argument `ledger.ts` makes: the only strings reaching the SQL
67
+ * text are column names written in this file, so there is no path that
68
+ * concatenates a caller's string into a statement and nothing to review for
69
+ * whether a particular value was escaped.
70
+ */
71
+ function buildCaptureConditions(window) {
72
+ const clauses = [];
73
+ const parameters = [];
74
+ if (window.since !== undefined) {
75
+ clauses.push('taken_at >= ?');
76
+ parameters.push(window.since);
77
+ }
78
+ if (window.until !== undefined) {
79
+ clauses.push('taken_at < ?');
80
+ parameters.push(window.until);
81
+ }
82
+ if (window.claimId !== undefined) {
83
+ clauses.push('claim_id = ?');
84
+ parameters.push(window.claimId);
85
+ }
86
+ return {
87
+ sql: clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`,
88
+ parameters,
89
+ };
90
+ }
91
+ /** Fold rows into a cost, applying the per-row token formula as it goes. */
92
+ function toCost(rows) {
93
+ let bytes = 0;
94
+ let estimatedTokens = 0;
95
+ let downscaled = 0;
96
+ let warned = 0;
97
+ for (const row of rows) {
98
+ bytes += row.bytes;
99
+ estimatedTokens += estimateTokens(row.width, row.height);
100
+ if (row.width !== row.source_width || row.height !== row.source_height) {
101
+ downscaled += 1;
102
+ }
103
+ if (row.warned === 1) {
104
+ warned += 1;
105
+ }
106
+ }
107
+ return { captures: rows.length, bytes, estimatedTokens, downscaled, warned };
108
+ }
109
+ /** Split rows by their grouping column, preserving the order SQLite returned. */
110
+ function toGroups(rows) {
111
+ const buckets = new Map();
112
+ for (const row of rows) {
113
+ const bucket = buckets.get(row.grouped);
114
+ if (bucket === undefined) {
115
+ buckets.set(row.grouped, [row]);
116
+ }
117
+ else {
118
+ bucket.push(row);
119
+ }
120
+ }
121
+ return ([...buckets.entries()]
122
+ .map(([group, groupRows]) => ({ group, ...toCost(groupRows) }))
123
+ // Biggest first, because the question a breakdown answers is "what is this
124
+ // costing me" and the answer is at the top. Ties break by name so the
125
+ // ordering is total and two runs over the same data render identically.
126
+ .sort((a, b) => b.estimatedTokens - a.estimatedTokens || a.group.localeCompare(b.group)));
127
+ }
128
+ /**
129
+ * The most escalation reasons one rollup will return.
130
+ *
131
+ * Bounded for the reason `ledger.ts` bounds its slice: both tables grow without
132
+ * limit and one consumer of this is assembling output a person reads. The
133
+ * counts above are unbounded because a count of a million rows is still one
134
+ * number; a *list* of a million reasons is not.
135
+ */
136
+ export const MAXIMUM_ESCALATION_REASONS = 50;
137
+ /**
138
+ * What the captures in a window cost.
139
+ *
140
+ * Three statements over one table, all sharing the window's predicate and the
141
+ * `captures_taken_at` index the schema created for "the rollup". Three rather
142
+ * than one because they answer three shapes of question — a total, two
143
+ * breakdowns, and a list of written reasons — and a single query returning all
144
+ * of them would either be a union of incompatible row shapes or a cross join
145
+ * that multiplies the totals by the number of groups.
146
+ *
147
+ * **The totals are not derived from the breakdowns**, though they could be.
148
+ * Summing `byTier` would give the same number for one query less. It is
149
+ * computed separately because the two are then independent readings of the
150
+ * same rows: if a tier ever appears that the breakdown does not know about,
151
+ * the total still counts it and the two disagree visibly, rather than the row
152
+ * disappearing from both.
153
+ */
154
+ export function readCaptureRollup(db, window = {}) {
155
+ const conditions = buildCaptureConditions(window);
156
+ const totalRows = db
157
+ .prepare(`SELECT width, height, source_width, source_height, bytes, warned, '' AS grouped
158
+ FROM captures${conditions.sql}`)
159
+ .all(...conditions.parameters);
160
+ const tierRows = db
161
+ .prepare(`SELECT width, height, source_width, source_height, bytes, warned, tier AS grouped
162
+ FROM captures${conditions.sql}`)
163
+ .all(...conditions.parameters);
164
+ const kindRows = db
165
+ .prepare(`SELECT width, height, source_width, source_height, bytes, warned, kind AS grouped
166
+ FROM captures${conditions.sql}`)
167
+ .all(...conditions.parameters);
168
+ const reasonWhere = conditions.sql === ''
169
+ ? ' WHERE reason IS NOT NULL'
170
+ : `${conditions.sql} AND reason IS NOT NULL`;
171
+ const reasonRows = db
172
+ .prepare(`SELECT id, taken_at, reason FROM captures${reasonWhere}
173
+ ORDER BY taken_at DESC, id DESC LIMIT ?`)
174
+ .all(...conditions.parameters, MAXIMUM_ESCALATION_REASONS);
175
+ return {
176
+ window,
177
+ total: toCost(totalRows),
178
+ byTier: toGroups(tierRows),
179
+ byKind: toGroups(kindRows),
180
+ escalationReasons: reasonRows.map((row) => ({
181
+ captureId: row.id,
182
+ takenAt: row.taken_at,
183
+ reason: row.reason,
184
+ })),
185
+ };
186
+ }
187
+ /** Fold comparison rows into outcomes plus their distinct settings triples. */
188
+ function toOutcomes(rows) {
189
+ let changed = 0;
190
+ let truncated = 0;
191
+ // Insertion-ordered, and the query returns most recent first, so the
192
+ // settings come out in the order they were last used.
193
+ const settings = new Map();
194
+ for (const row of rows) {
195
+ if (row.changed === 1) {
196
+ changed += 1;
197
+ }
198
+ if (row.truncated === 1) {
199
+ truncated += 1;
200
+ }
201
+ const key = `${String(row.colour_tolerance)}|${String(row.minimum_region_area)}|${String(row.maximum_regions)}`;
202
+ const existing = settings.get(key);
203
+ if (existing === undefined) {
204
+ settings.set(key, { row, comparisons: 1, changed: row.changed === 1 ? 1 : 0 });
205
+ }
206
+ else {
207
+ existing.comparisons += 1;
208
+ existing.changed += row.changed === 1 ? 1 : 0;
209
+ }
210
+ }
211
+ return {
212
+ comparisons: rows.length,
213
+ changed,
214
+ truncated,
215
+ settings: [...settings.values()].map((entry) => ({
216
+ colourTolerance: entry.row.colour_tolerance,
217
+ minimumRegionArea: entry.row.minimum_region_area,
218
+ maximumRegions: entry.row.maximum_regions,
219
+ comparisons: entry.comparisons,
220
+ changed: entry.changed,
221
+ })),
222
+ };
223
+ }
224
+ const DIFF_COLUMNS = 'colour_tolerance, minimum_region_area, maximum_regions, changed, truncated, at';
225
+ /**
226
+ * What diffs did around one capture, in both directions.
227
+ *
228
+ * **Two single-key lookups, one per index.** `comparisons_source` is
229
+ * `(source_capture_id, at DESC)`, so the source side gets its ordering from
230
+ * the index rather than from a sort; `comparisons_target` is the target key
231
+ * alone, so that side is a key lookup and an ordering the query asks for.
232
+ * Reading both directions in one statement would mean an `OR` across two
233
+ * different indexes, which is the shape that turns two lookups into a scan.
234
+ *
235
+ * `limit` bounds the rows each direction folds, defaulted and clamped by
236
+ * `ledger.ts`'s {@link clampLimit} — reused rather than restated, so the page
237
+ * sizes of the two readers in this directory cannot drift apart.
238
+ */
239
+ export function readCaptureDiffActivity(db, captureId, limit) {
240
+ const bound = clampLimit(limit);
241
+ const sourceRows = db
242
+ .prepare(`SELECT ${DIFF_COLUMNS} FROM comparisons WHERE source_capture_id = ?
243
+ ORDER BY at DESC, id DESC LIMIT ?`)
244
+ .all(captureId, bound);
245
+ const targetRows = db
246
+ .prepare(`SELECT ${DIFF_COLUMNS} FROM comparisons WHERE target_capture_id = ?
247
+ ORDER BY at DESC, id DESC LIMIT ?`)
248
+ .all(captureId, bound);
249
+ return {
250
+ captureId,
251
+ asSource: toOutcomes(sourceRows),
252
+ asTarget: toOutcomes(targetRows),
253
+ };
254
+ }
255
+ export function readMostDiffedTargets(db, limit) {
256
+ const bound = clampLimit(limit);
257
+ const rows = db
258
+ .prepare(`SELECT c.target_capture_id AS capture_id,
259
+ COUNT(*) AS comparisons,
260
+ SUM(c.changed) AS changed,
261
+ p.url AS url,
262
+ p.taken_at AS taken_at
263
+ FROM comparisons c
264
+ -- A left join, not an inner one: a comparison naming a capture whose
265
+ -- row is gone is still a comparison that happened, and dropping it
266
+ -- would quietly shrink a count whose whole purpose is to be compared
267
+ -- against another count.
268
+ LEFT JOIN captures p ON p.id = c.target_capture_id
269
+ GROUP BY c.target_capture_id
270
+ ORDER BY comparisons DESC, c.target_capture_id ASC
271
+ LIMIT ?`)
272
+ .all(bound);
273
+ return rows.map((row) => ({
274
+ captureId: row.capture_id,
275
+ comparisons: row.comparisons,
276
+ changed: row.changed,
277
+ url: row.url,
278
+ takenAt: row.taken_at,
279
+ }));
280
+ }