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,100 @@
1
+ /**
2
+ * Step four: a tab that ended without ever opening becomes representable.
3
+ *
4
+ * **A new step rather than an edit to step one**, per the rule that a step
5
+ * which has run somewhere is history (`CLAUDE.md`, `SCHEMA.md` §1.2d).
6
+ * SQLite cannot alter a check in place, so the table is rebuilt — which is
7
+ * why this step is longer than the change it makes.
8
+ *
9
+ * ── The defect, and why the constraint rather than the writer is wrong ───
10
+ *
11
+ * Step one carries `CHECK ((state = 'opening') = (driver_tab_id IS NULL))`,
12
+ * an **equivalence**: a null driver name is permitted on `opening` and
13
+ * nowhere else. Its own comment gives the reason it exists, and the reason is
14
+ * narrower than the rule it wrote:
15
+ *
16
+ * > A tab that has not opened has no driver name to be unique against, and
17
+ * > one that has opened does. **Without this the partial unique index below
18
+ * > would be satisfied by any number of live rows holding null.**
19
+ *
20
+ * That index is `one_row_per_physical_tab`, and it covers
21
+ * `state IN ('opening', 'open', 'closing')`. **`closed` and `failed` are
22
+ * outside it**, so a null driver name on a closed row cannot dilute a
23
+ * uniqueness rule that does not range over it. The equivalence constrains two
24
+ * states its own justification never reaches.
25
+ *
26
+ * ── What that cost, measured ────────────────────────────────────────────
27
+ *
28
+ * A lease ends in one of two ways and both have to write the tab row.
29
+ * A tab that never opened has no driver name, so **every** terminal state was
30
+ * refused for it: `closing` is dishonest (§1.4 — nothing was asked and no
31
+ * page may still exist), and `closed` was forbidden by the equivalence. The
32
+ * row could not leave `opening`, so the update threw.
33
+ *
34
+ * Because the sweep runs unconditionally before every handler (§1.0a, and
35
+ * that is correct), **one lapsed lease holding such a tab made every
36
+ * arbitration call by every caller throw, permanently and across spawns** —
37
+ * the lapsed row could never be swept, because sweeping it was the operation
38
+ * that threw. A lapsing lease is the ordinary case the lazy sweep exists to
39
+ * serve (§2.4), so this was the main line rather than an edge.
40
+ *
41
+ * ── The narrowing, stated exactly ───────────────────────────────────────
42
+ *
43
+ * | State | Driver name | Before | Now |
44
+ * |---|---|---|---|
45
+ * | `opening` | null | required | **required**, unchanged |
46
+ * | `open` · `closing` | present | required | **required**, unchanged |
47
+ * | `closed` · `failed` | either | null refused | **permitted** |
48
+ *
49
+ * The live states keep the rule exactly as it was, which is the whole of what
50
+ * the index needs. What becomes representable is the true fact the design had
51
+ * no way to record: **a tab whose lease ended before the tab ever opened.**
52
+ * Nothing is loosened while a tab is live.
53
+ */
54
+ const REBUILD = [
55
+ `
56
+ CREATE TABLE tabs_next (
57
+ id TEXT PRIMARY KEY,
58
+ claim_id TEXT NOT NULL,
59
+ browser_id TEXT NOT NULL,
60
+ driver_tab_id TEXT,
61
+ state TEXT NOT NULL
62
+ CHECK (state IN ('opening', 'open', 'closing', 'closed', 'failed')),
63
+ opened_at TEXT,
64
+ closed_at TEXT,
65
+ close_failed INTEGER NOT NULL DEFAULT 0
66
+ CHECK (close_failed IN (0, 1)),
67
+ close_attempts INTEGER NOT NULL DEFAULT 0
68
+ CHECK (close_attempts >= 0),
69
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
70
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
71
+ FOREIGN KEY (claim_id, browser_id) REFERENCES claims (id, browser_id),
72
+ -- Narrowed to the live states, which are the ones the partial unique index
73
+ -- ranges over. A tab still live must say whether it has a driver name; one
74
+ -- that has finished may have ended before it ever acquired one.
75
+ CHECK (
76
+ state NOT IN ('opening', 'open', 'closing')
77
+ OR (state = 'opening') = (driver_tab_id IS NULL)
78
+ )
79
+ ) STRICT
80
+ `,
81
+ `INSERT INTO tabs_next SELECT * FROM tabs`,
82
+ `DROP TABLE tabs`,
83
+ `ALTER TABLE tabs_next RENAME TO tabs`,
84
+ // Rebuilt with the table, because dropping it took them with it. Identical
85
+ // to step one's, which is what keeps this a change to one constraint.
86
+ `CREATE UNIQUE INDEX one_row_per_physical_tab
87
+ ON tabs (browser_id, driver_tab_id) WHERE state IN ('opening', 'open', 'closing')`,
88
+ `CREATE INDEX tabs_claim ON tabs (claim_id)`,
89
+ ];
90
+ /** Every statement this step runs, in order. */
91
+ export const STEP_FOUR_SQL = REBUILD;
92
+ export const stepFour = {
93
+ version: 4,
94
+ summary: 'A tab may end without ever having opened; the driver-name rule narrows to live states.',
95
+ apply: (db) => {
96
+ for (const statement of STEP_FOUR_SQL) {
97
+ db.exec(statement);
98
+ }
99
+ },
100
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Step five: `storage_seeded` becomes a recordable event kind (row #65).
3
+ *
4
+ * **A new step rather than an edit to step one**, per the rule that a step
5
+ * which has run somewhere is history (`CLAUDE.md`, `SCHEMA.md` §1.2d). SQLite
6
+ * cannot alter a check in place, so the table is rebuilt — which is why this
7
+ * step is longer than the one value it adds.
8
+ *
9
+ * ── Why the seed needs a kind of its own ────────────────────────────────
10
+ *
11
+ * `SCHEMA.md` §3.2 asks a specific question of the ledger: *"which leases
12
+ * started life already holding a credential"*. That is a question about a
13
+ * category of lease, so it has to be answerable by selecting on one — and a
14
+ * seed folded into the `claim_granted` row's detail would only be findable by
15
+ * reading every grant's JSON and testing for a key. **A kind is what makes it
16
+ * a query rather than a scan**, which is the same argument §1.6 makes for the
17
+ * kind column being a fixed list at all.
18
+ *
19
+ * It is recorded as its own row rather than as a field on the grant for a
20
+ * second reason: the seed is applied **after** the arbitration transaction
21
+ * commits, because writing storage is browser work and browser work never
22
+ * happens inside the transaction (§2.4b). The grant row is written before the
23
+ * seeding has happened, so a field on it would be a claim about the future.
24
+ *
25
+ * ── What the row carries, and what it must never carry ──────────────────
26
+ *
27
+ * **Origins and keys, never values.** §3.2 states it outright and gives the
28
+ * reason §3.9 gives about cookie values: the question needs an answer and the
29
+ * answer does not need the credential in it. Nothing in the schema can
30
+ * enforce that — `detail` is text — so it is enforced where the row is built
31
+ * (`storage-seed.ts`) and asserted by a test that reads the ledger back and
32
+ * looks for the value it seeded.
33
+ */
34
+ const REBUILD = [
35
+ `
36
+ CREATE TABLE events_next (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
39
+ kind TEXT NOT NULL
40
+ CHECK (kind IN (
41
+ 'claim_requested', 'claim_granted', 'claim_queued', 'claim_promoted',
42
+ 'claim_renewed', 'claim_released', 'claim_expired', 'claim_revoked',
43
+ 'tab_opening', 'tab_open_failed', 'tab_closing',
44
+ 'navigate', 'act', 'read', 'evaluate', 'capture', 'compare',
45
+ 'browser_launched', 'browser_adopted', 'browser_exited',
46
+ 'launch_race_lost', 'sweep',
47
+ -- Added by this step. The origins and the keys of a seeded
48
+ -- lease, and never the values.
49
+ 'storage_seeded'
50
+ )),
51
+ outcome TEXT NOT NULL
52
+ CHECK (outcome IN ('allow', 'deny')),
53
+ guard TEXT,
54
+ claim_id TEXT REFERENCES claims (id),
55
+ tab_id TEXT REFERENCES tabs (id),
56
+ session_id TEXT,
57
+ adapter TEXT NOT NULL
58
+ CHECK (adapter IN ('tool-stdio', 'tool-http', 'cli', 'internal')),
59
+ browser_id TEXT REFERENCES browsers (id),
60
+ detail TEXT,
61
+ -- A guard names the rule that refused, so it belongs on a denial and means
62
+ -- nothing on an allow.
63
+ CHECK ((outcome = 'deny') = (guard IS NOT NULL))
64
+ ) STRICT
65
+ `,
66
+ // The identifier is a cursor callers may already hold (§1.6), so the copy
67
+ // preserves it rather than letting AUTOINCREMENT reissue from one.
68
+ `INSERT INTO events_next SELECT * FROM events`,
69
+ `DROP TABLE events`,
70
+ `ALTER TABLE events_next RENAME TO events`,
71
+ // Rebuilt with the table, because dropping it took them with it. Identical
72
+ // to step one's, which is what keeps this a change to one check constraint
73
+ // and nothing else — a rebuild that quietly loses an index is a rebuild
74
+ // that changes the performance of every ledger read.
75
+ `CREATE INDEX events_at ON events (at)`,
76
+ `CREATE INDEX events_claim_id ON events (claim_id, id)`,
77
+ `CREATE INDEX events_kind_at ON events (kind, at)`,
78
+ `CREATE INDEX events_guard ON events (guard) WHERE guard IS NOT NULL`,
79
+ ];
80
+ /** Every statement this step runs, in order. */
81
+ export const STEP_FIVE_SQL = REBUILD;
82
+ export const stepFive = {
83
+ version: 5,
84
+ summary: 'A seeded lease is recordable: the `storage_seeded` event kind (#65).',
85
+ apply: (db) => {
86
+ for (const statement of STEP_FIVE_SQL) {
87
+ db.exec(statement);
88
+ }
89
+ },
90
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Step six: signing in becomes recordable — `browser_signin_began` and
3
+ * `browser_signin_ended` (`SCHEMA.md` §5.5.1).
4
+ *
5
+ * **A new step rather than an edit to step five**, per the rule that a step
6
+ * which has run somewhere is history (`CLAUDE.md`, `SCHEMA.md` §1.2d). SQLite
7
+ * cannot alter a check in place, so the table is rebuilt — which is why this
8
+ * step is longer than the two values it adds.
9
+ *
10
+ * ── Why signing in needs kinds of its own ───────────────────────────────
11
+ *
12
+ * §5.5.1 makes signing in a **service operation** rather than something a
13
+ * person does to a browser directly, and the reason it gives is the one that
14
+ * makes these rows necessary: it *"refuses if any live lease holds a tab on
15
+ * that browser"*, and while it is in progress *"requests for it are refused
16
+ * with a retry hint"*. So the service spends a period deliberately turning
17
+ * callers away, and §1.6 requires **every decision, allowed and refused
18
+ * alike**.
19
+ *
20
+ * Without a kind for the two edges, that period is invisible in the ledger:
21
+ * a reader would see a run of `browser_unavailable` denials with nothing
22
+ * saying why the browser was unavailable, and the obvious reading of a
23
+ * cluster of denials is a fault. **These two rows are what make the
24
+ * difference between "the browser was broken" and "a person was signing in"
25
+ * a query rather than a guess** — which is the same argument §1.6 makes for
26
+ * the kind column being a fixed list at all.
27
+ *
28
+ * Two kinds rather than one with a direction field, because the pair brackets
29
+ * an interval and the questions asked of it are asked of the edges: when did
30
+ * this start, did it ever finish, is one open right now. A single kind would
31
+ * make "is a sign-in open" a scan that reads every row's JSON and counts,
32
+ * rather than a comparison of two counts.
33
+ *
34
+ * ── What the rows carry, and what they must never carry ─────────────────
35
+ *
36
+ * **Which browser, and when. Never anything a person typed.** A sign-in is
37
+ * the one moment where credentials are physically present at the machine, and
38
+ * the ledger is a file that gets read, copied and pasted into messages. What
39
+ * a sign-in produces lives in the browser's own profile directory (§1.2) and
40
+ * is written there by the browser; **nothing in this service ever sees it**,
41
+ * so there is nothing here to leak by accident — but the rule is stated
42
+ * because the row is built by hand and a well-meaning addition would be the
43
+ * way it stopped being true. Nothing in the schema can enforce it — `detail`
44
+ * is text — so it is enforced where the row is built (`sign-in.ts`) and
45
+ * asserted by a test that reads the ledger back.
46
+ */
47
+ const REBUILD = [
48
+ `
49
+ CREATE TABLE events_next (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
52
+ kind TEXT NOT NULL
53
+ CHECK (kind IN (
54
+ 'claim_requested', 'claim_granted', 'claim_queued', 'claim_promoted',
55
+ 'claim_renewed', 'claim_released', 'claim_expired', 'claim_revoked',
56
+ 'tab_opening', 'tab_open_failed', 'tab_closing',
57
+ 'navigate', 'act', 'read', 'evaluate', 'capture', 'compare',
58
+ 'browser_launched', 'browser_adopted', 'browser_exited',
59
+ 'launch_race_lost', 'sweep', 'storage_seeded',
60
+ -- Added by this step. The two edges of the one interval in
61
+ -- which this service turns callers away on purpose, so that
62
+ -- period is legible as a sign-in rather than as a fault.
63
+ 'browser_signin_began', 'browser_signin_ended'
64
+ )),
65
+ outcome TEXT NOT NULL
66
+ CHECK (outcome IN ('allow', 'deny')),
67
+ guard TEXT,
68
+ claim_id TEXT REFERENCES claims (id),
69
+ tab_id TEXT REFERENCES tabs (id),
70
+ session_id TEXT,
71
+ adapter TEXT NOT NULL
72
+ CHECK (adapter IN ('tool-stdio', 'tool-http', 'cli', 'internal')),
73
+ browser_id TEXT REFERENCES browsers (id),
74
+ detail TEXT,
75
+ -- A guard names the rule that refused, so it belongs on a denial and means
76
+ -- nothing on an allow.
77
+ CHECK ((outcome = 'deny') = (guard IS NOT NULL))
78
+ ) STRICT
79
+ `,
80
+ // The identifier is a cursor callers may already hold (§1.6), so the copy
81
+ // preserves it rather than letting AUTOINCREMENT reissue from one.
82
+ `INSERT INTO events_next SELECT * FROM events`,
83
+ `DROP TABLE events`,
84
+ `ALTER TABLE events_next RENAME TO events`,
85
+ // Rebuilt with the table, because dropping it took them with it. Identical
86
+ // to step one's, which is what keeps this a change to one check constraint
87
+ // and nothing else — a rebuild that quietly loses an index is a rebuild
88
+ // that changes the performance of every ledger read.
89
+ `CREATE INDEX events_at ON events (at)`,
90
+ `CREATE INDEX events_claim_id ON events (claim_id, id)`,
91
+ `CREATE INDEX events_kind_at ON events (kind, at)`,
92
+ `CREATE INDEX events_guard ON events (guard) WHERE guard IS NOT NULL`,
93
+ ];
94
+ /** Every statement this step runs, in order. */
95
+ export const STEP_SIX_SQL = REBUILD;
96
+ export const stepSix = {
97
+ version: 6,
98
+ summary: 'Signing in is recordable: the two `browser_signin_*` event kinds (§5.5.1).',
99
+ apply: (db) => {
100
+ for (const statement of STEP_SIX_SQL) {
101
+ db.exec(statement);
102
+ }
103
+ },
104
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Step seven: a browser can be claimed for a person before it has a process.
3
+ *
4
+ * ── The constraint this widens, and why it was right to begin with ──────
5
+ *
6
+ * Step one states it as `(state = 'stopped') = (pid IS NULL)`, with the
7
+ * reason that *"a stopped browser has no process, and a running one has
8
+ * one"*, expressed as a constraint rather than a convention because the
9
+ * reclamation path branches on the process being absent. Every state that
10
+ * existed when it was written was either *stopped*, or *a process is doing
11
+ * something*, so the biconditional was exactly true.
12
+ *
13
+ * ── What `signing-in` turned out to be, which the constraint did not cover ──
14
+ *
15
+ * `SCHEMA.md` §5.5.1 makes signing in a claim over the **profile**, not over
16
+ * a process: *"What the command does is claim the browser for the person"*,
17
+ * and *"nothing is stopped and nothing is relaunched"*. The claim has to be
18
+ * taken **before** a window is handed over, because its whole purpose is to
19
+ * stop a caller opening a tab in a browser somebody is about to drive by
20
+ * hand — a claim taken after the window appeared would be a guard that
21
+ * announced itself once the race was already lost.
22
+ *
23
+ * So there is a real and ordinary state that constraint makes unwritable:
24
+ * **the browser is not running, and a person has claimed it in order to sign
25
+ * in.** That is what a first sign-in on a fresh installation *is* — nobody
26
+ * has started a browser, which is precisely why nobody is signed in. The
27
+ * store rejected it with a check-constraint failure, which is the correct
28
+ * behaviour of a constraint that had been told something narrower than the
29
+ * truth.
30
+ *
31
+ * ── What is kept, and it is the part that was load-bearing ──────────────
32
+ *
33
+ * The direction the reclamation path relies on is **unchanged and still
34
+ * total**: a row that says `stopped` still has no process, and a row with a
35
+ * process still does not say `stopped`. Nothing that branches on *is there a
36
+ * process to reclaim* sees a different answer than before.
37
+ *
38
+ * What is relaxed is only the other direction, and only for one state: a
39
+ * browser that is `signing-in` may have a process or may not, because a
40
+ * person can be signing into one that is already up or into one that is
41
+ * being started for them. Every other non-stopped state still requires a
42
+ * process, so this does not become "the constraint means nothing now" —
43
+ * `starting`, `running` and `failed` are each still tied to one.
44
+ *
45
+ * **A new step rather than an edit to step one**, per the rule that a step
46
+ * which has run somewhere is history. SQLite cannot alter a check in place,
47
+ * so the table is rebuilt.
48
+ */
49
+ const REBUILD = [
50
+ `
51
+ CREATE TABLE browsers_next (
52
+ id TEXT PRIMARY KEY
53
+ CHECK (id IN ('regular', 'private')),
54
+ state TEXT NOT NULL DEFAULT 'stopped'
55
+ CHECK (state IN ('stopped', 'starting', 'running', 'signing-in', 'failed')),
56
+ pid INTEGER,
57
+ launched_at TEXT,
58
+ endpoint TEXT,
59
+ browser_uuid TEXT,
60
+ restart_count INTEGER NOT NULL DEFAULT 0
61
+ CHECK (restart_count >= 0),
62
+ -- **The defaults are part of the column**, and a rebuild that omits them
63
+ -- silently changes what an INSERT that does not name them does: it stops
64
+ -- defaulting from the database's own clock and starts failing NOT NULL.
65
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
66
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
67
+ -- **A stopped browser has no process.** Unchanged from step one, total,
68
+ -- and it is the direction the reclamation path branches on.
69
+ CHECK (state != 'stopped' OR pid IS NULL),
70
+ -- **A browser with no process is stopped — unless a person has claimed it.**
71
+ -- Step one's other direction, with the single exception §5.5.1 requires:
72
+ -- signing in is a claim over the profile rather than over a process, taken
73
+ -- before a window is handed over, so it is the one state that may hold
74
+ -- either. Starting, running and failed each still require a process.
75
+ CHECK (pid IS NOT NULL OR state IN ('stopped', 'signing-in'))
76
+ ) STRICT
77
+ `,
78
+ `INSERT INTO browsers_next SELECT * FROM browsers`,
79
+ `DROP TABLE browsers`,
80
+ `ALTER TABLE browsers_next RENAME TO browsers`,
81
+ ];
82
+ /** Every statement this step runs, in order. */
83
+ export const STEP_SEVEN_SQL = REBUILD;
84
+ export const stepSeven = {
85
+ version: 7,
86
+ summary: 'A browser may be claimed for a person before it has a process (§5.5.1).',
87
+ apply: (db) => {
88
+ for (const statement of STEP_SEVEN_SQL) {
89
+ db.exec(statement);
90
+ }
91
+ },
92
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Step eight: a sign-in records **which process is holding it**.
3
+ *
4
+ * ── The failure this exists to make recoverable ─────────────────────────
5
+ *
6
+ * `broker login` moves the browser to `signing-in` and gives it back in a
7
+ * `finally`. **A `finally` does not run when the process is signalled**, so a
8
+ * person who pressed Ctrl-C — the ordinary way anybody stops a command that is
9
+ * waiting — left the browser in `signing-in` with nothing able to move it out.
10
+ * Every caller was then refused `browser.serving` forever, and a second
11
+ * `broker login` was refused too, because §5.5.1 permits only one sign-in at a
12
+ * time. The state was reachable by one keystroke and escapable only by editing
13
+ * the database by hand.
14
+ *
15
+ * A signal handler is added alongside this step and covers the interruption
16
+ * case. **It cannot cover the rest**, and that is the whole reason this column
17
+ * exists: `SIGKILL` cannot be handled at all, a power cut runs nothing, and a
18
+ * process that dies in the kernel runs nothing either. Any mechanism that
19
+ * relies on the dying process doing something is a mechanism with a hole in
20
+ * exactly the cases nobody can rehearse.
21
+ *
22
+ * ── Why a new column rather than reusing `browsers.pid` ─────────────────
23
+ *
24
+ * **`browsers.pid` is the browser's process, not the sign-in's**, and the two
25
+ * are different processes with different lifetimes: the browser is adopted and
26
+ * deliberately outlives the command (`login-command.ts` detaches precisely so
27
+ * the person's window survives). Reading it as the owner would ask "is the
28
+ * browser still up" while meaning "is the person's command still running", and
29
+ * those answers diverge in both directions.
30
+ *
31
+ * Worse, step seven made `signing-in` the one state permitted a **null** pid,
32
+ * because a first sign-in on a fresh installation claims a browser that has
33
+ * never been started. That is the commonest sign-in there is, and it is
34
+ * precisely the one where `browsers.pid` carries nothing to test.
35
+ *
36
+ * ── Why not the lease sweep ─────────────────────────────────────────────
37
+ *
38
+ * The sweep expires **claims** whose `expires_at` has passed. A sign-in is not
39
+ * a claim: it takes no lease and no tab budget (§5.5.1), it is a state on the
40
+ * `browsers` row, and it has no expiry — deliberately, because a person
41
+ * signing in may take as long as they take and a sweep that timed them out
42
+ * would end a sign-in that was going fine. So there is no lapse for the sweep
43
+ * to notice, and extending it to notice one would mean inventing a deadline
44
+ * the design does not have.
45
+ *
46
+ * The question is settled by **evidence** instead: the owner is either running
47
+ * or it is not, which is a fact rather than a timer.
48
+ *
49
+ * ── Nullable, and what each value means ─────────────────────────────────
50
+ *
51
+ * - **Null while the browser is not signing in.** There is no owner, so
52
+ * recording one would be a claim about a process that is not doing anything.
53
+ * - **Set for the duration of a sign-in**, to the process identifier of the
54
+ * command that began it.
55
+ *
56
+ * It is deliberately **not** constrained to be non-null on `signing-in`. A
57
+ * store written by an older build carries `signing-in` rows with no owner, and
58
+ * a constraint would make this step fail on exactly the installations that hit
59
+ * the bug. Those rows read as "owner unknown", which the recovery path reports
60
+ * as such rather than reclaiming on a guess.
61
+ *
62
+ * **A new step rather than an edit to step seven**, per the rule in
63
+ * `steps.ts`: a step that has run somewhere is history.
64
+ */
65
+ const ADD_COLUMN = `ALTER TABLE browsers ADD COLUMN signin_owner_pid INTEGER`;
66
+ /** Every statement this step runs, in order. */
67
+ export const STEP_EIGHT_SQL = [ADD_COLUMN];
68
+ export const stepEight = {
69
+ version: 8,
70
+ summary: 'A sign-in records the process holding it, so an abandoned one is recoverable (§5.5.1).',
71
+ apply: (db) => {
72
+ for (const statement of STEP_EIGHT_SQL) {
73
+ db.exec(statement);
74
+ }
75
+ },
76
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Step nine: a browser's name is free, and its kind is the column.
3
+ *
4
+ * ── What this drops, and it is a decision rather than a widening ────────
5
+ *
6
+ * Step one wrote `CHECK (id IN ('regular', 'private'))` and step seven
7
+ * repeated it through a rebuild. That check is the database's half of
8
+ * *"exactly two browsers, no exceptions, ever"* — the half that makes the
9
+ * sentence true from underneath, since the primary key caps the table at two
10
+ * and step one's seed floors it at two.
11
+ *
12
+ * **That sentence is overturned** (`DECISIONS.md` §13i): the configured
13
+ * browsers are a bounded list per kind rather than a fixed pair, so the set
14
+ * of legal names is not knowable when the schema is written. A check
15
+ * naming two literals cannot express *"a name somebody configured"*, and a
16
+ * check that tried would have to be rewritten every time a name changed —
17
+ * which is a schema step per configuration edit, on a table whose whole
18
+ * purpose is to be configured.
19
+ *
20
+ * ── What stands in its place, so nothing load-bearing is merely dropped ────
21
+ *
22
+ * **`kind` is a column with its own check.** What that constraint
23
+ * actually protects is not the two words: it is that every row is either a
24
+ * persistent signed-in browser or an ephemeral clean-room one, with no third
25
+ * thing and no row that is neither. That property is what the rest of
26
+ * the design branches on — a clean-room browser is launched with an
27
+ * ephemeral profile, a signed-in one is not, and `SCHEMA.md` §5.5.1's
28
+ * sign-in is a claim over a persistent profile.
29
+ *
30
+ * So the kind stays **database-enforced and total** while the name becomes
31
+ * free. This is the same trade §1.2 already made in the other direction:
32
+ * *"No `persistent` flag. Whether a browser uses a persistent profile is a
33
+ * property of which browser it is."* That reasoning held while the name was
34
+ * one of two literals and the word carried the kind. Once a name is
35
+ * `checkout` or `admin`, the word carries nothing, and the property has to
36
+ * be written down or it is not enforced anywhere. **The column exists
37
+ * because the name stopped implying it**, which is the condition §1.2's
38
+ * reasoning was conditional on all along.
39
+ *
40
+ * ── Backfill: the two existing rows are named by what they are ──────────
41
+ *
42
+ * A store stepped by an earlier build holds exactly the rows `regular` and
43
+ * `private`, because that is what the check permitted. Each is backfilled to
44
+ * its own kind by name, which is exact rather than a guess: those two names
45
+ * meant those two kinds, and no other name could exist to be ambiguous.
46
+ *
47
+ * The default is deliberately **absent**. A default would let a later insert
48
+ * that forgot the column produce a row whose kind is a guess, and the row
49
+ * creation this step enables (`DECISIONS.md` §13i — rows are created on
50
+ * first launch, not from configuration at startup) is exactly the writer
51
+ * that must state it.
52
+ *
53
+ * ── Rows are still not created from configuration here ──────────────────
54
+ *
55
+ * This step creates no rows and deletes none. It does not read the
56
+ * configured lists, and could not usefully: two processes on one machine may
57
+ * hold different configurations, and a step that seeded from one process's
58
+ * environment would write that process's beliefs into a store the other one
59
+ * shares. §1.2a already arbitrates the launch race through the same
60
+ * transaction as claims — *"one row, one winner"* — so a row appears when a
61
+ * browser is first launched, created by whichever caller won that race.
62
+ *
63
+ * The two rows step one seeded stay exactly where they are. They are the
64
+ * default configuration's two browsers, and a store that has them is a store
65
+ * that was stepped.
66
+ *
67
+ * **A new step rather than an edit to step one or step seven**, per the rule
68
+ * in `steps.ts`: a step that has run somewhere is history. SQLite cannot
69
+ * alter a check in place, so the table is rebuilt — the same shape step
70
+ * seven used, and for the same reason.
71
+ */
72
+ const REBUILD = [
73
+ `
74
+ CREATE TABLE browsers_next (
75
+ -- **No check on the name.** The legal names are the configured ones, which
76
+ -- the schema cannot know. What a name must satisfy is enforced where it is
77
+ -- read: 'src/config/environment.ts' refuses a name that is not a usable
78
+ -- word, once, at startup, naming the entry that was wrong.
79
+ id TEXT PRIMARY KEY,
80
+ -- **The kind, and it is total.** Every row is a persistent signed-in
81
+ -- browser or an ephemeral clean-room one; there is no third and no row
82
+ -- that is neither. This is what the name used to carry.
83
+ kind TEXT NOT NULL
84
+ CHECK (kind IN ('regular', 'private')),
85
+ state TEXT NOT NULL DEFAULT 'stopped'
86
+ CHECK (state IN ('stopped', 'starting', 'running', 'signing-in', 'failed')),
87
+ pid INTEGER,
88
+ launched_at TEXT,
89
+ endpoint TEXT,
90
+ browser_uuid TEXT,
91
+ restart_count INTEGER NOT NULL DEFAULT 0
92
+ CHECK (restart_count >= 0),
93
+ -- **The defaults are part of the column**, and a rebuild that omits them
94
+ -- silently changes what an INSERT that does not name them does: it stops
95
+ -- defaulting from the database's own clock and starts failing NOT NULL.
96
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
97
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
98
+ signin_owner_pid INTEGER,
99
+ -- **A stopped browser has no process.** Unchanged from step one, total,
100
+ -- and it is the direction the reclamation path branches on.
101
+ CHECK (state != 'stopped' OR pid IS NULL),
102
+ -- **A browser with no process is stopped — unless a person has claimed it.**
103
+ -- Step seven's exception, carried forward unchanged: signing in is a claim
104
+ -- over the profile rather than over a process, so it is the one state that
105
+ -- may hold either.
106
+ CHECK (pid IS NOT NULL OR state IN ('stopped', 'signing-in'))
107
+ ) STRICT
108
+ `,
109
+ // The columns are named on both sides rather than `SELECT *`, because the
110
+ // destination carries a column the source does not: a positional copy
111
+ // would shift every value one place to the left from `kind` onward.
112
+ `
113
+ INSERT INTO browsers_next
114
+ (id, kind, state, pid, launched_at, endpoint, browser_uuid, restart_count,
115
+ created_at, updated_at, signin_owner_pid)
116
+ SELECT
117
+ id,
118
+ -- Exact rather than a guess: the check this step drops permitted these two
119
+ -- names and no others, and each named its own kind.
120
+ CASE id WHEN 'private' THEN 'private' ELSE 'regular' END,
121
+ state, pid, launched_at, endpoint, browser_uuid, restart_count,
122
+ created_at, updated_at, signin_owner_pid
123
+ FROM browsers
124
+ `,
125
+ `DROP TABLE browsers`,
126
+ `ALTER TABLE browsers_next RENAME TO browsers`,
127
+ ];
128
+ /** Every statement this step runs, in order. */
129
+ export const STEP_NINE_SQL = REBUILD;
130
+ export const stepNine = {
131
+ version: 9,
132
+ summary: "A browser's name is configured and its kind is a column (§1.2, DECISIONS.md §13i).",
133
+ apply: (db) => {
134
+ for (const statement of STEP_NINE_SQL) {
135
+ db.exec(statement);
136
+ }
137
+ },
138
+ };