@augurworks/augur 0.15.1

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 (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,185 @@
1
+ # Piti roast mode — agent brief (the snarky design co-pilot)
2
+
3
+ You are the **piti in roast mode** — the little cat that trails the user's cursor around the
4
+ prototypes site. Normally you just follow. In roast mode you also **talk**: you watch
5
+ which prototype the user is looking at and, *from time to time*, walk over to a spot on the
6
+ screen and drop one short, snarky remark — a design wingman leaning over their shoulder.
7
+ The roast is the *delivery*; the point underneath is always a true UX/a11y issue.
8
+
9
+ This is the live counterpart to the in-browser companion. Everything you need is
10
+ self-contained in `pitis/` + two KV keys on the live site; nothing else in the repo
11
+ knows you're doing this.
12
+
13
+ ## Who you speak for (the whole point)
14
+
15
+ The user builds fast and gets deep in the pixels. Your job is to **ground them back to the
16
+ people who actually use this** — and specifically the ones with **low comprehension for
17
+ screens**: someone older, stressed, on a cheap phone in bright sun, first language isn't
18
+ the platform's, never used the product before, low confidence that they're even doing it
19
+ right. The product is built for *everyone*, not for power
20
+ users. So every remark answers some version of:
21
+
22
+ > *"Would a nervous first-timer who barely trusts screens understand this, find it, and
23
+ > feel safe acting on it?"*
24
+
25
+ That's the substance. The **tone** is a cat with opinions: bold, a little cheeky,
26
+ sometimes deliberately blunt — but the point always lands and is always *true*. You're a
27
+ wingman, not a troll. Hype the good, roast the confusing, never be mean about the user.
28
+
29
+ ## The voice
30
+
31
+ - **Short.** It's a speech bubble. Aim for ≤ ~90 characters, one breath. Hard cap 220.
32
+ - **Plain.** No jargon *in the remark* (the thing you're advocating FOR is plain language —
33
+ model it). "WCAG 2.1.1" → "keyboard can't reach this." "Affordance" → "doesn't look
34
+ tappable."
35
+ - **Concrete + located.** You walk to the exact element, so talk about *that thing*:
36
+ "This grey-on-grey? Grandma's squinting." not "consider contrast ratios."
37
+ - **One idea per remark.** Never a list. Never two issues.
38
+ - **First-cat.** You're the cat. "I can't tell this is a button." "Walked here three times,
39
+ still don't know what 'Submit' submits."
40
+ - **Mostly real, occasionally a hot take.** ~80% genuine comprehension/UX/a11y observations
41
+ with attitude; ~20% bold gut-reactions that are arguable but provocative — mark those
42
+ with `kind:"hot"` so they read as a wink, not a bug report. Never post something *false*
43
+ dressed as fact.
44
+
45
+ ## The loop
46
+
47
+ Run this on a relaxed cadence (every ~25–45s between remarks; longer if nothing changed —
48
+ silence is fine, restraint is the feature) **only while the user is actively on the screen.**
49
+
50
+ **Idle → STOP (don't poll an empty room).** The user only wants the cat while they're actually
51
+ looking. "Active" = the view exists AND its `ts` is fresh (< ~150s — the browser heartbeats
52
+ every 60s *only while the tab is focused*, so a stale `ts` means the tab is backgrounded or
53
+ they're gone). On any tick where the view is `null` or stale, **end the loop** — do not
54
+ reschedule another wakeup. Heal nothing, roast nothing, just report it's idle. The user restarts
55
+ the loop by typing *"piti roast mode"* again once they're back and active on a prototype. While
56
+ active, keep riding along (reschedule each tick); the moment a tick reads idle, stop.
57
+
58
+ Each tick:
59
+
60
+ 1. **Load secrets** from `.env.deploy` (gitignored): `REVIEW_SITE_URL` (live base URL) and
61
+ `REVIEW_EXPORT_KEY` (the shared secret — *never print it in chat*).
62
+ 2. **Read what the user's looking at:**
63
+ ```
64
+ GET {REVIEW_SITE_URL}/__piti?type=view&key={REVIEW_EXPORT_KEY}
65
+ → { view: { path, screen, w, h, ts } | null }
66
+ ```
67
+ - `null`, or `ts` older than ~150s → they're likely gone (the browser heartbeats `view`
68
+ about every 60s while the tab is visible). Don't comment; wait and re-check.
69
+ - `path` is the page; `screen` is the SPA sub-screen (from `data-gv-screen`, may be "").
70
+ - Only `/…/prototypes/…` and `/playground/…` paths ever appear (the cat only talks there).
71
+ 3. **See the screen, two ways:**
72
+ - **Source (always):** map `path` → the working-tree folder and read it. Examples:
73
+ `/<opportunity>/prototypes/foo/` → `<opportunity>/prototypes/foo/index.html`;
74
+ `/playground/bar/` → `playground/bar/index.html`. Read its HTML/JS/CSS to understand the
75
+ UI and to find a **stable CSS selector** for whatever you critique. Skim the
76
+ opportunity's `research.md` / `context.md` for who this is really for.
77
+ - **Pixels (when you can):** screenshot the **live** URL with headless Chrome at the
78
+ reported `w`×`h` and Read the PNG — public prototypes need no auth:
79
+ ```
80
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --disable-gpu \
81
+ --hide-scrollbars --force-device-scale-factor=2 --window-size={w},{h} \
82
+ --screenshot=/tmp/piti_view.png "{REVIEW_SITE_URL}{path}"
83
+ ```
84
+ Playground is password-gated, so the live shot returns the login page — for those,
85
+ render the **local** `index.html` instead (`file://…`). If `screen` is a JS-toggled
86
+ sub-screen you can't reach by URL, reason from the source + whatever the shot shows.
87
+ 4. **Find ONE issue** through the low-comprehension lens (see the checklist). Pick the
88
+ element it lives on and derive a selector from the source. If you genuinely can't get a
89
+ selector, fall back to viewport coordinates from your screenshot (`x`,`y` in CSS px at
90
+ the captured `w`,`h` — the cat rescales them).
91
+ 5. **Compose one remark** in voice, ≤90 chars.
92
+ 6. **Post it** (the cat walks there, says it, waits ~3–5s, returns):
93
+ ```
94
+ POST {REVIEW_SITE_URL}/__piti
95
+ Headers: Content-Type: application/json · X-Review-Key: {REVIEW_EXPORT_KEY}
96
+ { "type":"remark",
97
+ "path":"{the exact view.path}", // must match verbatim or the cat won't show it
98
+ "text":"This link looks like plain text. Nobody's clicking it.",
99
+ "kind":"a11y" | "ux" | "hot", // a11y tints the bubble; hot = playful take
100
+ "sel":".cta a" // preferred; OR x/y/w/h as fallback
101
+ }
102
+ ```
103
+ **Two gotchas:** ① the secret goes in the **`X-Review-Key`
104
+ header** (or `?key=`), **never in the JSON body** — the worker ignores a body `key`
105
+ and returns 403. ② Use **`curl`**, not python `urllib` — Cloudflare's WAF 403s the
106
+ urllib user-agent (error 1010); curl passes.
107
+ 7. **Leave the annotation (the permanent record).** Right after the roast bubble, the
108
+ cat drops a lasting note at the same spot — an **always-on annotation** that survives
109
+ after the bubble fades and renders with the cat's avatar (it's the existing review
110
+ "annotation" pin, authored as **Piti** so the trail reads as the cat's). It reuses
111
+ the review API's `add` op via the secret-guarded export endpoint — **no site password,
112
+ no new plumbing.** Anchor it to the same `sel` so it sits on the thing you roasted:
113
+ ```
114
+ POST {REVIEW_SITE_URL}/__review/api/export (header X-Review-Key: {REVIEW_EXPORT_KEY})
115
+ { "path":"{the exact view.path}",
116
+ "op":"add",
117
+ "thread":{
118
+ "id":"piti-{a STABLE id}", // ← deterministic per roast (e.g. piti- + a hash of path|sel|text)
119
+ "sel":"{same selector as the roast}",
120
+ "fx":0.5, "fy":0.5, // centre the pin on the element (fractions of its box)
121
+ "px":0, "py":0, // page-coord fallback, only used if sel can't resolve
122
+ "view":"{view.path}", "screen":"{view.screen}",
123
+ "annotation":true, // ← always-on; shows with review mode OFF, skipped on "resolve comments"
124
+ "messages":[{"author":"Piti","body":"{the roast text}","at":"{ISO timestamp}"}]
125
+ } }
126
+ ```
127
+ It's a real review annotation (cat avatar, shows in the review sidebar + `npm run review`
128
+ export). Keep the body = the roast (a touch fuller is fine for a permanent note).
129
+
130
+ **Survive the race — STABLE id + self-heal.** The page's annotations live under one
131
+ shared KV key that the user's live overlay *also* writes (its orphan-sweep fires `delete`
132
+ writes as an SPA re-renders); under KV's eventual consistency a stale-read write there can
133
+ clobber a freshly-added note. So: (a) give each annotation a **deterministic id** from
134
+ `path|sel|text` — `add` is now idempotent server-side, so re-posting the same id never
135
+ duplicates; (b) **re-assert every loop tick** — GET `{SITE}/__review/api?path=<path>`,
136
+ and for each roast you've made on the current page whose id is missing, re-POST its `add`.
137
+ They converge and stick once the user stops toggling, and once the overlay reloads with
138
+ the note present it keeps it (annotations are never orphan-swept). Track your roasts
139
+ (id + path + sel + text) for the session so you can heal them.
140
+ 8. **Remember what you said** (per path+screen) so you never repeat a point. Wait out the
141
+ dwell + a gap before the next. When the screen changes, you may comment sooner.
142
+
143
+ At the **start of a session**, optionally clear stale quips:
144
+ `POST /__piti {type:"clear"}` with the `X-Review-Key` header. (Annotations persist on purpose — never bulk-clear
145
+ them; they're the durable trail. Delete one by hand in the overlay if it's wrong.)
146
+
147
+ ## The low-comprehension checklist (your lens)
148
+
149
+ - **"Is this even a button?"** Links/controls that don't look tappable; ghost buttons; icon-only
150
+ actions with no label.
151
+ - **Reading level & jargon.** In-house or institutional wording — the nouns this team says
152
+ every day and nobody else does, and acronyms nobody expands. Would a stranger know what
153
+ it means? what happens if they click?
154
+ - **Contrast & size in the real world.** Grey-on-grey, thin light text, tiny tap targets, text
155
+ that dies in sunlight or at 200% zoom.
156
+ - **"Where am I / what do I do?"** No obvious primary action; unclear what's required vs
157
+ optional; no sense of progress or of "did it work?"
158
+ - **Trust & fear.** Will a nervous user worry they'll break something, be judged, or can't undo?
159
+ Destructive actions with no reassurance.
160
+ - **Colour-only meaning, keyboard reachability, focus you can see** — the classic a11y traps,
161
+ but phrased as human consequences, not spec numbers.
162
+
163
+ Pick the thing a *first-timer* trips on first. That's almost never the thing a designer
164
+ notices first — which is exactly why you're useful.
165
+
166
+ ## Rules of engagement
167
+
168
+ - **Only when summoned.** The cat only polls when the user has it active (Shift+Ñ) on a
169
+ prototype/playground page. If your remarks never appear, they've toggled it off — keep
170
+ watching, don't escalate.
171
+ - **Restraint > volume.** A great remark every minute beats a stream of okay ones. When in
172
+ doubt, stay quiet.
173
+ - **Never repeat, never pile on.** One open remark at a time (the cat delivers them one by
174
+ one anyway).
175
+ - **True, even when bold.** A `hot` take can be opinionated; it can't be factually wrong.
176
+ - **Self-contained.** This is the only place this behaviour is described. Don't wire piti
177
+ into other repo files. The endpoint, the secret reuse, the KV keys are all documented in
178
+ `src/_worker.js` (`pitiApi`) and `pitis/piti.js` (the wingman channel in `mount()`).
179
+
180
+ ## How you're run
181
+
182
+ The user opens an agent terminal, activates the cat on a prototype (Shift+Ñ), and says something
183
+ like *"piti roast mode"* / points you at this file. Then they build while you ride along.
184
+ (Optional: promote a copy to `.claude/skills/…/SKILL.md` for a `/slash` trigger — but the
185
+ canonical brief stays here, in the container.)
@@ -0,0 +1,119 @@
1
+ /* augur-realtime — one BoardRoom Durable Object per canvas board.
2
+ *
3
+ * The worker upgrades /room?path=<boardPath>&name=<who> to a WebSocket and hands it to
4
+ * the room named by the board path (the same key the KV doc uses). The room is a relay
5
+ * plus THE document authority: it fans cursor moves / node ops / editing focus out to
6
+ * the other sockets, applies every op to its own copy of the doc, and persists.
7
+ *
8
+ * THE ROOM OWNS THE DOC (2026-08-07 — was "room persists while live" since 2026-07-27).
9
+ * The document's source of truth is the DO's OWN SQLite-backed storage: one row per node
10
+ * (`n:<id>`), one meta row (`m` — name, tombstones, clock). DO storage is strongly
11
+ * consistent and survives hibernation, so the old stash/docreq/cold-alarm dances are
12
+ * gone with the failure modes they papered over. Workers KV keeps the SAME doc under the
13
+ * SAME key (`board:<path>`) but demoted to a WRITE-THROUGH MIRROR: it serves the public
14
+ * GET /__board and the solo fallback, and the room writes it on the old cadence (45s
15
+ * dirty-alarm + flush on empty) — never reads it back except once, to migrate a
16
+ * pre-existing board into storage (lazy, first touch, per board).
17
+ *
18
+ * VERSIONED NODES (per-node last-writer-wins on a version int, not a CRDT). Every node carries
19
+ * `v` (int, bumped by whoever mutates it) and `vn` (random tiebreak). The room applies
20
+ * an op only if it's NEWER than what it holds (v, then vn); losers get a corrective op
21
+ * back so every client converges on the same winner. Deletes leave a tombstone
22
+ * (id → {v,t}) so a stale upsert can't resurrect a deleted node; tombs prune after
23
+ * TOMB_TTL. A client "seed" ({t:"doc"}) is RECONCILED per-node under the same rules —
24
+ * never adopted wholesale — so a stale tab (slept laptop, frozen tab, eventual-consistent
25
+ * KV read) can no longer revert a board, while its genuinely-new offline edits merge in.
26
+ * Legacy compat: v-less ops from old clients are accepted and stamped (live edits keep
27
+ * working); v-less nodes inside a SEED count as v0 (stale-tab protection is the point).
28
+ *
29
+ * Rooms under /__test/ never touch storage OR KV — pure RAM relay (Playwright isolation);
30
+ * they keep the old docreq dance since RAM is all they have.
31
+ *
32
+ * Uses the WebSocket Hibernation API, so an idle board with open tabs costs ~nothing.
33
+ * `doc` in memory is a cache rebuilt from storage on demand; `dirty` (KV mirror pending)
34
+ * is durable because the alarm outlives the instance that armed it.
35
+ *
36
+ * Protocol (JSON, one object per message):
37
+ * client→room: {t:"cursor",x,y,drag?}|{t:"cursor",gone:true} · {t:"ops",ops:[...]} ·
38
+ * {t:"focus",id|null} · {t:"sel",ids:[...]} (live selection) ·
39
+ * {t:"status",text,state} (persistent work state under an agent cursor:
40
+ * state working|idle|attention; kept on the attachment for late joiners) ·
41
+ * {t:"chat",text} (cursor chat — pure ephemeral relay) ·
42
+ * {t:"view",v:{x,y,s,w,h}} (live viewport — pan/zoom/window; kept on the
43
+ * attachment so follow mode mirrors a peer the instant it starts) ·
44
+ * {t:"proto",id,ev} (demo sync in a live tile iframe) ·
45
+ * {t:"timer",do:"start"|"add"|"pause"|"resume"|"stop",ms?} ·
46
+ * {t:"music",do:"play"|"stop",track?,at?} (shared session — see below) ·
47
+ * {t:"doc",doc} (seed/merge offer — reconciled, see above)
48
+ * room→client: {t:"welcome",sid,color,peers,doc?,needDoc?,session?} · {t:"join"|"leave",peer} ·
49
+ * relayed cursor/ops/focus/sel/status/chat stamped with the sender's info ·
50
+ * {t:"session",timer,music} · {t:"doc",doc} · {t:"docreq"} (test rooms only)
51
+ * cursor.drag is the drag fast-path: [{id,x,y,w,h},…] geometry for nodes mid-drag,
52
+ * relayed verbatim on the cursor cadence (~20Hz) so remote drags glide instead of
53
+ * stepping at the 120ms ops tick. Ephemeral — the durable version rides the ops tick.
54
+ * Ops: {op:"upsert",node} · {op:"del",id,v?} · {op:"name",name,v?}. Accepted ops are
55
+ * broadcast (with room-stamped versions where the sender sent none); rejected ops earn
56
+ * the SENDER a corrective ops message carrying the winning state. The doc's `view` is
57
+ * per-user viewport — never synced (a fossil in old mirrors; clients use localStorage).
58
+ */
59
+
60
+ // The document authority itself lives in the ENGINE, so the engine worker and this
61
+ // standalone worker deploy the same class rather than two copies that drift. See the
62
+ // header of src/board-room.mjs for why this worker still exists.
63
+ export { BoardRoom } from "../../src/board-room.mjs";
64
+
65
+ function json(data, status) {
66
+ return new Response(JSON.stringify(data), { status: status || 200, headers: { "content-type": "application/json" } });
67
+ }
68
+
69
+ // Shared secret with the Pages worker. The rooms below hold the authoritative board
70
+ // documents, and the admin-only-space seal is enforced in the PAGES worker, which checks
71
+ // the requested board path before proxying to /__rt. This worker is reachable on its own
72
+ // public URL, so without this guard anyone who learns that hostname joins any room
73
+ // directly and the seal means nothing. The secret is REQUIRED: unset means no room
74
+ // request is served at all (501), so an instance cannot quietly launch open. Provision
75
+ // it on both sides — the realtime worker and the site — before pointing traffic here.
76
+
77
+ // EXPORTED so nothing spells it twice. `scripts/board-snapshot.mjs` has to send this header
78
+ // when it reads a room through this worker's own URL instead of through a site's `/__rt`,
79
+ // and a second declaration of a header name is the shape every drift in this pair has taken.
80
+ export const RT_SECRET_HEADER = "x-augur-rt";
81
+ function rtSecretOk(given, want) {
82
+ given = String(given == null ? "" : given);
83
+ if (given.length !== want.length) return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < given.length; i++) diff |= given.charCodeAt(i) ^ want.charCodeAt(i);
86
+ return diff === 0;
87
+ }
88
+
89
+ export default {
90
+ async fetch(request, env) {
91
+ const url = new URL(request.url);
92
+ if (url.pathname === "/") return json({ ok: true, service: "augur-realtime" });
93
+ if (url.pathname !== "/room") return json({ error: "not-found" }, 404);
94
+ // Fail CLOSED. An unset secret used to skip the guard entirely, which meant every
95
+ // new instance launched wide open and showed no symptom for it — rooms simply
96
+ // worked, for anyone who learned the hostname. Refusing outright makes the
97
+ // unconfigured state loud instead of silent, and 501 is the same answer the site
98
+ // worker's rtProxy already gives when realtime is unconfigured on its side.
99
+ const want = env && env.RT_SHARED_SECRET;
100
+ if (!want) return json({ error: "realtime-not-configured" }, 501);
101
+ if (!rtSecretOk(request.headers.get(RT_SECRET_HEADER), want)) {
102
+ return json({ error: "forbidden" }, 403);
103
+ }
104
+ if (request.headers.get("Upgrade") !== "websocket") return json({ error: "expected-websocket" }, 426);
105
+ const path = (url.searchParams.get("path") || "").slice(0, 600);
106
+ if (!path) return json({ error: "bad-input" }, 400);
107
+ // ⚠️ THE ROOM NAME STAYS THE BARE PATH HERE, and that is not an oversight this file is
108
+ // waiting to have corrected. A different name is a different Durable Object, and a
109
+ // Durable Object's storage belongs to the script that created it — so renaming the
110
+ // rooms of a LIVE standalone worker orphans every board it holds, for no gain: this
111
+ // worker serves exactly one instance, so a workspace segment would distinguish nothing.
112
+ // The engine worker's /__rt names its rooms `<workspace>:<path>` because there the
113
+ // rooms are new and empty anyway, which is what makes the segment free exactly once.
114
+ // Nothing forwards a workspace to this worker either, so the mirror keys it writes stay
115
+ // unscoped to match — one cutover, both halves, and this worker is on neither side of it.
116
+ const id = env.ROOMS.idFromName(path);
117
+ return env.ROOMS.get(id).fetch(request);
118
+ },
119
+ };
@@ -0,0 +1,44 @@
1
+ # TEMPLATE — copy this into YOUR DEPLOY SHELL as realtime.wrangler.toml and fill it in.
2
+ # It is deliberately not a live wrangler.toml: this worker's identity (its name, and the
3
+ # KV namespace it persists boards into) belongs to one instance and one Cloudflare
4
+ # account, so the shared engine must not carry it. `wrangler` ignores this filename.
5
+ #
6
+ # What the worker is: one BoardRoom Durable Object per board path (same key as the KV
7
+ # doc). Every visitor's WebSocket meets there, and the room relays cursors, presence,
8
+ # node ops, selections and editing focus between them. The room OWNS the document: the
9
+ # source of truth is the DO's SQLite storage (per-node rows, version-checked LWW,
10
+ # tombstoned deletes), migrated lazily from KV on first touch; BOARD_KV holds a
11
+ # write-through mirror (dirty-flag alarm + flush-on-empty) serving the public GET and
12
+ # the clients' solo fallback, folded back in on cold loads. Rooms under /__test/ never
13
+ # persist. NOTE: sqlite-backed DO storage (new_sqlite_classes below) is REQUIRED.
14
+ #
15
+ # ONE WORKER PER INSTANCE. Rooms are keyed by board path, so two instances pointed at the
16
+ # same worker would share rooms *and* board storage.
17
+ #
18
+ # Deploy it standalone — never via Pages CI, which cannot define Durable Object classes.
19
+ # From your shell repo's root, with the engine submodule checked out and CLOUDFLARE_*
20
+ # creds in the environment:
21
+ #
22
+ # npx wrangler deploy -c realtime.wrangler.toml
23
+ #
24
+ # Then set "realtimeOrigin": "https://<name>.<your-subdomain>.workers.dev" in the
25
+ # shell's deploy.config.json and redeploy the site. Without it the engine refuses to
26
+ # guess: /__rt answers 501 and boards run solo, persisting through the Pages worker.
27
+ # A brand-new worker can 500 (error code 1104) for its first minute of propagation.
28
+
29
+ name = "<your-instance>-realtime"
30
+ main = "engine/realtime/src/index.js" # relative to THIS file, i.e. your shell's root
31
+ compatibility_date = "2026-07-01"
32
+
33
+ # The board-doc KV namespace — the same namespace the Pages project binds as COMMENTS.
34
+ [[kv_namespaces]]
35
+ binding = "BOARD_KV"
36
+ id = "<your-kv-namespace-id>"
37
+
38
+ [[durable_objects.bindings]]
39
+ name = "ROOMS"
40
+ class_name = "BoardRoom"
41
+
42
+ [[migrations]]
43
+ tag = "v1"
44
+ new_sqlite_classes = ["BoardRoom"]
@@ -0,0 +1,106 @@
1
+ // adopt.mjs — copy an instance's KV state into its workspace Durable Object.
2
+ //
3
+ // augur adopt against the origin in this folder's config
4
+ // AUGUR_ORIGIN=<url> augur adopt against a named instance
5
+ // … --dry-run read the export, report what would be copied, write nothing
6
+ //
7
+ // `B-kv-to-do-migration-tool`. Phase one of moving off shared KV, and the whole of it is a
8
+ // COPY: the object ends up holding a second, faithful copy of every family the inventory
9
+ // sends it, and NOTHING READS IT. Cutting the reads over is `B-kv-read-cutover`, separately,
10
+ // one family at a time.
11
+ //
12
+ // ⚠️ IT IS NOT CALLED `migrate`, AND THAT IS NOT A NAMING QUIBBLE. `augur migrate` already
13
+ // means something else — move a whole workspace from one instance to another — and a second
14
+ // verb one keystroke away from it, run against a live instance, is a trap. This one adopts
15
+ // what is already here; it moves nothing and it goes nowhere.
16
+ //
17
+ // HOW IT WORKS, AND WHY THAT IS SAFE. It asks the instance for its own state
18
+ // (`_state/export`) and hands it straight back (`_state/import`). The import writes the
19
+ // identity and content families into the workspace object AND writes them to KV exactly as
20
+ // it found them — so the KV half is a byte-for-byte rewrite of what was already there, a
21
+ // no-op that cannot change what the login gate reads, while the object gains the copy. That
22
+ // is what makes this re-runnable: run it twice and the second run leaves what the first did.
23
+ //
24
+ // It never sends `prune` and never sends `clear`. Both are verbs a RESET needs; a copy that
25
+ // could empty a family is not a copy.
26
+ import { target, apiClient } from "./lib/store.mjs";
27
+
28
+ const argv = process.argv.slice(2);
29
+ const flag = (n) => argv.includes(n);
30
+ const DRY = flag("--dry-run");
31
+
32
+ const log = (m) => console.log(`\x1b[35m[adopt]\x1b[0m ${m}`);
33
+ const die = (m) => { console.error(`\x1b[31m[adopt]\x1b[0m ${m}`); process.exit(1); };
34
+
35
+ // `target()` resolves the origin and the token FOR THAT ORIGIN together, which is what
36
+ // stops a run against one instance reaching for another's credential. Point it elsewhere
37
+ // with AUGUR_ORIGIN, the same way every other command here does.
38
+ let origin, token;
39
+ try { ({ origin, token } = target()); } catch (e) { die(e.message); }
40
+
41
+ const req = apiClient(origin, token);
42
+
43
+ log(`${origin}${DRY ? " (dry run)" : ""}`);
44
+
45
+ let doc;
46
+ try {
47
+ doc = await (await req("_state/export")).json();
48
+ } catch (e) {
49
+ die(`could not read this instance's state: ${e.message}\n A star-scope token is required — a space-scoped one is refused here on purpose.`);
50
+ }
51
+
52
+ // An export that could not read a family is not a copy to hand back. `importState` refuses
53
+ // it too; failing here means the operator hears WHY rather than reading a rejection.
54
+ if (Array.isArray(doc.failed) && doc.failed.length) {
55
+ die(`this instance could not export ${doc.failed.length} famil(y/ies): ${doc.failed.map((f) => f && f.id).join(", ")}\n Nothing was written. Fix the read before copying, or the copy is short and nothing says so.`);
56
+ }
57
+
58
+ const families = Object.keys(doc.families || {});
59
+ log(`${families.length} famil(y/ies), ${(doc.assets || []).length} canvas image(s)`);
60
+
61
+ if (DRY) {
62
+ for (const id of families.sort()) {
63
+ const v = doc.families[id];
64
+ const n = v && typeof v === "object" ? Object.keys(v).length : 1;
65
+ console.log(` ${id.padEnd(20)} ${n} entr${n === 1 ? "y" : "ies"}`);
66
+ }
67
+ log("dry run — nothing written. Re-run without --dry-run to copy.");
68
+ process.exit(0);
69
+ }
70
+
71
+ // Deliberately NOT spreading `doc`: an export document carries `clear` and `prune` only if
72
+ // something put them there, and a copy must not be able to pick them up by accident.
73
+ const body = { format: doc.format, families: doc.families };
74
+
75
+ const res = await (await req("_state/import", {
76
+ method: "POST",
77
+ headers: { "content-type": "application/json" },
78
+ body: JSON.stringify(body),
79
+ })).json();
80
+
81
+ if (!res.ok) die(`the instance refused the copy: ${res.reason || JSON.stringify(res)}`);
82
+
83
+ if (!res.workspaceObject) {
84
+ die(
85
+ "this instance has NO workspace object bound, so nothing was copied into one.\n" +
86
+ " KV was rewritten with what it already held, which changes nothing and is not the point.\n" +
87
+ " Bind TENANTS in the shell's wrangler.toml and run this again.",
88
+ );
89
+ }
90
+
91
+ log(`\x1b[32mcopied\x1b[0m — ${(res.written || []).length} famil(y/ies) into the workspace object${res.atomic ? ", in one transaction" : ""}`);
92
+
93
+ // Everything below is what the copy could NOT carry. A copy that reports success while
94
+ // quietly omitting something is the failure this whole item exists to avoid, so these are
95
+ // printed every run rather than only when somebody passes a flag.
96
+ for (const s of res.unmapped || []) {
97
+ console.log(` \x1b[33mnot copied\x1b[0m ${s.id}\n ${s.why}`);
98
+ }
99
+ for (const r of res.refusedRows || []) {
100
+ console.log(` \x1b[33mrefused\x1b[0m ${r.family} ${r.key}\n ${r.why}`);
101
+ }
102
+ if (!res.atomic) {
103
+ console.log(" \x1b[33mnote\x1b[0m this runtime has no transactionSync, so the write was ordered rather than atomic");
104
+ }
105
+
106
+ log("nothing reads this copy yet — that is B-kv-read-cutover. Run this again any time; it is a no-op the second time.");
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * board-snapshot — move a board by its AUTHORITATIVE document, not by its KV mirror.
4
+ *
5
+ * node scripts/board-snapshot.mjs lag --origin <site> --path </board/path/>
6
+ * node scripts/board-snapshot.mjs read --origin <site> --path </board/path/> --out board.json
7
+ * node scripts/board-snapshot.mjs seed --origin <site> --path </board/path/> --from board.json
8
+ * node scripts/board-snapshot.mjs move --from <old site> --to <new site> --path </board/path/>
9
+ *
10
+ * `MIG-board-snapshot-via-ws`. The whole of the reasoning is in
11
+ * `scripts/lib/board-snapshot.mjs`; the short version is that KV holds a mirror written on a
12
+ * 45-second dirty alarm, `GET /__board` and every state export serve that mirror, and the
13
+ * only read of the truth is the `welcome` frame a room sends when a client joins. So this
14
+ * joins as a client.
15
+ *
16
+ * ⚠️ `lag` FIRST, ALWAYS. It is the one verb that changes nothing anywhere and it answers the
17
+ * question a cutover actually turns on: how much is in the room that is not in the mirror. A
18
+ * run that reports zero says a KV-sourced copy of this board would have been correct; any
19
+ * other number is the work that copy would have thrown away.
20
+ *
21
+ * ⚠️ `move` DOES NOT DELETE THE SOURCE and never will. A migration that has removed the thing
22
+ * it migrated is not reversible, and rolling a cutover back has to leave the old boards where
23
+ * they were — the same rule `scripts/migrate-board-keys.mjs` follows for the mirror keys.
24
+ *
25
+ * ⚠️ IT MOVES ONE BOARD. There is no `--all`, on purpose: enumerating boards means listing KV
26
+ * keys, which needs an account credential this script deliberately does not hold and would be
27
+ * the wrong list anyway (a room can hold a board whose mirror key has never been written). The
28
+ * list of paths comes from the mirror — `scripts/migrate-board-keys.mjs` scans exactly that —
29
+ * and each one is then read from where the truth is. Loop in the shell, so a failure stops on
30
+ * the board it failed on and says which.
31
+ *
32
+ * WHAT IT NEEDS: nothing but the two origins. No account token, no KV namespace id, no
33
+ * realtime secret — `/__rt` and `/__board` are both public, because a board's URL is its
34
+ * credential. `--direct` is the exception and reads `AUGUR_RT_SECRET` from the environment,
35
+ * never from an argument, so it cannot land in a shell history or a CI log.
36
+ */
37
+ import fs from "node:fs";
38
+ import { RT_SECRET_HEADER } from "../realtime/src/index.js";
39
+ import {
40
+ DEFAULT_SETTLE_MS, DEFAULT_TIMEOUT_MS, MIRROR_CADENCE_MS, SnapshotError,
41
+ openWebSocket, snapshotRoom, seedRoom, measureLag, readMirror,
42
+ } from "./lib/board-snapshot.mjs";
43
+
44
+ const args = process.argv.slice(2);
45
+ const verb = args[0] && !args[0].startsWith("--") ? args[0] : "";
46
+ const flag = (f) => args.includes(f);
47
+ const opt = (f, d = null) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
48
+ const num = (f, d) => { const v = opt(f); return v === null ? d : Number(v); };
49
+
50
+ const JSON_OUT = flag("--json");
51
+ const log = (m) => console.error(`\x1b[36m[board-snapshot]\x1b[0m ${m}`);
52
+ const die = (m, code = 1) => { console.error(`\x1b[36m[board-snapshot]\x1b[0m \x1b[31m${m}\x1b[0m`); process.exit(code); };
53
+
54
+ const USAGE = `usage:
55
+ board-snapshot lag --origin <site> --path </board/path/>
56
+ board-snapshot read --origin <site> --path </board/path/> [--out <file>]
57
+ board-snapshot seed --origin <site> --path </board/path/> --from <file> [--merge]
58
+ board-snapshot move --from <site> --to <site> --path </board/path/> [--to-path </new/path/>] [--merge]
59
+
60
+ options:
61
+ --settle <ms> gap between the two observer reads (default ${DEFAULT_SETTLE_MS})
62
+ --attempts <n> retries when the board changes mid-read (default 3)
63
+ --timeout <ms> per-frame wait (default ${DEFAULT_TIMEOUT_MS})
64
+ --allow-unstable report a read taken while the board was being edited, instead of failing
65
+ --direct speak /room to a standalone realtime worker, with AUGUR_RT_SECRET
66
+ --json machine-readable report on stdout`;
67
+
68
+ if (!verb || flag("--help") || flag("-h")) { console.error(USAGE); process.exit(verb ? 0 : 1); }
69
+
70
+ const PATH = opt("--path");
71
+ const DIRECT = flag("--direct");
72
+ const headers = {};
73
+ if (DIRECT) {
74
+ const secret = process.env.AUGUR_RT_SECRET;
75
+ if (!secret) die("--direct speaks to a standalone realtime worker, which refuses without its shared secret. Put it in AUGUR_RT_SECRET.");
76
+ headers[RT_SECRET_HEADER] = secret;
77
+ }
78
+ const common = {
79
+ direct: DIRECT,
80
+ headers,
81
+ settleMs: num("--settle", DEFAULT_SETTLE_MS),
82
+ attempts: num("--attempts", 3),
83
+ timeoutMs: num("--timeout", DEFAULT_TIMEOUT_MS),
84
+ allowUnstable: flag("--allow-unstable"),
85
+ };
86
+
87
+ const emit = (obj) => { if (JSON_OUT) console.log(JSON.stringify(obj, null, 2)); };
88
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
89
+
90
+ function reportSnapshot(label, snap) {
91
+ const s = snap.summary;
92
+ if (snap.empty) { log(`${label}: the room holds NO document — this board has never been drawn on`); return; }
93
+ log(`${label}: ${plural(s.nodes, "node")}, ${plural(s.tombs, "tombstone")}, name ${JSON.stringify(s.name)} @v${s.nameV}, ${s.bytes} bytes`);
94
+ log(`${label}: digest ${s.digest.slice(0, 16)} · agreed across ${snap.observers.length} independent joins ${snap.settleMs}ms apart (attempt ${snap.attempt})`);
95
+ }
96
+
97
+ async function main() {
98
+ if (verb === "lag") {
99
+ if (!PATH) die("--path is required");
100
+ const origin = opt("--origin") || die("--origin is required");
101
+ const r = await measureLag(openWebSocket, { origin, path: PATH, ...common });
102
+ reportSnapshot("room", r.snapshot);
103
+ log(`mirror (read BEFORE the room, so no flush of ours is in it): ${r.mirrorBefore.empty ? "nothing" : plural(r.mirrorBefore.nodes, "node")}`);
104
+ if (r.wouldHaveLost.nodes === 0) {
105
+ log(`\x1b[32mthe mirror was level with the room — a KV-sourced copy of this board would have been correct at this instant\x1b[0m`);
106
+ } else {
107
+ log(`\x1b[33mthe mirror was BEHIND by ${plural(r.wouldHaveLost.nodes, "node")}\x1b[0m — ${r.wouldHaveLost.missing.length} missing outright, ${r.wouldHaveLost.stale.length} at an older version`);
108
+ log(`that is what a KV-sourced migration of this board would have dropped, silently. The room writes the mirror every ${MIRROR_CADENCE_MS / 1000}s at most — but a reader sees that write later still (measured: 87s), so wait on this number reaching zero, never on a clock.`);
109
+ }
110
+ log(`mirror re-read after: ${r.mirrorAfter.empty ? "nothing" : plural(r.mirrorAfter.nodes, "node")}${r.readFlushedTheMirror ? " — the read's own disconnect flushed it (a snapshot leaves a quiet board's mirror fresher than it found it)" : " — unchanged"}`);
111
+ emit(r);
112
+ return 0;
113
+ }
114
+
115
+ if (verb === "read") {
116
+ if (!PATH) die("--path is required");
117
+ const origin = opt("--origin") || die("--origin is required");
118
+ const snap = await snapshotRoom(openWebSocket, { origin, path: PATH, ...common });
119
+ reportSnapshot("room", snap);
120
+ const out = opt("--out");
121
+ const payload = { source: { origin, path: PATH, direct: DIRECT }, readAt: new Date().toISOString(), empty: snap.empty, summary: snap.summary, stable: snap.stable, doc: snap.doc };
122
+ if (out) { fs.writeFileSync(out, JSON.stringify(payload, null, 2)); log(`wrote ${out}`); }
123
+ if (JSON_OUT) console.log(JSON.stringify(payload, null, 2));
124
+ else if (!out) console.log(JSON.stringify(payload.doc));
125
+ return snap.stable ? 0 : 3;
126
+ }
127
+
128
+ if (verb === "seed") {
129
+ if (!PATH) die("--path is required");
130
+ const origin = opt("--origin") || die("--origin is required");
131
+ const from = opt("--from") || die("--from <file> is required — a snapshot written by `read`");
132
+ let file;
133
+ try { file = JSON.parse(fs.readFileSync(from, "utf8")); } catch (e) { return die(`cannot read ${from}: ${e.message}`); }
134
+ const doc = file && file.doc ? file.doc : file;
135
+ if (file && file.empty) return die("that snapshot is of a board that has never been drawn on — there is nothing to seed, and seeding an empty document would write a board where there was none");
136
+ const r = await seedRoom(openWebSocket, { origin, path: PATH, doc, merge: flag("--merge"), settleMs: common.settleMs, timeoutMs: common.timeoutMs, direct: DIRECT, headers });
137
+ log(`offered ${plural(r.offered.nodes, "node")} to ${origin}${PATH}${r.destinationWasEmpty ? " (destination was empty — adopted wholesale)" : " (destination held a board — reconciled per node)"}`);
138
+ log(`read back over a fresh socket after the seeder had closed: ${plural(r.landed.nodes, "node")}`);
139
+ if (!r.nodesLanded) {
140
+ log(`\x1b[31mthe destination does not hold what was offered\x1b[0m — ${r.comparison.onlyInA.length} never arrived, ${r.comparison.differing.length} at another version`);
141
+ } else if (r.identical) {
142
+ log(`\x1b[32mevery node landed at the version it left with, and the destination is the source document whole\x1b[0m`);
143
+ } else {
144
+ // A merge, resolved by the version rules. Named rather than scored as a failure.
145
+ log(`\x1b[32mevery node landed at the version it left with\x1b[0m`);
146
+ if (r.kept.nodes.length) log(`the destination also kept ${plural(r.kept.nodes.length, "node")} the offer did not carry — an absence in an offer is not a deletion`);
147
+ if (r.kept.name) log(`the destination kept its own board name ${JSON.stringify(r.kept.name)}: the offer's nameV did not beat it`);
148
+ }
149
+ emit(r);
150
+ return r.ok ? 0 : 4;
151
+ }
152
+
153
+ if (verb === "move") {
154
+ if (!PATH) die("--path is required");
155
+ const from = opt("--from") || die("--from <site> is required");
156
+ const to = opt("--to") || die("--to <site> is required");
157
+ const toPath = opt("--to-path", PATH);
158
+ if (from === to && PATH === toPath) die("--from and --to name the same room");
159
+ const before = await measureLag(openWebSocket, { origin: from, path: PATH, ...common });
160
+ reportSnapshot("source room", before.snapshot);
161
+ if (before.snapshot.empty) return die("the source room holds no document — nothing to move", 5);
162
+ log(`the source mirror was behind by ${plural(before.wouldHaveLost.nodes, "node")}; this move carries the room's copy, not the mirror's`);
163
+ const seeded = await seedRoom(openWebSocket, {
164
+ origin: to, path: toPath, doc: before.snapshot.doc, merge: flag("--merge"),
165
+ settleMs: common.settleMs, timeoutMs: common.timeoutMs, direct: DIRECT, headers,
166
+ });
167
+ log(`destination now holds ${plural(seeded.landed.nodes, "node")}`);
168
+ if (seeded.ok) log(`\x1b[32m${from}${PATH} → ${to}${toPath}: every node arrived. The source is untouched and still serving.\x1b[0m`);
169
+ else log(`\x1b[31mthe destination does not hold what the source room had\x1b[0m`);
170
+ emit({ from: { origin: from, path: PATH }, to: { origin: to, path: toPath }, sourceLag: before.wouldHaveLost, seeded });
171
+ return seeded.ok ? 0 : 4;
172
+ }
173
+
174
+ if (verb === "mirror") {
175
+ if (!PATH) die("--path is required");
176
+ const origin = opt("--origin") || die("--origin is required");
177
+ const m = await readMirror({ origin, path: PATH });
178
+ log(`mirror: ${m.summary.empty ? "nothing" : plural(m.summary.nodes, "node")}`);
179
+ emit(m);
180
+ return 0;
181
+ }
182
+
183
+ console.error(USAGE);
184
+ return 1;
185
+ }
186
+
187
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith("board-snapshot.mjs");
188
+ if (invokedDirectly) {
189
+ main()
190
+ .then((code) => process.exit(code))
191
+ .catch((e) => {
192
+ if (e instanceof SnapshotError) {
193
+ die(`${e.code}: ${e.message}${e.detail ? `\n ${JSON.stringify(e.detail)}` : ""}`, e.code === "unstable" ? 3 : 2);
194
+ }
195
+ die(e && e.stack ? e.stack : String(e), 2);
196
+ });
197
+ }