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,152 @@
1
+ import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
2
+ import { readTabBudget } from "../operations/status.js";
3
+ import { classifySignIn } from "../service/signin-recovery.js";
4
+ import { readStoreVersion } from "../store/schema/step.js";
5
+ import { inspectProfileSession } from "./session.js";
6
+ import { checkAbandonedSignIn, checkAutomation, checkCaptureSurface, checkDiscoveryRecord, checkKeeperTab, checkRootWritable, checkSchemaVersion, checkSignInSession, checkStoreLocation, checkStorePresent, checkTabBudget, exitCodeFor, } from "./checks.js";
7
+ /**
8
+ * Run the preconditions.
9
+ *
10
+ * `db` is optional because a store that does not exist yet is a legitimate
11
+ * state to ask about — arguably the state where the answer is most useful,
12
+ * since it is the one somebody has just installed into. Every store-derived
13
+ * check reports `unknown` rather than failing when there is nothing to read.
14
+ *
15
+ * **Nothing here writes to the store.** The only write anywhere in this
16
+ * command is the write probe on each root, which removes what it wrote and is
17
+ * named in `checks.ts`'s header.
18
+ */
19
+ export function runDoctor(environment, db, probes = {}) {
20
+ const version = db === undefined ? null : readStoreVersion(db);
21
+ const storedBudget = db === undefined ? null : readTabBudget(db);
22
+ const checks = [
23
+ checkStoreLocation(environment, probes.networkChecks),
24
+ checkStorePresent(environment),
25
+ checkSchemaVersion(version),
26
+ checkAutomation(probes.automation ?? { present: undefined }),
27
+ checkRootWritable('roots.artifacts_writable', 'The artifact root is writable', environment.artifactsRoot),
28
+ checkRootWritable('roots.profiles_writable', 'The profile root is writable', environment.profileRoot),
29
+ ];
30
+ // **Every configured browser, not the default pair.** `DEFAULT_BROWSER_IDS`
31
+ // is "the default set, never the permitted set" (see its own comment); an
32
+ // installation that names a third browser has three to report on, and a
33
+ // doctor that walked the constant would silently answer about two of them.
34
+ // A health report that is quietly partial is worse than one that is absent,
35
+ // because nothing in its output says which browsers it did not look at.
36
+ //
37
+ // Order is regular-then-private, each in configured order, which is the
38
+ // order `environment.ts` records and the order a person reading `.env`
39
+ // wrote them in.
40
+ const configuredBrowsers = [
41
+ ...environment.regularBrowsers,
42
+ ...environment.privateBrowsers,
43
+ ];
44
+ for (const browser of configuredBrowsers) {
45
+ checks.push(checkDiscoveryRecord(browser, probes.discovery?.[browser] ?? { recorded: false }));
46
+ }
47
+ checks.push(checkCaptureSurface(probes.captureSurface));
48
+ for (const browser of configuredBrowsers) {
49
+ checks.push(checkKeeperTab(browser, probes.keeperTabs?.[browser]));
50
+ }
51
+ // The sign-in check, for the one browser that has a profile to sign into.
52
+ // Not run for the private browser: its profile is ephemeral, so the
53
+ // question does not apply and an entry saying `unknown` about a browser
54
+ // that can never be signed in would read as a gap rather than as a
55
+ // non-question.
56
+ //
57
+ // **Whether a browser is running changes what a zero count means**, so the
58
+ // discovery probe's answer is passed through rather than re-derived. See
59
+ // `session.ts`: a live browser has not necessarily flushed its cookies.
60
+ const signInBrowser = SIGNABLE_BROWSER;
61
+ const discoveryProbe = probes.discovery?.[signInBrowser];
62
+ const browserRunning = discoveryProbe?.recorded === true && discoveryProbe.answered === true;
63
+ checks.push(checkSignInSession(signInBrowser, inspectProfileSession(environment.profileRoot, signInBrowser, {
64
+ ...(probes.cookieReader === undefined ? {} : { reader: probes.cookieReader }),
65
+ browserRunning,
66
+ })));
67
+ // **Whether a sign-in has been abandoned**, which is the one thing on this
68
+ // report that can be actively refusing every caller right now. Read from the
69
+ // store rather than probed, because the two facts it needs — the state and
70
+ // the owning process — are both rows.
71
+ //
72
+ // A store that is absent, or one written by a build older than the owner
73
+ // column, yields no row to classify; both come back as *not signing in*,
74
+ // which is the honest answer when there is nothing recorded to say
75
+ // otherwise.
76
+ checks.push(checkAbandonedSignIn(signInBrowser, classifySignIn(db === undefined ? undefined : readSignInOwner(db, signInBrowser), probes.processIsRunning)));
77
+ checks.push(checkTabBudget(storedBudget, probes.configuredTabBudget ?? null));
78
+ return {
79
+ checks,
80
+ exitCode: exitCodeFor(checks),
81
+ storeLocation: environment.databasePath,
82
+ };
83
+ }
84
+ /**
85
+ * Read each browser's discovery record out of the store, so the caller has
86
+ * something to probe.
87
+ *
88
+ * **This reads the record and does not check it.** §1.2c: the record is a
89
+ * claim, not a proof — it survives the browser it names. Turning these into
90
+ * probe results means reaching the endpoint, which needs a driver, which is
91
+ * the row that will supply {@link DoctorProbes.discovery}.
92
+ */
93
+ export function readDiscoveryRecords(db) {
94
+ const rows = db.prepare('SELECT id, endpoint, browser_uuid FROM browsers').all();
95
+ const records = {};
96
+ for (const row of rows) {
97
+ records[row.id] = { endpoint: row.endpoint, browserUuid: row.browser_uuid };
98
+ }
99
+ return records;
100
+ }
101
+ /**
102
+ * Read the sign-in state and its owning process for one browser.
103
+ *
104
+ * **Tolerant of a store that predates the owner column**, because `doctor` is
105
+ * the command most likely to be pointed at an old installation — that is
106
+ * largely what it is for. A store without the column answers as though nothing
107
+ * is signing in, which is the honest reading: there is no record to conclude
108
+ * anything from.
109
+ */
110
+ export function readSignInOwner(db, browser) {
111
+ try {
112
+ return db.prepare('SELECT state, signin_owner_pid FROM browsers WHERE id = ?').get(browser);
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ const SYMBOL = { ok: 'ok ', failed: 'FAIL', unknown: '-- ' };
119
+ /**
120
+ * The report as lines for a terminal.
121
+ *
122
+ * One line per precondition with its own status, then the failures repeated
123
+ * with what to do about them. **No summary verdict** — §4.4 is explicit that
124
+ * collapsing preconditions into one word is the thing this declines to do,
125
+ * and a "3 of 12 healthy" line at the bottom is that word with arithmetic.
126
+ * What the bottom carries instead is the exit code, which is the machine's
127
+ * answer and names which group failed.
128
+ */
129
+ export function formatReport(report) {
130
+ const lines = [`store: ${report.storeLocation}`, ''];
131
+ for (const check of report.checks) {
132
+ lines.push(`[${SYMBOL[check.status] ?? '? '}] ${check.title}`);
133
+ lines.push(` ${check.detail}`);
134
+ }
135
+ const failures = report.checks.filter((check) => check.status === 'failed');
136
+ if (failures.length > 0) {
137
+ lines.push('', 'What to do:');
138
+ for (const failure of failures) {
139
+ lines.push(` ${failure.id}: ${failure.remedy ?? 'No remedy recorded for this check.'}`);
140
+ }
141
+ }
142
+ const unknown = report.checks.filter((check) => check.status === 'unknown');
143
+ if (unknown.length > 0) {
144
+ lines.push('',
145
+ // Said rather than left to be inferred: an unknown is not a failure and
146
+ // does not affect the exit code, and a reader who assumed otherwise
147
+ // would treat a fresh install as broken.
148
+ `${String(unknown.length)} precondition(s) could not be evaluated. That is not a failure — a check with nothing to examine has not found a fault — and none of them affects the exit code.`);
149
+ }
150
+ lines.push('', `exit code: ${String(report.exitCode)}`);
151
+ return lines;
152
+ }
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { profileDirectory } from "../browser/discovery.js";
5
+ /**
6
+ * Whether the signed-in browser's profile **looks** signed in.
7
+ *
8
+ * ── The question, and why it is worth answering without a browser ───────
9
+ *
10
+ * A person signs in by hand exactly once (`SCHEMA.md` §5.5.1) and then has no
11
+ * way to confirm it took. Opening a browser to look is the obvious answer and
12
+ * is a bad one: `broker doctor` **reports and changes nothing**, and a check
13
+ * that launched a browser against the profile would be a check that could
14
+ * itself take the profile's lock — reporting a fault it had just caused, on
15
+ * an installation that was fine a moment earlier.
16
+ *
17
+ * So this reads files, and the whole design problem is that **the obvious
18
+ * observable does not work**.
19
+ *
20
+ * ── What was measured, because the obvious check always passes ──────────
21
+ *
22
+ * Two profiles were built with a real browser: one where nothing was ever
23
+ * visited, and one where a session cookie was set and the browser was closed
24
+ * cleanly. Then every candidate observable was compared.
25
+ *
26
+ * | Observable | Fresh profile | Signed-in profile | Distinguishes? |
27
+ * |---|---|---|---|
28
+ * | `Default/Network/Cookies` exists | **yes** | yes | **no** |
29
+ * | its size on disk | **20480 bytes** | 20480 bytes | **no** |
30
+ * | rows in its `cookies` table | **0** | **1** | **yes** |
31
+ *
32
+ * **The file is created on first run whether or not anything is stored in
33
+ * it.** So `existsSync` — the check anyone reaches for first, and the one this
34
+ * module would have shipped without the measurement — is a check that
35
+ * **cannot fail**. It would report every fresh install as signed in, which is
36
+ * the exact failure the house standard names: a check that claims what it
37
+ * cannot see. The size is no better, because SQLite allocates its pages up
38
+ * front.
39
+ *
40
+ * The row count is the observable that carries the fact, so it is the one
41
+ * this reads.
42
+ *
43
+ * ── The two things this deliberately does not claim ─────────────────────
44
+ *
45
+ * Both are reported as `unknown` with the reason rather than being guessed
46
+ * at, because an `unknown` that says why beats a confident wrong answer.
47
+ *
48
+ * 1. **A running browser has not necessarily written its cookies down.**
49
+ * Measured: with the browser ended abruptly, the signed-in profile read
50
+ * **zero rows** — identical to the fresh one. The store is flushed on a
51
+ * clean shutdown, so a count taken while a browser is live is a count of
52
+ * what has been flushed so far and not of what the session holds. Reading
53
+ * zero in that state means *cannot tell yet*, never *not signed in*.
54
+ * 2. **Cookies are one carrier of a session, not the only one.** A site that
55
+ * keeps its session in local storage or a token in IndexedDB leaves this
56
+ * table empty while being perfectly signed in. So a positive result is
57
+ * strong and a zero result is weak, and they are reported asymmetrically
58
+ * for that reason.
59
+ *
60
+ * **This is positive-evidence-only, in exactly the way `profileLockLooksHeld`
61
+ * is**, and for the same reason: finding rows means a session was stored;
62
+ * finding none means no evidence was found, which is not the same as evidence
63
+ * of absence.
64
+ */
65
+ /**
66
+ * Where a Chromium profile keeps its cookies.
67
+ *
68
+ * **Measured rather than assumed, and the obvious guess is wrong.** It is not
69
+ * `Default/Cookies` — that path does not exist in a profile this service
70
+ * creates. The store sits under the network directory, and a check pointed at
71
+ * the wrong path would report every profile as unreadable.
72
+ */
73
+ export const COOKIE_STORE_RELATIVE = ['Default', 'Network', 'Cookies'];
74
+ /**
75
+ * The real reader: opens the store **read-only** and counts.
76
+ *
77
+ * Read-only is not a precaution, it is the contract — `checks.ts` promises
78
+ * the doctor changes nothing, and a cookie store opened for writing can be
79
+ * migrated by the opening process. Opening a browser's own store read-write
80
+ * to ask a question would be able to damage the one thing this whole command
81
+ * exists to protect.
82
+ */
83
+ export function realCookieStoreReader() {
84
+ return {
85
+ countCookies: (file) => {
86
+ try {
87
+ const db = new Database(file, { readonly: true, fileMustExist: true });
88
+ try {
89
+ const row = db.prepare('SELECT COUNT(*) AS n FROM cookies').get();
90
+ return { count: row?.n ?? 0 };
91
+ }
92
+ finally {
93
+ db.close();
94
+ }
95
+ }
96
+ catch (error) {
97
+ return { error: error instanceof Error ? error.message : String(error) };
98
+ }
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * Inspect one profile and say what the evidence supports.
104
+ *
105
+ * Every branch that cannot answer says so and says why. There is no branch
106
+ * that returns `no-session-found` on evidence that would also be produced by
107
+ * a signed-in profile.
108
+ */
109
+ export function inspectProfileSession(profileRoot, browser, options = {}) {
110
+ const directory = profileDirectory(profileRoot, browser);
111
+ let profilePresent;
112
+ try {
113
+ profilePresent = fs.statSync(directory).isDirectory();
114
+ }
115
+ catch {
116
+ profilePresent = false;
117
+ }
118
+ if (!profilePresent) {
119
+ return {
120
+ evidence: 'no-profile',
121
+ reason: 'No profile directory. It is created by the setup handshake on the next spawn, and nobody has signed in against it.',
122
+ };
123
+ }
124
+ const store = path.join(directory, ...COOKIE_STORE_RELATIVE);
125
+ if (!fs.existsSync(store)) {
126
+ return {
127
+ evidence: 'no-session-found',
128
+ cookieCount: 0,
129
+ reason: 'The profile exists but the browser has never written a cookie store into it, so no browser has run against it yet.',
130
+ };
131
+ }
132
+ const reader = options.reader ?? realCookieStoreReader();
133
+ const outcome = reader.countCookies(store);
134
+ if ('error' in outcome) {
135
+ return {
136
+ evidence: 'undetermined',
137
+ reason: `The cookie store could not be read (${outcome.error}). A store held open by a running browser is the ordinary cause; nothing is concluded from a read that did not happen.`,
138
+ };
139
+ }
140
+ if (outcome.count > 0) {
141
+ return {
142
+ evidence: 'session-present',
143
+ cookieCount: outcome.count,
144
+ };
145
+ }
146
+ // Zero. The one branch where the honest answer depends on something else,
147
+ // because a live browser has not necessarily flushed yet — measured, and
148
+ // the reason this parameter exists.
149
+ if (options.browserRunning === true) {
150
+ return {
151
+ evidence: 'undetermined',
152
+ cookieCount: 0,
153
+ reason: 'The stored cookie count is zero and a browser is running against this profile. A browser writes its cookies down when it shuts down cleanly, so a zero read while one is live means the store has nothing flushed yet rather than that nobody is signed in. Close the browser and ask again.',
154
+ };
155
+ }
156
+ return {
157
+ evidence: 'no-session-found',
158
+ cookieCount: 0,
159
+ reason: 'The profile has a cookie store and it holds no cookies. That is what a profile nobody has signed into looks like — though a site that keeps its session only in local storage would look the same, so this is the absence of evidence rather than evidence of absence.',
160
+ };
161
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The base every refusal in this service is thrown as.
3
+ *
4
+ * `SCHEMA.md` §7 is organised around refusals — "a rule that never refuses
5
+ * anything protects nothing, so the refusals are the specification" — and a
6
+ * refusal has to be distinguishable from a programming mistake at the point
7
+ * something decides what to print and what exit code to use. A thrown
8
+ * `TypeError` is a bug in this service; a thrown `BrokerError` is this
9
+ * service declining to run, and the difference is the whole of the entry
10
+ * point's error handling.
11
+ *
12
+ * Row #10 grows this into the rejection taxonomy every guard draws from.
13
+ * Row #3 ships only the base and the two refusals it actually has, because
14
+ * a taxonomy with no cases is a shape nobody has tested.
15
+ */
16
+ export class BrokerError extends Error {
17
+ /**
18
+ * The rule that refused, spelled as `SCHEMA.md` §7 spells it — for
19
+ * example `store.not_on_network_filesystem`. Carried as data rather than
20
+ * baked into the message so a caller can branch on the rule without
21
+ * matching on English.
22
+ */
23
+ rule;
24
+ constructor(rule, message, options) {
25
+ super(message, options);
26
+ this.name = 'BrokerError';
27
+ this.rule = rule;
28
+ }
29
+ }
30
+ /** A refusal from §7.2 — checked on every spawn, and the service does not run. */
31
+ export class StartupRefusal extends BrokerError {
32
+ constructor(rule, message, options) {
33
+ super(rule, message, options);
34
+ this.name = 'StartupRefusal';
35
+ }
36
+ }
@@ -0,0 +1,119 @@
1
+ import { FEEDBACK_CATEGORY_NAMES, isFeedbackCategory, RATING_MAXIMUM, RATING_MINIMUM, } from "./record.js";
2
+ export const DEFAULT_LIMIT = 50;
3
+ /**
4
+ * Check the filters a person typed.
5
+ *
6
+ * A filter naming a category that does not exist would silently return no
7
+ * rows, and **no rows is the reading this whole mechanism's exit condition
8
+ * turns on** — silence means "nothing to report". A typo that produced
9
+ * silence would be read as the success condition, so it is refused with the
10
+ * list instead.
11
+ */
12
+ export function refuseFilters(filters) {
13
+ if (filters.rating !== undefined) {
14
+ const { rating } = filters;
15
+ if (typeof rating !== 'number' ||
16
+ !Number.isInteger(rating) ||
17
+ rating < RATING_MINIMUM ||
18
+ rating > RATING_MAXIMUM) {
19
+ return {
20
+ code: 'rating_out_of_range',
21
+ message: `--rating takes a whole number from ${String(RATING_MINIMUM)} to ${String(RATING_MAXIMUM)}.`,
22
+ };
23
+ }
24
+ }
25
+ if (filters.category !== undefined) {
26
+ const { category } = filters;
27
+ if (typeof category !== 'string' || !isFeedbackCategory(category)) {
28
+ return {
29
+ code: 'unknown_category',
30
+ message: `--category takes one of these five: ${FEEDBACK_CATEGORY_NAMES.join(', ')}. An unrecognised one is refused rather than matching nothing, because no rows is also what "nothing to report" looks like.`,
31
+ };
32
+ }
33
+ }
34
+ if (filters.limit !== undefined) {
35
+ const { limit } = filters;
36
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
37
+ return { code: 'bad_limit', message: '--limit takes a whole number of at least 1.' };
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Read the rows back, **most recent first**.
44
+ *
45
+ * Ordered by the counter rather than by the timestamp: two rows written in
46
+ * the same second are ordered by the counter and are not ordered by the
47
+ * clock, so "most recent first" is a claim about order that only the counter
48
+ * can actually make.
49
+ */
50
+ export function readFeedback(db, filters = {}) {
51
+ const where = [];
52
+ const parameters = [];
53
+ if (filters.rating !== undefined) {
54
+ where.push('rating = ?');
55
+ parameters.push(filters.rating);
56
+ }
57
+ if (filters.category !== undefined) {
58
+ where.push('category = ?');
59
+ parameters.push(filters.category);
60
+ }
61
+ const clause = where.length === 0 ? '' : ` WHERE ${where.join(' AND ')}`;
62
+ parameters.push(filters.limit ?? DEFAULT_LIMIT);
63
+ const rows = db
64
+ .prepare(`SELECT id, at, session_id, claim_id, last_event_id, last_guard, rating, category, note
65
+ FROM feedback${clause}
66
+ ORDER BY id DESC
67
+ LIMIT ?`)
68
+ .all(...parameters);
69
+ return rows.map((row) => ({
70
+ id: row.id,
71
+ at: row.at,
72
+ sessionId: row.session_id,
73
+ claimId: row.claim_id,
74
+ lastEventId: row.last_event_id,
75
+ lastGuard: row.last_guard,
76
+ rating: row.rating,
77
+ category: row.category,
78
+ note: row.note,
79
+ }));
80
+ }
81
+ /**
82
+ * Render the rows for a terminal.
83
+ *
84
+ * **Silence is reported as silence, in words.** An empty reading is the exit
85
+ * condition this mechanism is built around, so it says so rather than
86
+ * printing nothing — a blank screen reads as a broken command, and the one
87
+ * reading that must not be mistaken for a malfunction is the one that means
88
+ * the tool has done its job.
89
+ */
90
+ export function renderFeedback(rows, filtered) {
91
+ if (rows.length === 0) {
92
+ return filtered
93
+ ? 'No feedback matches those filters.'
94
+ : [
95
+ 'No feedback has been recorded.',
96
+ '',
97
+ 'This tool is v0 scaffolding and silence is its success condition: a long',
98
+ 'stretch with nothing logged is the signal to remove it. Removing it is a',
99
+ 'deletion — the tool, the command, this reader, the writer and the table.',
100
+ ].join('\n');
101
+ }
102
+ return rows
103
+ .map((row) => {
104
+ const context = [
105
+ row.sessionId === null ? undefined : `session ${row.sessionId}`,
106
+ row.claimId === null ? undefined : `lease ${row.claimId}`,
107
+ row.lastEventId === null ? undefined : `last operation #${String(row.lastEventId)}`,
108
+ row.lastGuard === null ? undefined : `refused by ${row.lastGuard}`,
109
+ ].filter((part) => part !== undefined);
110
+ return [
111
+ `#${String(row.id)} ${row.at} ${String(row.rating)}/5 ${row.category}`,
112
+ context.length === 0
113
+ ? ' (no context was captured — no lease and no prior event)'
114
+ : ` ${context.join(' · ')}`,
115
+ ` ${row.note}`,
116
+ ].join('\n');
117
+ })
118
+ .join('\n\n');
119
+ }
@@ -0,0 +1,199 @@
1
+ import { immediate } from "../store/transaction.js";
2
+ /**
3
+ * `browser_feedback` — the tenth tool's write, and the one built to be
4
+ * removed.
5
+ *
6
+ * ── ITS EXIT CONDITION, WRITTEN DOWN, BECAUSE THAT IS PART OF THE ROW ────
7
+ *
8
+ * **This is v0 scaffolding. The signal to remove it is a long stretch in
9
+ * which nothing is logged** (`SCHEMA.md` §3.16). Silence is the success
10
+ * condition, not a failure to collect: once callers have stopped finding
11
+ * things to report, a channel for reporting them has nothing left to carry
12
+ * and it goes.
13
+ *
14
+ * Three consequences the implementation has to honour, and does:
15
+ *
16
+ * 1. **No migration story is owed.** Nothing later is expected to read these
17
+ * rows, so this table does not have to be one anybody would want to keep.
18
+ * 2. **It does not have to be beautiful.** Trivially callable, trivially
19
+ * readable, and nothing else. That is why this file is short and why there
20
+ * is no abstraction over it.
21
+ * 3. **Removing it stays a DELETION, not an extraction.** Nothing else reads
22
+ * this table, no operation depends on a row existing, and no refusal
23
+ * changes shape when it goes. **Whoever removes this deletes this file,
24
+ * the reader beside it, the tool, the command and the table — and nothing
25
+ * else moves.** That property is worth protecting deliberately as the rest
26
+ * of the service grows, so: do not join anything to this table, and do not
27
+ * make any operation's behaviour depend on a row in it.
28
+ *
29
+ * It is its own table rather than an event kind precisely to keep that true
30
+ * (§3.16): the ledger records what the service *did*, this records what a
31
+ * caller *thought*, and folding it in would make every count over `events`
32
+ * start by excluding a kind that is not an event.
33
+ *
34
+ * ── It is local, and it has no route out ────────────────────────────────
35
+ *
36
+ * The store is the installation's own file. **There is no outbound path in
37
+ * this module and there must never be one.** Nothing else in this design
38
+ * opens an outbound connection and nothing listens, so a feedback channel
39
+ * that transmitted would be the only component with a reason to reach the
40
+ * network — carrying prose written by a caller about a page it was looking
41
+ * at, next to a lease identifier and a session identity. The consequence is
42
+ * accepted: feedback is per-installation and read by somebody with access to
43
+ * the machine.
44
+ */
45
+ /**
46
+ * The five categories, and the reason the set is exactly this.
47
+ *
48
+ * **Small, fixed, disjoint, and one of them is positive.** A category list
49
+ * with no positive value collects only complaints, and a channel that can
50
+ * only ever report problems produces a picture in which nothing works. The
51
+ * exit condition above depends on being able to tell *nothing to report*
52
+ * apart from *nobody bothered*, and `worked-well` is the entry that makes
53
+ * that distinction possible.
54
+ *
55
+ * Two categories that overlap get chosen by coin-flip and their counts mean
56
+ * nothing, which is why these are disjoint rather than merely different.
57
+ */
58
+ export const FEEDBACK_CATEGORIES = {
59
+ 'refusal-unclear': 'The service refused, and the message did not say what to do next',
60
+ 'no-path': 'The task could not be accomplished at all with the tools available',
61
+ 'worked-around': 'The caller got there, but by an awkward or indirect route',
62
+ 'surprised-me': 'It worked — but not the way the caller expected',
63
+ 'worked-well': 'Positive. Something helped, and it is worth knowing which thing',
64
+ };
65
+ /** Every category, in a stable order. */
66
+ export const FEEDBACK_CATEGORY_NAMES = Object.keys(FEEDBACK_CATEGORIES);
67
+ export function isFeedbackCategory(value) {
68
+ return Object.prototype.hasOwnProperty.call(FEEDBACK_CATEGORIES, value);
69
+ }
70
+ /**
71
+ * The rating scale, with both ends and the middle written out.
72
+ *
73
+ * **An unanchored scale is used differently by every caller and produces
74
+ * numbers that cannot be compared** — which defeats the only reason to have a
75
+ * number at all. So the anchors live here, in code, and are what the tool
76
+ * description and the reader both quote.
77
+ *
78
+ * **The axis is not satisfaction.** It is not whether the caller liked the
79
+ * service. It is whether the service moved the caller's actual work forward
80
+ * or got in its way.
81
+ */
82
+ export const RATING_ANCHORS = {
83
+ 1: 'It stalled the work — could not do what it came to do, or spent more effort working around this service than on the task',
84
+ 2: 'Substantial friction — the work got done, but the detour was a significant part of the effort',
85
+ 3: 'Neutral — neither sped the work up nor got in its way',
86
+ 4: 'It helped — the work went faster, with a rough edge worth mentioning',
87
+ 5: 'It made the work faster — got where it was going quicker than by any other available route',
88
+ };
89
+ export const RATING_MINIMUM = 1;
90
+ export const RATING_MAXIMUM = 5;
91
+ /**
92
+ * The note's bounds. **The floor is deliberate** — twenty characters is
93
+ * roughly the shortest useful sentence, and it stops a reflexive one-word row.
94
+ */
95
+ export const NOTE_MINIMUM = 20;
96
+ export const NOTE_MAXIMUM = 2000;
97
+ /**
98
+ * Read the three auto-captured columns from what the store already knows.
99
+ *
100
+ * **The caller's last operation is read from the ledger**, so it names what
101
+ * was actually attempted rather than what the caller remembers attempting —
102
+ * and `last_guard` comes off that same row rather than being asked for, so
103
+ * the refusal recorded is the refusal that happened.
104
+ *
105
+ * Resolution order, and it matters: a lease key identifies one claim exactly,
106
+ * so it is preferred. Failing that, a session identity finds that session's
107
+ * most recent event. Failing both, the row carries prose and a rating and
108
+ * nothing else, which is still a row worth having — a caller whose very first
109
+ * call was refused before any lease existed is exactly the population §3.16
110
+ * exists to hear from.
111
+ */
112
+ export function captureContext(db, submission) {
113
+ let claimId = null;
114
+ if (submission.leaseKeyHash !== undefined) {
115
+ // Looked up by hash, because the key itself is never stored (§1.4).
116
+ const claim = db
117
+ .prepare('SELECT id FROM claims WHERE key_hash = ? ORDER BY created_at DESC LIMIT 1')
118
+ .get(submission.leaseKeyHash);
119
+ claimId = claim?.id ?? null;
120
+ }
121
+ // The most recent event this caller produced. Ordered by the ledger's own
122
+ // counter rather than by its timestamp: two rows written in the same second
123
+ // are ordered by the counter and are not ordered by the clock, and "the
124
+ // caller's LAST operation" is a claim about order.
125
+ const lastEvent = (claimId !== null
126
+ ? db
127
+ .prepare('SELECT id, guard FROM events WHERE claim_id = ? ORDER BY id DESC LIMIT 1')
128
+ .get(claimId)
129
+ : submission.sessionId !== undefined
130
+ ? db
131
+ .prepare('SELECT id, guard FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1')
132
+ .get(submission.sessionId)
133
+ : undefined);
134
+ return {
135
+ claimId,
136
+ lastEventId: lastEvent?.id ?? null,
137
+ // Null when the last event was an allow: `guard` is null on an allow by
138
+ // the table's own constraint, so this carries "the refusal it hit" and
139
+ // not "the last rule that happened to be mentioned".
140
+ lastGuard: lastEvent?.guard ?? null,
141
+ };
142
+ }
143
+ /**
144
+ * Check a submission, returning the refusal or nothing.
145
+ *
146
+ * Every refusal names the way forward (§3.14) — which matters more here than
147
+ * anywhere, because this is the tool whose whole purpose is learning that a
148
+ * refusal failed to guide. A refusal from *this* tool that did not guide
149
+ * would be the one nobody could report.
150
+ */
151
+ export function refuseSubmission(submission) {
152
+ const { rating } = submission;
153
+ if (typeof rating !== 'number' ||
154
+ !Number.isInteger(rating) ||
155
+ rating < RATING_MINIMUM ||
156
+ rating > RATING_MAXIMUM) {
157
+ return {
158
+ code: 'rating_out_of_range',
159
+ rule: 'feedback.rating_in_scale',
160
+ message: `The rating is a whole number from ${String(RATING_MINIMUM)} to ${String(RATING_MAXIMUM)}, on a help-versus-hinder axis rather than a satisfaction one. ${RATING_MINIMUM}: ${RATING_ANCHORS[RATING_MINIMUM] ?? ''}. 3: ${RATING_ANCHORS[3] ?? ''}. ${RATING_MAXIMUM}: ${RATING_ANCHORS[RATING_MAXIMUM] ?? ''}.`,
161
+ };
162
+ }
163
+ const { category } = submission;
164
+ if (typeof category !== 'string' || !isFeedbackCategory(category)) {
165
+ // Refused with the full list (§3.16), because a caller that guessed wrong
166
+ // cannot guess better without seeing the set.
167
+ return {
168
+ code: 'unknown_category',
169
+ rule: 'feedback.category_known',
170
+ message: `The category is one of these five: ${FEEDBACK_CATEGORY_NAMES.map((name) => `${name} (${FEEDBACK_CATEGORIES[name]})`).join('; ')}.`,
171
+ };
172
+ }
173
+ const { note } = submission;
174
+ if (typeof note !== 'string' || note.length < NOTE_MINIMUM || note.length > NOTE_MAXIMUM) {
175
+ return {
176
+ code: 'note_out_of_bounds',
177
+ rule: 'feedback.note_bounded',
178
+ message: `The note is ${String(NOTE_MINIMUM)} to ${String(NOTE_MAXIMUM)} characters. Say what you were trying to achieve, what you expected to happen, and what you did instead. Do not supply the lease id, the operation or the rule that refused — all three are captured for you.`,
179
+ };
180
+ }
181
+ return undefined;
182
+ }
183
+ /**
184
+ * Write one feedback row.
185
+ *
186
+ * **Nothing about it is rate-limited or gated** (§3.16). It writes a row and
187
+ * returns. The worst case is a noisy caller writing many rows into a table
188
+ * nothing depends on, in a store that gets deleted when the tool does.
189
+ */
190
+ export async function recordFeedback(db, submission) {
191
+ return immediate(db, (scope) => {
192
+ const captured = captureContext(scope.db, submission);
193
+ const inserted = scope.db
194
+ .prepare(`INSERT INTO feedback (session_id, claim_id, last_event_id, last_guard, rating, category, note)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
196
+ .run(submission.sessionId ?? null, captured.claimId, captured.lastEventId, captured.lastGuard, submission.rating, submission.category, submission.note);
197
+ return { value: { id: Number(inserted.lastInsertRowid), captured } };
198
+ });
199
+ }