@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.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- 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
|
+
}
|