oxedyne/daimond/dev/verify_sync.mjs
142 KiB, 10 runs
created by r2519314175:715, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_sync.mjs — a user's work travels between devices through the gateway's |
| 2 | // encrypted mailbox, and two devices editing at once converge rather than clobber. |
| 3 | // |
| 4 | // This drives the real client engine (sync.js) against the REAL gateway (/api/sync), so |
| 5 | // it needs the dev stack up: the app (DAIMOND_PORT, default 8777) and the gateway on |
| 6 | // :9002. |
| 7 | // |
| 8 | // 1. Sign in, make a chat, push. The mailbox holds a version >= 1, and its blob |
| 9 | // is ciphertext — the plaintext codeword must NOT appear in it. |
| 10 | // 2. Simulate a second device: wipe local chats and the version cursor, pull, and |
| 11 | // confirm the chat's transcript comes back decrypted and merged. |
| 12 | // 3. Conflict: another device bumps the mailbox out of band, then this device |
| 13 | // pushes from its now-stale version; the engine must 409, pull, merge and retry |
| 14 | // to success — the version advances, no work lost. |
| 15 | // 4. Diamonds travel too. A Diamond is a DIRECTORY, not a record, so the whole |
| 16 | // of it must arrive — name, tags, crystal, log, links, and which model it |
| 17 | // thinks with. A deletion must travel and STAY travelled, the freshest copy |
| 18 | // must win, and a parcel from a device that predates any of this must still |
| 19 | // apply. |
| 20 | // 5. The identity export bundle carries the salt, without which a second device |
| 21 | // could never derive the key to open any of this. |
| 22 | // 6. A parcel the gateway refuses as too large (413) is SAID so — on the sync |
| 23 | // chip, held, with the reason on hover — and the stall clears on the next |
| 24 | // push that works. |
| 25 | // 7. A parked tab converges on window focus, without a reload, and a focus |
| 26 | // storm coalesces into one pull. |
| 27 | // 8. Provider keys and model lists travel too: sealed keys only, deterministic |
| 28 | // to the byte, freshest-wins per provider, and a union so neither device |
| 29 | // loses a provider the other has never seen. |
| 30 | // 10. One section of a parcel that cannot be merged costs only itself: the |
| 31 | // Diamonds beside it still arrive, and the merge says what it could not do. |
| 32 | // 11. A reconcile that gives up says so on the chip, rather than leaving the |
| 33 | // "Synced" its own pull put there. |
| 34 | // 12. Two REAL devices, on two browser profiles, converge in BOTH directions -- |
| 35 | // including the one that is not being typed at, which raises no focus event |
| 36 | // and ends no turn and therefore never asked the gateway anything at all. |
| 37 | // 13. And it converges with NOTHING happening on it at all: the gateway taps |
| 38 | // every other device of the account when the mailbox moves, so a window |
| 39 | // left open on a second desk applies the other one's work within seconds -- |
| 40 | // no focus, no visibility change, no settling event, no reload. Over a |
| 41 | // socket where the front door carries one and over a parked request where |
| 42 | // it does not, across a gateway restart, carrying version integers only. |
| 43 | import { open, chat, signInAs } from './harness.mjs'; |
| 44 | import { makePagePro } from './pro.mjs'; |
| 45 | import { GW_PORT, GW_URL } from './ports.mjs'; |
| 46 | import { spawn, execFileSync } from 'node:child_process'; |
| 47 | import fs from 'node:fs'; |
| 48 | import path from 'node:path'; |
| 49 | import { fileURLToPath } from 'node:url'; |
| 50 | |
| 51 | const ok = [], bad = []; |
| 52 | const check = (name, pass, detail) => { |
| 53 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 54 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 55 | }; |
| 56 | |
| 57 | /// Push, and wait until THIS DEVICE'S OWN PARCEL is what the mailbox holds. |
| 58 | /// |
| 59 | /// A single push() call is not enough: one that finds another in flight (or the |
| 60 | /// app busy) only reschedules and returns, so awaiting it proves nothing. |
| 61 | /// |
| 62 | /// THE VERSION ADVANCING IS NOT ENOUGH EITHER, which is what this helper used |
| 63 | /// to wait for, and it is why one check per run went red and never the same |
| 64 | /// one. `serverVersion` moves on any completed round, and three of them are not |
| 65 | /// this push: |
| 66 | /// |
| 67 | /// * a round that was ALREADY IN FLIGHT when the caller made its change. It |
| 68 | /// collected the parcel before the change existed, and it lands carrying |
| 69 | /// the state as it was. The version advances, the wait is satisfied, and |
| 70 | /// the pull that follows reads a mailbox that never saw the change -- |
| 71 | /// "the second device pulls the shared Diamond down" and "a deleted |
| 72 | /// workspace file is restored by pull", both of which failed exactly here. |
| 73 | /// * `pull()` adopting a higher version from another device. |
| 74 | /// * push()'s own idle branch: with nothing new to send it pulls instead, |
| 75 | /// which can advance the version having sent nothing at all. |
| 76 | /// |
| 77 | /// So the wait is on the only fact the callers actually depend on: the mailbox |
| 78 | /// decrypts to a parcel THIS device produced after the change. The parcel is |
| 79 | /// resampled each round and every sample kept, because a stray landing round |
| 80 | /// may carry a perfectly good newer parcel; what can never be accepted is the |
| 81 | /// one collected before the caller's change, which is never sampled at all. |
| 82 | /// |
| 83 | /// A push that does not land is a FAILURE, not a note. It used to print a line |
| 84 | /// and carry on, so the red surfaced two hundred lines later as an unrelated |
| 85 | /// merge fault. |
| 86 | async function pushLanded(pg, where) { |
| 87 | const r = await pg.evaluate(async (ms) => { |
| 88 | const mailbox = async () => { |
| 89 | const res = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 90 | const j = await res.json(); |
| 91 | if (!j.present) return null; |
| 92 | try { return await window.DaimondIdentity.unwrap(j.blob); } |
| 93 | catch (e) { return null; } |
| 94 | }; |
| 95 | const mine = new Set(); |
| 96 | const t0 = Date.now(); |
| 97 | let rounds = 0, held = null, sample = ''; |
| 98 | while (Date.now() - t0 < ms) { |
| 99 | rounds++; |
| 100 | await window.DaimondSync.push(); |
| 101 | sample = JSON.stringify(await window.DaimondSync.parcel()); |
| 102 | mine.add(sample); |
| 103 | held = await mailbox(); |
| 104 | if (held !== null && mine.has(held)) return { landed: true, rounds, took: Date.now() - t0 }; |
| 105 | await new Promise(r => setTimeout(r, 200)); |
| 106 | } |
| 107 | return { |
| 108 | landed: false, rounds, took: Date.now() - t0, |
| 109 | // Which it is: a mailbox holding somebody else's parcel and a mailbox |
| 110 | // holding nothing readable are different faults. |
| 111 | mailbox: held === null ? '(absent or undecryptable)' : String(held.length) + ' bytes', |
| 112 | parcel: String(sample.length) + ' bytes', |
| 113 | state: JSON.stringify(window.DaimondSync.state()), |
| 114 | }; |
| 115 | }, 25000); |
| 116 | if (!r.landed) { |
| 117 | check('a push reached the mailbox' + (where ? ' (' + where + ')' : ''), false, |
| 118 | r.rounds + ' rounds in ' + r.took + 'ms, mailbox ' + r.mailbox |
| 119 | + ' vs parcel ' + r.parcel + ' — ' + r.state); |
| 120 | } |
| 121 | return r.landed; |
| 122 | } |
| 123 | |
| 124 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 125 | |
| 126 | /// Put `window.__bump(tag)` on the page: one genuine local change, so the push |
| 127 | /// that follows is not skipped as a no-op. |
| 128 | /// |
| 129 | /// THE BLINDING THIS EXISTS FOR. Every stubbed refusal below — 413, 402, 409 — |
| 130 | /// is only reached if a parcel is actually SENT, and the engine sends one only |
| 131 | /// when the parcel differs from the last one it sent. The helper this replaces |
| 132 | /// wrote `daimond-chats` in localStorage. Transcripts moved into IndexedDB, so |
| 133 | /// those writes changed nothing `collectSync()` reads: every push was skipped |
| 134 | /// before any request was made, no stub was ever reached, and eighteen checks |
| 135 | /// reported on something other than the behaviour they name. |
| 136 | /// |
| 137 | /// Two things are done about that. The change goes through the app's own chat |
| 138 | /// store — `DaimondCore.chatStore()`, the very object `collectSync()` packs, so |
| 139 | /// a store that moves again takes this with it LOUDLY (the call throws) rather |
| 140 | /// than silently. And the parcel is COMPARED across the change, so a bump that |
| 141 | /// stops moving it says so at the call site instead of surfacing as an |
| 142 | /// unrelated red four sections later. Each caller asserts on what it returns. |
| 143 | /// |
| 144 | /// The tombstone map `daimond-msgs-deleted` would also have worked (see |
| 145 | /// verify_sessionrenew, which uses it) and is inert by construction. This is |
| 146 | /// preferred here because these sections say "a genuine local change" and mean |
| 147 | /// a transcript: what travels is the same section of the parcel the 413 and 409 |
| 148 | /// arms exist to protect. |
| 149 | const installBump = (pg) => pg.evaluate(() => { |
| 150 | window.__bump = async function (tag) { |
| 151 | const before = JSON.stringify(await window.DaimondCore.collectSync()); |
| 152 | const store = window.DaimondCore.chatStore(); |
| 153 | const list = store.stored(); |
| 154 | if (!list.length) return { moved: false, why: 'no chat in the store to change' }; |
| 155 | list[0].messages = (list[0].messages || []).concat([ |
| 156 | { role: 'user', content: tag, mid: tag, ts: Date.now() }, |
| 157 | ]); |
| 158 | list[0].updatedAt = Date.now(); |
| 159 | store.save(list); |
| 160 | const after = JSON.stringify(await window.DaimondCore.collectSync()); |
| 161 | return { moved: after !== before, why: after === before ? 'the parcel did not move' : '' }; |
| 162 | }; |
| 163 | |
| 164 | /// Push until a parcel really LEAVES this device, and say whether one did. |
| 165 | /// |
| 166 | /// THE SECOND BLINDING. `__bump` above makes sure there is something to send; |
| 167 | /// this makes sure it is sent. `push()` answers a caller no differently |
| 168 | /// whether it sent or stood aside -- over a live turn, and over a round |
| 169 | /// already in flight, it only reschedules and returns -- so every stubbed |
| 170 | /// refusal below (413, 402, 409) could be asserted against a gateway nobody |
| 171 | /// had spoken to. Observed: "a permanent conflict is retried, and bounded" |
| 172 | /// red at posts=0, with the chip still reading "Syncing…" from the round |
| 173 | /// that had swallowed the call. |
| 174 | /// |
| 175 | /// `count` is the section's own POST counter, so what is waited for is the |
| 176 | /// thing the section measures. Re-bumped each round because a round that was |
| 177 | /// in flight may have sent the change for real before the stub went on, |
| 178 | /// leaving the engine with nothing to send and no reason to speak again. |
| 179 | window.__pushSent = async function (count, tag, ms) { |
| 180 | const t0 = Date.now(); |
| 181 | let rounds = 0; |
| 182 | while (count() === 0 && Date.now() - t0 < (ms || 15000)) { |
| 183 | rounds++; |
| 184 | if (rounds > 1) await window.__bump(tag + '-' + rounds); |
| 185 | await window.DaimondSync.push(); |
| 186 | if (count() === 0) await new Promise(r => setTimeout(r, 250)); |
| 187 | } |
| 188 | return { sent: count() > 0, rounds: rounds, took: Date.now() - t0 }; |
| 189 | }; |
| 190 | }); |
| 191 | |
| 192 | /// One local change, from Node. Returns `{ moved, why }`. |
| 193 | const bumped = (pg, tag) => pg.evaluate(t => window.__bump(t), tag); |
| 194 | |
| 195 | /// Did every change a section made really move the parcel? The sections that |
| 196 | /// bump from inside their own `evaluate` collect the answers and hand them back |
| 197 | /// as `tag: moved` / `tag: <why not>` lines. |
| 198 | const allMoved = (lines) => Array.isArray(lines) && lines.length > 0 |
| 199 | && lines.every(l => / moved$/.test(l)); |
| 200 | |
| 201 | /// Is the gateway answering? |
| 202 | async function gatewayUp() { |
| 203 | try { |
| 204 | const r = await fetch(`${GW_URL}/api/health`, { signal: AbortSignal.timeout(2000) }); |
| 205 | return r.ok; |
| 206 | } catch (e) { return false; } |
| 207 | } |
| 208 | |
| 209 | /// Stop the running gateway and start it again exactly as it was. |
| 210 | /// |
| 211 | /// Exactly as it was matters: the suite may be running it from `gateway/` or |
| 212 | /// from the generated `dev/devgw/`, and which one decides what config it reads. |
| 213 | /// Both are taken from the live process rather than guessed, so this restarts |
| 214 | /// whatever is actually there and hands it back in the state it found it. |
| 215 | /// |
| 216 | /// Returns `true`, or a string saying what went wrong -- this test is the one |
| 217 | /// place in the suite that takes the gateway down, and it must not leave the |
| 218 | /// verifiers that run after it wondering why. |
| 219 | /// |
| 220 | /// WHICH PROCESS. Not `pgrep -x daimond_gateway`. A libtest harness built from |
| 221 | /// `src/app_main.rs` is called `daimond_gateway-<hash>`, and Linux truncates a |
| 222 | /// process name to fifteen characters -- which is exactly `daimond_gateway`. So on |
| 223 | /// a machine where any lane is running `cargo test` in gateway/, that pgrep matches |
| 224 | /// the test harness, `[0]` picks it, and this function reads /proc/<it>/exe and |
| 225 | /// spawns A TEST BINARY as the gateway. The port is the identity: the gateway is |
| 226 | /// whatever answers on :9002, because that is the only thing the rest of the suite |
| 227 | /// means by "the gateway". |
| 228 | async function restartGateway() { |
| 229 | let pid; |
| 230 | try { |
| 231 | const ss = execFileSync('ss', ['-ltnp', `sport = :${GW_PORT}`], { encoding: 'utf8' }); |
| 232 | pid = (/pid=(\d+)/.exec(ss) || [])[1]; |
| 233 | } catch (e) { return `could not ask which process holds :${GW_PORT}: ` + e.message; } |
| 234 | if (!pid) return 'no daimond_gateway process to restart'; |
| 235 | |
| 236 | let cwd, exe; |
| 237 | try { |
| 238 | cwd = fs.readlinkSync(`/proc/${pid}/cwd`); |
| 239 | // Linux appends " (deleted)" to this link once the binary has been |
| 240 | // REPLACED under the running process -- which is what a rebuild does, and |
| 241 | // a rebuild during a test run is an ordinary Tuesday. Spawning the link |
| 242 | // verbatim then failed ENOENT on a path ending in that suffix, and the |
| 243 | // failure arrived as an ChildProcess 'error' EVENT rather than a throw, |
| 244 | // so it went round the whole file's try/catch and killed the run outright |
| 245 | // with everything before it green and nothing said about why. |
| 246 | exe = fs.readlinkSync(`/proc/${pid}/exe`).replace(/ \(deleted\)$/, ''); |
| 247 | } catch (e) { return 'could not read the gateway process: ' + e.message; } |
| 248 | if (!fs.existsSync(exe)) return 'the gateway binary is no longer at ' + exe; |
| 249 | |
| 250 | // THE ONE PROCESS, never `pkill -x daimond_gateway`. That form signals every |
| 251 | // process on the machine whose (truncated) name is `daimond_gateway`, which |
| 252 | // includes every other lane's libtest harness and every other worktree's |
| 253 | // gateway. The pid is already known; use it. |
| 254 | try { process.kill(Number(pid), 'SIGTERM'); } catch (e) { /* already gone */ } |
| 255 | for (let i = 0; i < 20 && await gatewayUp(); i++) await sleep(500); |
| 256 | if (await gatewayUp()) return 'the gateway would not stop'; |
| 257 | |
| 258 | let spawnErr = ''; |
| 259 | const child = spawn(exe, [], { |
| 260 | cwd, detached: true, stdio: 'ignore', |
| 261 | env: { ...process.env, APP_MODE: process.env.APP_MODE || 'sandbox' }, |
| 262 | }); |
| 263 | // A spawn failure is an EVENT, not a throw. Unhandled it is fatal to the |
| 264 | // whole run — see the note on the " (deleted)" suffix above. |
| 265 | child.on('error', (e) => { spawnErr = String(e && e.message || e); }); |
| 266 | child.unref(); |
| 267 | await sleep(200); |
| 268 | if (spawnErr) return 'the gateway would not start: ' + spawnErr; |
| 269 | for (let i = 0; i < 60; i++) { |
| 270 | if (await gatewayUp()) return true; |
| 271 | await sleep(500); |
| 272 | } |
| 273 | return `the gateway did not come back on :${GW_PORT}`; |
| 274 | } |
| 275 | |
| 276 | const s = await open({ name: 'sync', signIn: true, connect: true, defaults: false }); |
| 277 | // NOTHING LEAVES THIS MACHINE. The sync checks seed a real Groq provider with a key to |
| 278 | // prove the provider store travels, and from 2026-08-20 a catalogue over a day old asks |
| 279 | // for itself -- so a suite that had never been a client of anybody's API quietly became |
| 280 | // one. The seeded row is a fixture and its host is stubbed rather than reached. |
| 281 | await s.page.route('https://api.groq.com/**', (r) => r.fulfill({ |
| 282 | status: 200, contentType: 'application/json', body: JSON.stringify({ data: [] }), |
| 283 | })); |
| 284 | const { page } = s; |
| 285 | let child = null; // a second REAL device, paired in at (12) |
| 286 | |
| 287 | // The engine and its dependencies must be live and the session authed. |
| 288 | await page.waitForFunction( |
| 289 | () => !!window.DaimondSync && !!window.DaimondCore && !!window.DaimondGateway |
| 290 | && DaimondGateway.state().authed, |
| 291 | null, { timeout: 12000 }, |
| 292 | ).catch(() => {}); |
| 293 | |
| 294 | try { |
| 295 | const authed = await page.evaluate(() => DaimondGateway.state().authed); |
| 296 | check('gateway session is authed (sync can reach its mailbox)', authed); |
| 297 | await installBump(page); |
| 298 | |
| 299 | // Sync IS the Pro capability under test, so the account has to hold Pro: |
| 300 | // without it the gateway answers 402 and there is nothing below to measure. |
| 301 | const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway'); |
| 302 | const lic = await makePagePro(page, GWDIR); |
| 303 | // AN UNPROVISIONED IDENTITY REFUSES BY NAME, rather than reddening every check below it. |
| 304 | // |
| 305 | // `makePagePro` answers `{ id: '', status: 0, pro: false }` when this page's identity has |
| 306 | // no gateway account at all -- the binding `/api/account` makes, which `dev/provision.mjs` |
| 307 | // is what calls. Until 2026-08-24 `run_all.sh` provisioned one identity, `compose`, and |
| 308 | // this file drives its own, `sync`. So it came out of every gate at 37 failed / 68 passed, |
| 309 | // with `pro=false` on the second line and all thirty-five others downstream of it: sync is |
| 310 | // Pro-gated, so without Pro there is nothing below to measure. Nothing was wrong with the |
| 311 | // engine -- provisioned by hand it answers 177/177 -- and the report said "regression". |
| 312 | // |
| 313 | // So the missing thing is named, and the file stops. A suite reads `SKIPPED:` and counts |
| 314 | // it as neither a pass nor a fail, which is what it is. |
| 315 | if (!lic.id) { |
| 316 | console.log('SKIPPED: this file drives the `sync` identity, and it has no gateway ' |
| 317 | + 'account. Nothing here can be measured without one, because sync is behind Pro ' |
| 318 | + 'and Pro is bought for an account.'); |
| 319 | console.log(' To provision it: node dev/provision.mjs "$DAIMOND_SCRATCH/sync-profile" ' |
| 320 | + 'sync, then `daimond_ctl topup <id> 5000`, RESTART the gateway (o3db holds its ' |
| 321 | + 'key index per process), then node dev/pro.mjs <id> gateway.'); |
| 322 | console.log(' dev/run_all.sh does all of that: `sync` is in NEEDS_GRANT and ident_for ' |
| 323 | + 'names its identity. If you are seeing this from a suite run, that provisioning ' |
| 324 | + 'failed -- read $DAIMOND_SCRATCH/suite-provision.log.'); |
| 325 | process.exit(2); |
| 326 | } |
| 327 | check('the account holds Pro, so sync may run at all', |
| 328 | lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`); |
| 329 | // A refusal must never put anything over the app -- that dialog is gone, and |
| 330 | // this is what keeps it gone. |
| 331 | const overlay = await page.evaluate(() => |
| 332 | [...document.querySelectorAll('.modal')] |
| 333 | .filter(m => getComputedStyle(m).display !== 'none') |
| 334 | .map(m => (m.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60))); |
| 335 | check('no dialog was raised over the app on the way here', overlay.length === 0, |
| 336 | overlay.join(' | ')); |
| 337 | |
| 338 | // A distinctive codeword, carried in a real message so it lands in the synced |
| 339 | // transcript. We then hunt for it in the ciphertext to prove it is sealed. |
| 340 | const MARK = 'ZEBRA-' + '7788'; |
| 341 | await chat(s, 'Remember the codeword ' + MARK + ' for later.'); |
| 342 | |
| 343 | // Push and read the mailbox straight back. |
| 344 | // |
| 345 | // The push is RETRIED until the mailbox actually moves, because `push()` |
| 346 | // stands aside over a live turn -- "never over a live turn", sync.js -- and |
| 347 | // answers a caller no differently than when it sent. `chat()` returns when |
| 348 | // the send button stops looking busy, which is not the same instant as |
| 349 | // `DaimondCore.busy()` going false, so a single push here could return |
| 350 | // having only scheduled one. The mailbox then still held the round BEFORE |
| 351 | // the conversation: version 1, one chat, no messages in it, and the second |
| 352 | // device below duly got an empty transcript back. That read as a sync defect |
| 353 | // for weeks; it was this race. |
| 354 | // |
| 355 | // Waiting on the version rather than on a timer, and reporting how many |
| 356 | // rounds it took, so a push that stops landing altogether still fails here |
| 357 | // instead of being papered over by a longer wait. |
| 358 | const mailboxWas = await page.evaluate(async () => { |
| 359 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 360 | return ((await r.json()).version) | 0; |
| 361 | }); |
| 362 | await pushLanded(page, 'the conversation'); |
| 363 | const pushed = await page.evaluate(async () => { |
| 364 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 365 | const j = await r.json(); |
| 366 | let name = ''; |
| 367 | try { name = String(window.DaimondIdentity.displayName() || ''); } catch (e) { name = ''; } |
| 368 | return { version: j.version, present: j.present, blob: j.blob || '', device: j.device || '', account: name }; |
| 369 | }); |
| 370 | // Asserted against the MAILBOX, not against `DaimondSync.state().version` |
| 371 | // which `pushLanded` waits on: the client's own counter is this device's |
| 372 | // belief about the round, and the question here is whether the gateway |
| 373 | // really holds the conversation. `pushLanded` notes a push that never went |
| 374 | // and carries on; this is where that becomes a failure. |
| 375 | check('the push carrying the conversation actually landed', |
| 376 | (pushed.version | 0) > mailboxWas, 'v' + mailboxWas + '→' + pushed.version); |
| 377 | check('after a push the mailbox holds a version >= 1', pushed.present && pushed.version >= 1, |
| 378 | 'version=' + pushed.version); |
| 379 | |
| 380 | // The record's display label is the one thing the gateway keeps in the clear |
| 381 | // beside the blob. A browser-and-platform description is fine; the account |
| 382 | // name is the user's own words and must never leave the browser readable. |
| 383 | check('the plaintext device label is not the account\'s chosen name', |
| 384 | pushed.device !== '' && (pushed.account === '' || !pushed.device.includes(pushed.account)), |
| 385 | 'device="' + pushed.device + '" account="' + pushed.account + '"'); |
| 386 | |
| 387 | // The blob is ciphertext: the plaintext codeword must not be in it, and it must |
| 388 | // not decode to readable JSON. |
| 389 | check('the stored blob is ciphertext (plaintext codeword absent)', !pushed.blob.includes(MARK)); |
| 390 | const looksEncrypted = await page.evaluate((blob) => { |
| 391 | try { const t = atob(blob); return !(t.trim().startsWith('{') || t.includes('"chats"') || t.includes('messages')); } |
| 392 | catch (e) { return true; } |
| 393 | }, pushed.blob); |
| 394 | check('the blob does not decode to plaintext JSON', looksEncrypted); |
| 395 | |
| 396 | // (2) Second device: wipe local chats + version cursor, then pull. |
| 397 | // |
| 398 | // The chats are wiped through the store that holds them. They are in |
| 399 | // IndexedDB, so removing the old localStorage key emptied nothing and this |
| 400 | // measured a device that had never lost anything. |
| 401 | // The shape of what came back is carried out with the count, because this |
| 402 | // check asserts TWO things -- a chat arrived, and the words in it did -- and |
| 403 | // reporting only the count says "chats=1" for both a pass and the failure |
| 404 | // where the transcript is empty. That reads as a passing check that failed. |
| 405 | const restored = await page.evaluate(async () => { |
| 406 | const store = window.DaimondCore.chatStore(); |
| 407 | await store.wipe(); |
| 408 | localStorage.removeItem('daimond-sync-version'); |
| 409 | const emptied = store.stored().length; |
| 410 | const v = await window.DaimondSync.pull(); |
| 411 | const arr = store.stored(); |
| 412 | const text = JSON.stringify(arr); |
| 413 | return { |
| 414 | version: v, emptied, chatCount: arr.length, text, |
| 415 | // Per chat: its id, how many messages it holds, and which roles they |
| 416 | // are. A chat that arrives with an empty transcript and one that never |
| 417 | // arrived are different defects and this is what tells them apart. |
| 418 | shape: arr.map(c => ({ |
| 419 | id: c && c.id, |
| 420 | msgs: (c && Array.isArray(c.messages)) ? c.messages.length : -1, |
| 421 | roles: (c && Array.isArray(c.messages)) ? c.messages.map(m => m && m.role).join(',') : '', |
| 422 | })), |
| 423 | }; |
| 424 | }); |
| 425 | check('the second device really started with no transcripts', |
| 426 | restored.emptied === 0, 'held=' + restored.emptied); |
| 427 | check('a fresh device pulls and decrypts the chat transcript back', |
| 428 | restored.chatCount >= 1 && restored.text.includes(MARK), |
| 429 | 'chats=' + restored.chatCount |
| 430 | + ' codeword=' + (restored.text.includes(MARK) ? 'present' : 'ABSENT') |
| 431 | + ' ' + JSON.stringify(restored.shape)); |
| 432 | |
| 433 | // (3) Conflict: another device bumps the mailbox out of band (a garbage blob is |
| 434 | // fine — the gateway is opaque and checks only the version), so THIS device's |
| 435 | // known version is now stale. Its next push must 409, pull, merge and retry. |
| 436 | const conflict = await page.evaluate(async () => { |
| 437 | const before = window.DaimondSync.version(); |
| 438 | // The "other device" pushes over the current version, advancing it by one. |
| 439 | const otherWrite = await fetch('/api/sync', { |
| 440 | method: 'POST', credentials: 'same-origin', |
| 441 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 442 | body: JSON.stringify({ base_version: before, device: 'other-device', blob: 'AAAABBBBCCCCDDDD' }), |
| 443 | }); |
| 444 | const otherJson = await otherWrite.json(); |
| 445 | // A genuine local change, or the push is skipped before it is ever sent and |
| 446 | // there is no 409 to reconcile. |
| 447 | const changed = await window.__bump('conflict-note'); |
| 448 | // This device still thinks the version is `before`. Push the fresh change. |
| 449 | // |
| 450 | // Pushed until the mailbox moves, not once: a push that finds a round |
| 451 | // already in flight reschedules and returns, and reading the version in |
| 452 | // the same tick then reported a reconcile that had not been attempted |
| 453 | // yet as one that failed. The base is still stale whichever call ends up |
| 454 | // sending, so what is waited for is still the 409-pull-retry path. |
| 455 | let after = otherJson.version; |
| 456 | const t0 = Date.now(); |
| 457 | while (after <= otherJson.version && Date.now() - t0 < 15000) { |
| 458 | await window.DaimondSync.push(); // base=before → 409 → pull → retry → success. |
| 459 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 460 | after = ((await r.json()).version) | 0; |
| 461 | if (after <= otherJson.version) await new Promise(x => setTimeout(x, 250)); |
| 462 | } |
| 463 | return { before, bumped: otherJson.version, after, changed }; |
| 464 | }); |
| 465 | check('the out-of-band write advanced the mailbox', conflict.bumped === conflict.before + 1, |
| 466 | 'before=' + conflict.before + ' bumped=' + conflict.bumped); |
| 467 | check('and this device really had something of its own to send', |
| 468 | conflict.changed.moved === true, conflict.changed.why); |
| 469 | check('a stale push reconciles (409 → pull → retry) and advances past the conflict', |
| 470 | conflict.after > conflict.bumped, 'bumped=' + conflict.bumped + ' after=' + conflict.after); |
| 471 | // After reconciling, the mailbox is this device's real (decryptable) state again. |
| 472 | const reopened = await page.evaluate(async () => { |
| 473 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 474 | const j = await r.json(); |
| 475 | try { const plain = await window.DaimondIdentity.unwrap(j.blob); JSON.parse(plain); return true; } |
| 476 | catch (e) { return false; } |
| 477 | }); |
| 478 | check('the reconciled blob is this device’s own decryptable state', reopened); |
| 479 | |
| 480 | // (3b) Workspace files travel too: write one, push, confirm it is sealed, then |
| 481 | // delete it locally and pull it back. |
| 482 | // |
| 483 | // The push is driven by the helper, not called once: a bare push() that finds |
| 484 | // a round in flight only reschedules, and the mailbox then held a parcel with |
| 485 | // no file in it at all. That read as "a deleted workspace file is restored by |
| 486 | // pull" failing -- while the sealed check above it stayed green, because a |
| 487 | // blob that never carried the file passes "the mark is absent" perfectly. |
| 488 | // Which is why the file's presence is now asserted too: a check that only |
| 489 | // looks for something's ABSENCE is answered by having nothing. |
| 490 | const FILEMARK = 'FILEMARK-' + '5566'; |
| 491 | await page.evaluate(async (mark) => { |
| 492 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 493 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 494 | await app.run_tool('file_write', JSON.stringify({ path: 'sync-note.txt', content: 'workspace ' + mark })); |
| 495 | }, FILEMARK); |
| 496 | await pushLanded(page, 'the workspace file'); |
| 497 | const filePush = await page.evaluate(async () => { |
| 498 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 499 | const j = await r.json(); |
| 500 | let plain = ''; |
| 501 | try { plain = await window.DaimondIdentity.unwrap(j.blob || ''); } catch (e) { plain = ''; } |
| 502 | return { version: j.version, blob: j.blob || '', plain }; |
| 503 | }); |
| 504 | check('the workspace file really travelled — the mailbox holds it, sealed', |
| 505 | filePush.plain.includes(FILEMARK), 'parcel ' + filePush.plain.length + ' bytes'); |
| 506 | check('a workspace file is sealed in the pushed blob (content absent from ciphertext)', |
| 507 | !filePush.blob.includes(FILEMARK)); |
| 508 | |
| 509 | const fileRestore = await page.evaluate(async () => { |
| 510 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 511 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 512 | await app.run_tool('file_delete', JSON.stringify({ path: 'sync-note.txt' })); |
| 513 | const gone = await app.run_tool('file_read', JSON.stringify({ path: 'sync-note.txt' })); |
| 514 | localStorage.removeItem('daimond-sync-filebase'); // a fresh device has no baseline. |
| 515 | await window.DaimondSync.pull(); |
| 516 | const back = await app.run_tool('file_read', JSON.stringify({ path: 'sync-note.txt' })); |
| 517 | return { gone: String(gone), back: String(back) }; |
| 518 | }); |
| 519 | check('a deleted workspace file is restored by pull', |
| 520 | fileRestore.back.includes(FILEMARK) && /error|not found|no such/i.test(fileRestore.gone), |
| 521 | 'back=' + fileRestore.back.slice(0, 40)); |
| 522 | |
| 523 | // (3c) A deletion on another device propagates here (an unchanged local copy |
| 524 | // is removed; an edit would have beaten the delete). |
| 525 | // The agreeing push is driven by the helper for the same reason as (3b): the |
| 526 | // deletion below is only meaningful against a mailbox that HELD the file, and |
| 527 | // a bare push() that stood aside left there being nothing to delete. |
| 528 | await page.evaluate(async () => { |
| 529 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 530 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 531 | await app.run_tool('file_write', JSON.stringify({ path: 'DELME.txt', content: 'delete me across devices' })); |
| 532 | localStorage.removeItem('daimond-sync-filebase'); |
| 533 | }); |
| 534 | await pushLanded(page, 'DELME.txt enters the baseline'); |
| 535 | const delProp = await page.evaluate(async () => { |
| 536 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 537 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 538 | const present = await app.run_tool('file_read', JSON.stringify({ path: 'DELME.txt' })); |
| 539 | // The OTHER device deletes DELME.txt and pushes the reduced state. |
| 540 | const state = await window.DaimondCore.collectSync(); |
| 541 | delete state.files['DELME.txt']; |
| 542 | const blob = await window.DaimondIdentity.wrap(JSON.stringify(state)); |
| 543 | const ver = window.DaimondSync.version(); |
| 544 | await fetch('/api/sync', { |
| 545 | method: 'POST', credentials: 'same-origin', |
| 546 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 547 | body: JSON.stringify({ base_version: ver, device: 'other', blob: blob }), |
| 548 | }); |
| 549 | await window.DaimondSync.pull(); // honour the remote deletion. |
| 550 | const after = await app.run_tool('file_read', JSON.stringify({ path: 'DELME.txt' })); |
| 551 | return { present: String(present), after: String(after) }; |
| 552 | }); |
| 553 | check('a file deleted on another device is removed here', |
| 554 | delProp.present.includes('delete me') && /error|not found|no such/i.test(delProp.after), |
| 555 | 'after=' + delProp.after.slice(0, 40)); |
| 556 | |
| 557 | // ── (4) Diamonds ─────────────────────────────────────────────────── |
| 558 | // A Diamond is a directory in OPFS, so "it synced" means the whole directory |
| 559 | // arrived: the crystal and its versions, the metadata that carries the name |
| 560 | // and the tags, the append-only log, and the link sidecar. Each of those is a |
| 561 | // separate file, and a merge that carried only some of them would look like a |
| 562 | // working sync right up until the user asked the Graph pane a question. |
| 563 | const DMARK = 'DIAMOND-' + '3344'; |
| 564 | |
| 565 | /// Reach the real wasm on the page's own OPFS — the same store the app reads, |
| 566 | /// not a copy of it. |
| 567 | const wasm = (fn, arg) => page.evaluate(async ({ src, arg }) => { |
| 568 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 569 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 570 | return await (new Function('app', 'arg', `return (${src})(app, arg);`))(app, arg); |
| 571 | }, { src: fn.toString(), arg }); |
| 572 | |
| 573 | /// Make a Diamond the way a person does: the rail's button, the dialog, the |
| 574 | /// name, Create. Going through the UI is what records the model choice, which |
| 575 | /// is half of what this section is checking travels. |
| 576 | const newDiamond = async (name) => { |
| 577 | await page.evaluate(() => document.getElementById('new-diamond-btn').click()); |
| 578 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 579 | const r = await page.evaluate((nm) => { |
| 580 | const card = [...document.querySelectorAll('.dlg-card')].find(c => c.getClientRects().length); |
| 581 | if (!card) return 'no dialog'; |
| 582 | const inp = card.querySelector('input.dlg-input'); |
| 583 | if (!inp) return 'no name field'; |
| 584 | inp.value = nm; |
| 585 | inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 586 | const btn = card.querySelector('.dlg-ok'); |
| 587 | if (!btn) return 'no create button'; |
| 588 | btn.click(); |
| 589 | return 'ok'; |
| 590 | }, name); |
| 591 | await page.waitForTimeout(1000); |
| 592 | return r; |
| 593 | }; |
| 594 | |
| 595 | /// Delete the named Diamond the way a person does — the cog, then Delete at |
| 596 | /// the foot of its dialog. THERE IS NO CONFIRM ANY MORE: since the trash, |
| 597 | /// deleting is reversible and asks nothing, and this is the call site that |
| 598 | /// has to put the Diamond into the state that travels. |
| 599 | const removeDiamond = async (name) => { |
| 600 | const found = await page.evaluate((nm) => { |
| 601 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 602 | .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm); |
| 603 | if (!box) return false; |
| 604 | const cog = box.querySelector('.tile-cog'); |
| 605 | if (!cog) return false; |
| 606 | cog.click(); |
| 607 | return true; |
| 608 | }, name); |
| 609 | if (!found) return 'not in the rail'; |
| 610 | await page.waitForSelector('.tile-dlg-delete', { timeout: 8000 }); |
| 611 | await page.evaluate(() => document.querySelector('.tile-dlg-delete').click()); |
| 612 | await page.waitForTimeout(1500); |
| 613 | return 'ok'; |
| 614 | }; |
| 615 | |
| 616 | /// Destroy a Diamond for good, out of the trash. THIS is now the call site |
| 617 | /// that writes the tombstone. |
| 618 | /// |
| 619 | /// By ID rather than by looking the name up in the panel: this file runs |
| 620 | /// sections that empty the store on purpose, and a lookup that went through |
| 621 | /// the panel would report "not in the trash" for a record that is perfectly |
| 622 | /// present — testing the fixture rather than the tombstone. |
| 623 | const purgeDiamond = async (id) => { |
| 624 | await page.evaluate((i) => window.DaimondCore.trashPurge(i), id); |
| 625 | await page.waitForTimeout(1200); |
| 626 | return 'ok'; |
| 627 | }; |
| 628 | |
| 629 | /// A crystal as the store now holds one: `crystal.json`, conforming to the core |
| 630 | /// schema. These fixtures only ever needed a crystal they could tell apart, and |
| 631 | /// the schema's `title` is where a short distinguishing string goes. |
| 632 | /// |
| 633 | /// It has to be REAL JSON, not merely a different string. The parcel carries the |
| 634 | /// Diamond's directory file for file, so whatever this writes is what the app |
| 635 | /// parses on the other side — a markdown fixture would leave the merge under test |
| 636 | /// carrying something no reader downstream can open. |
| 637 | const crystalOf = (title) => JSON.stringify({ title: title }); |
| 638 | |
| 639 | /// What another device would have pushed: this device's own state with one |
| 640 | /// Diamond's entry rewritten to a different crystal at a different stamp, |
| 641 | /// sealed under the shared key, written over the current version and pulled |
| 642 | /// straight back — which is the merge under test. |
| 643 | /// |
| 644 | /// BOTH stamps move, in both the places a real export carries them: the entry |
| 645 | /// the merge compares, and the `meta.json` inside the packed directory. A |
| 646 | /// real device's copies always agree, so a fixture whose copies disagree |
| 647 | /// would be testing a state that cannot occur -- and a crystal edit on a real |
| 648 | /// device moves `updated` (it was worked on) AND `touched` (it changed). |
| 649 | const otherDeviceShifts = (id, crystal, delta) => page.evaluate(async (arg) => { |
| 650 | const state = await window.DaimondCore.collectSync(); |
| 651 | const e = (state.diamonds || []).find(d => d.id === arg.id); |
| 652 | if (!e) return 'no entry for that Diamond'; |
| 653 | const pack = JSON.parse(e.data); |
| 654 | pack.files['crystal.json'] = arg.crystal; |
| 655 | const meta = JSON.parse(pack.files['.daimond/meta.json']); |
| 656 | meta.updated = (e.updated || 0) + arg.delta; |
| 657 | meta.touched = (e.touched || e.updated || 0) + arg.delta; |
| 658 | pack.files['.daimond/meta.json'] = JSON.stringify(meta); |
| 659 | e.updated = meta.updated; |
| 660 | e.touched = meta.touched; |
| 661 | e.data = JSON.stringify(pack); |
| 662 | const blob = await window.DaimondIdentity.wrap(JSON.stringify(state)); |
| 663 | await fetch('/api/sync', { |
| 664 | method: 'POST', credentials: 'same-origin', |
| 665 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 666 | body: JSON.stringify({ base_version: window.DaimondSync.version(), device: 'other', blob: blob }), |
| 667 | }); |
| 668 | await window.DaimondSync.pull(); |
| 669 | return 'ok'; |
| 670 | }, { id, crystal, delta }); |
| 671 | |
| 672 | /// One Diamond's whole visible state, read back from the store. |
| 673 | const readDiamond = (id) => wasm(async (app, id) => { |
| 674 | const list = JSON.parse(await app.list_diamonds()).find(d => d.id === id) || null; |
| 675 | if (!list) return null; |
| 676 | return { |
| 677 | name: list.name, |
| 678 | tags: list.tags || [], |
| 679 | version: list.crystal_version, |
| 680 | updated: list.updated, |
| 681 | crystal: await app.read_crystal_data(id), |
| 682 | log: JSON.parse(await app.log_read(id)).length, |
| 683 | links: JSON.parse(await app.links_touching('diamond:' + id)), |
| 684 | }; |
| 685 | }, id); |
| 686 | |
| 687 | // The fixture: two Diamonds, one of them tagged, steered (so its log has an |
| 688 | // edit record beside the create) and linked to the other. |
| 689 | await newDiamond('Sync-Alpha'); |
| 690 | await newDiamond('Sync-Bravo'); |
| 691 | const ids = await wasm(async (app, crystal) => { |
| 692 | const list = JSON.parse(await app.list_diamonds()); |
| 693 | const find = (n) => (list.find(d => d.name === n) || {}).id || ''; |
| 694 | const A = find('Sync-Alpha'), B = find('Sync-Bravo'); |
| 695 | if (!A || !B) return { A, B }; |
| 696 | await app.set_tags(A, JSON.stringify(['travel', 'sync'])); |
| 697 | await app.write_crystal_data(A, crystal); |
| 698 | await app.add_link(A, 'diamond:' + A, 'diamond:' + B, 'part-of', 'bravo sits under alpha', 'user'); |
| 699 | return { A, B }; |
| 700 | }, crystalOf('Alpha ' + DMARK)); |
| 701 | check('the fixture Diamonds were made through the rail', !!(ids.A && ids.B), |
| 702 | 'alpha=' + (ids.A || '-') + ' bravo=' + (ids.B || '-')); |
| 703 | |
| 704 | const before = await readDiamond(ids.A); |
| 705 | // What the fixture actually laid down, before any of it travels. Without this |
| 706 | // a fixture that quietly failed to write reads afterwards as a sync that |
| 707 | // quietly failed to carry, and the two want opposite fixes. |
| 708 | check('the fixture took locally before anything synced', |
| 709 | !!before && before.tags.join(',') === 'travel,sync' && before.crystal.includes(DMARK) |
| 710 | && before.log >= 2 && before.links.length >= 1, |
| 711 | before ? 'tags=[' + before.tags.join(',') + '] v' + before.version |
| 712 | + ' log=' + before.log + ' links=' + before.links.length : 'absent'); |
| 713 | await pushLanded(page, 'the fixture Diamonds'); |
| 714 | |
| 715 | // A second device: it has never seen these Diamonds, holds no per-Diamond |
| 716 | // model choice, and has no version cursor. Wiping all three is what "another |
| 717 | // device" means here. |
| 718 | const arrived = await page.evaluate(async (id) => { |
| 719 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 720 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 721 | for (const d of JSON.parse(await app.list_diamonds())) await app.delete_diamond(d.id); |
| 722 | localStorage.removeItem('daimond-diamond-models'); |
| 723 | localStorage.removeItem('daimond-sync-version'); |
| 724 | const gone = JSON.parse(await app.list_diamonds()).length; |
| 725 | await window.DaimondSync.pull(); |
| 726 | const models = JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}'); |
| 727 | return { gone: gone, model: models[id] || null }; |
| 728 | }, ids.A); |
| 729 | check('the second device really started without them', arrived.gone === 0, 'held=' + arrived.gone); |
| 730 | |
| 731 | const after = await readDiamond(ids.A); |
| 732 | check('a Diamond arrives on the second device at all', !!after, |
| 733 | after ? after.name : 'absent'); |
| 734 | check('its name and tags arrive with it', |
| 735 | !!after && after.name === 'Sync-Alpha' && after.tags.join(',') === 'travel,sync', |
| 736 | after ? after.name + ' [' + after.tags.join(',') + ']' : 'absent'); |
| 737 | check('its crystal arrives, at the version it was left at', |
| 738 | !!after && after.crystal.includes(DMARK) && after.version === before.version, |
| 739 | after ? 'v' + after.version + ' len=' + after.crystal.length : 'absent'); |
| 740 | check('its log arrives whole (the create record and the edit)', |
| 741 | !!after && after.log === before.log && after.log >= 2, |
| 742 | after ? 'records=' + after.log : 'absent'); |
| 743 | check('the link to the other Diamond arrives, and is found from this end', |
| 744 | !!after && after.links.some(l => l.other === 'diamond:' + ids.B && l.rel === 'part-of'), |
| 745 | after ? JSON.stringify(after.links.map(l => l.other + '/' + l.rel)) : 'absent'); |
| 746 | check('the model the Diamond thinks with arrives with it', |
| 747 | !!(arrived.model && arrived.model.model), JSON.stringify(arrived.model)); |
| 748 | // The other Diamond came too, or the rail below has nothing to delete. |
| 749 | const bravoBack = await readDiamond(ids.B); |
| 750 | check('the second Diamond arrives as well', !!bravoBack && bravoBack.name === 'Sync-Bravo', |
| 751 | bravoBack ? bravoBack.name : 'absent'); |
| 752 | |
| 753 | // (4b) Deleting through the rail must put the Diamond into a state the |
| 754 | // parcel CARRIES — and, since the trash, that state is not a tombstone. |
| 755 | // |
| 756 | // The two are different promises and both are asked here. Trashing has to |
| 757 | // travel WITH THE BYTES, or the other device holds a name it cannot restore; |
| 758 | // destroying has to travel as a tombstone, or the other device still holds |
| 759 | // the Diamond and hands it straight back on the next pull. The old shape of |
| 760 | // this check — delete in the rail, expect a tombstone — would now pass with |
| 761 | // a trash that quietly destroyed everything, which is the failure the panel |
| 762 | // exists to prevent. |
| 763 | // What the parcel carried BEFORE the delete, so "its bytes still travel" is a |
| 764 | // comparison rather than an absolute: earlier sections of this file empty the |
| 765 | // store on purpose, and a Diamond whose bytes were not in the parcel to begin |
| 766 | // with cannot be asked to still be in it. |
| 767 | const carriedBefore = await page.evaluate(async (id) => { |
| 768 | const state = await window.DaimondSync.parcel(); |
| 769 | return (state.diamonds || []).some(d => d.id === id); |
| 770 | }, ids.B); |
| 771 | const removed = await removeDiamond('Sync-Bravo'); |
| 772 | const parcel = await page.evaluate(async (id) => { |
| 773 | const state = await window.DaimondSync.parcel(); |
| 774 | const rec = (state.trash && state.trash.items) ? state.trash.items[id] : null; |
| 775 | return { |
| 776 | v: state.v, |
| 777 | tombed: !!(state.diamondTombs && state.diamondTombs[id]), |
| 778 | trashed: !!(rec && rec.at > rec.back), |
| 779 | listed: (state.diamonds || []).some(d => d.id === id), |
| 780 | count: (state.diamonds || []).length, |
| 781 | }; |
| 782 | }, ids.B); |
| 783 | check('a Diamond deleted in the rail travels as TRASHED, so the other device can restore it', |
| 784 | removed === 'ok' && parcel.trashed, removed + ', trashed=' + parcel.trashed); |
| 785 | check('and it is NOT tombstoned by that — deleting it is not destroying it', |
| 786 | !parcel.tombed, 'tombed=' + parcel.tombed); |
| 787 | check('while its bytes travel exactly as before, or there would be nothing to restore', |
| 788 | parcel.listed === carriedBefore, |
| 789 | 'carried before=' + carriedBefore + ', after=' + parcel.listed + ' (of ' + parcel.count + ')'); |
| 790 | check('the parcel declares itself v3', parcel.v === 3, 'v=' + parcel.v); |
| 791 | |
| 792 | // And destroying it from the trash is what lays the tombstone. |
| 793 | const purged = await purgeDiamond(ids.B); |
| 794 | const afterPurge = await page.evaluate(async (id) => { |
| 795 | const state = await window.DaimondSync.parcel(); |
| 796 | return { |
| 797 | tombed: !!(state.diamondTombs && state.diamondTombs[id]), |
| 798 | listed: (state.diamonds || []).some(d => d.id === id), |
| 799 | }; |
| 800 | }, ids.B); |
| 801 | check('destroying it from the trash IS what tombstones it in the parcel', |
| 802 | purged === 'ok' && afterPurge.tombed, purged + ', tombed=' + afterPurge.tombed); |
| 803 | check('and it is no longer offered as a live Diamond', |
| 804 | !afterPurge.listed, 'listed=' + afterPurge.listed); |
| 805 | |
| 806 | // (4c) A deletion made on ANOTHER device reaches this one, and does not come |
| 807 | // back on the cycle after — the failure a tombstone-less delete would show as |
| 808 | // a Diamond that reappears every time the app is opened. |
| 809 | const delCycle = await page.evaluate(async (id) => { |
| 810 | const post = async (state) => { |
| 811 | const blob = await window.DaimondIdentity.wrap(JSON.stringify(state)); |
| 812 | await fetch('/api/sync', { |
| 813 | method: 'POST', credentials: 'same-origin', |
| 814 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 815 | body: JSON.stringify({ base_version: window.DaimondSync.version(), device: 'other', blob: blob }), |
| 816 | }); |
| 817 | await window.DaimondSync.pull(); |
| 818 | }; |
| 819 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 820 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 821 | const here = async () => JSON.parse(await app.list_diamonds()).some(d => d.id === id); |
| 822 | const held = await here(); |
| 823 | // The other device deletes it: gone from its Diamonds, present in its tombs. |
| 824 | const state = await window.DaimondCore.collectSync(); |
| 825 | state.diamonds = (state.diamonds || []).filter(d => d.id !== id); |
| 826 | state.diamondTombs = state.diamondTombs || {}; |
| 827 | state.diamondTombs[id] = Date.now(); |
| 828 | await post(state); |
| 829 | // SETTLE, DO NOT SNAPSHOT. `pull()` resolving is not the same instant as the |
| 830 | // store having finished with the directory it just removed: two |
| 831 | // `list_diamonds()` calls a few microseconds apart were observed |
| 832 | // disagreeing, the first still carrying the Diamond and the second empty. |
| 833 | // Reading once, in the same tick, therefore reported a deletion that HAD |
| 834 | // happened as one that had not -- and it read as sync losing a tombstone, |
| 835 | // which is about as alarming as this suite gets. |
| 836 | // |
| 837 | // Bounded, so a deletion that genuinely never lands still fails rather than |
| 838 | // hanging: half a second is already hundreds of times what the settle costs. |
| 839 | const gone = async () => { |
| 840 | for (let i = 0; i < 25; i++) { |
| 841 | if (!(await here())) return false; |
| 842 | await new Promise(r => setTimeout(r, 20)); |
| 843 | } |
| 844 | return true; |
| 845 | }; |
| 846 | const afterPull = await gone(); |
| 847 | // A full cycle later — this device pushes its own view, then pulls again. |
| 848 | await window.DaimondSync.push(); |
| 849 | await window.DaimondSync.pull(); |
| 850 | const afterCycle = await gone(); // same reason: settle, do not snapshot |
| 851 | return { held, afterPull, afterCycle }; |
| 852 | }, ids.A); |
| 853 | check('a Diamond deleted on another device is deleted here', |
| 854 | delCycle.held === true && delCycle.afterPull === false, |
| 855 | 'held=' + delCycle.held + ' after=' + delCycle.afterPull); |
| 856 | check('and does not resurrect on the next cycle', delCycle.afterCycle === false, |
| 857 | 'after a push+pull, present=' + delCycle.afterCycle); |
| 858 | |
| 859 | // (4d) Freshest wins, wholesale, and the comparison is STRICT: an equal stamp |
| 860 | // keeps what is here, so an unchanged Diamond is not rewritten on every pull. |
| 861 | await newDiamond('Sync-Charlie'); |
| 862 | const cid = await wasm(async (app, crystal) => { |
| 863 | const list = JSON.parse(await app.list_diamonds()); |
| 864 | const id = (list.find(d => d.name === 'Sync-Charlie') || {}).id || ''; |
| 865 | if (id) await app.write_crystal_data(id, crystal); |
| 866 | return id; |
| 867 | }, crystalOf('HERE-' + DMARK)); |
| 868 | await pushLanded(page, 'Sync-Charlie'); |
| 869 | |
| 870 | /// Which of the four copies below is on this device, named by the one field they |
| 871 | /// differ by. PARSED rather than compared as text: a copy that arrives as |
| 872 | /// anything but the JSON a crystal now is fails here, where it reads as a merge |
| 873 | /// that carried the wrong thing, rather than passing as bytes that merely match. |
| 874 | const crystalNow = async () => { |
| 875 | const text = await wasm((app, id) => app.read_crystal_data(id), cid); |
| 876 | try { return JSON.parse(text).title; } |
| 877 | catch (e) { return 'not JSON: ' + String(text).slice(0, 30); } |
| 878 | }; |
| 879 | |
| 880 | const shifted = await otherDeviceShifts(cid, crystalOf('OLDER-COPY'), -60000); |
| 881 | const keptOlder = await crystalNow(); |
| 882 | await otherDeviceShifts(cid, crystalOf('EQUAL-COPY'), 0); |
| 883 | const keptEqual = await crystalNow(); |
| 884 | await otherDeviceShifts(cid, crystalOf('NEWER-COPY'), 60000); |
| 885 | const tookNewer = await crystalNow(); |
| 886 | check('the freshest-wins fixture reached the other device', shifted === 'ok', shifted); |
| 887 | check('an older copy from another device does not overwrite this one', |
| 888 | keptOlder === 'HERE-' + DMARK, keptOlder); |
| 889 | check('an equally-stamped copy keeps what is here (the comparison is strict)', |
| 890 | keptEqual === 'HERE-' + DMARK, keptEqual); |
| 891 | check('a fresher copy from another device replaces this one', |
| 892 | tookNewer === 'NEWER-COPY', tookNewer); |
| 893 | |
| 894 | // (4e) A device that predates all of this sends a v1 parcel: no `diamonds`, |
| 895 | // no `diamondTombs`. It must apply as it always did, and touch nothing. |
| 896 | const v1 = await page.evaluate(async (id) => { |
| 897 | const state = await window.DaimondCore.collectSync(); |
| 898 | delete state.diamonds; |
| 899 | delete state.diamondTombs; |
| 900 | state.v = 1; |
| 901 | const blob = await window.DaimondIdentity.wrap(JSON.stringify(state)); |
| 902 | await fetch('/api/sync', { |
| 903 | method: 'POST', credentials: 'same-origin', |
| 904 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 905 | body: JSON.stringify({ base_version: window.DaimondSync.version(), device: 'old-device', blob: blob }), |
| 906 | }); |
| 907 | let threw = ''; |
| 908 | try { await window.DaimondCore.applySync(JSON.parse(await window.DaimondIdentity.unwrap(blob))); } |
| 909 | catch (e) { threw = String(e && e.message || e); } |
| 910 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 911 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 912 | return { threw, still: JSON.parse(await app.list_diamonds()).some(d => d.id === id) }; |
| 913 | }, cid); |
| 914 | check('a v1 parcel (no diamonds section) applies without error', |
| 915 | v1.threw === '', v1.threw); |
| 916 | check('and leaves this device’s Diamonds exactly where they were', v1.still === true); |
| 917 | |
| 918 | // ── (4f) Tags across devices: the change that used to vanish ─────── |
| 919 | // A tag is content, but it is not WORK, so tagging deliberately leaves |
| 920 | // `updated` alone -- the rail is ordered by it, and filing a Diamond must not |
| 921 | // shuffle it to the top. The merge compared `updated`, so a tag-only change |
| 922 | // was invisible to it: it never travelled, and the moment the other device |
| 923 | // did anything stamped (a rename, a crystal edit) its untagged copy became |
| 924 | // strictly fresher and REPLACED the tagged one wholesale. A real user lost |
| 925 | // real tags to exactly that. |
| 926 | // |
| 927 | // Two devices are simulated on the one browser by swapping the store under |
| 928 | // it: `export_diamond` is a device's disk, `import_diamond` lays it back down |
| 929 | // verbatim (stamps included), and the merge that runs on each side is the |
| 930 | // app's own `applySync`, not a copy of it written here. |
| 931 | const TAG = 'keepsake'; |
| 932 | |
| 933 | /// What this device holds, as import-ready packs: its disk. |
| 934 | const diskNow = () => wasm(async (app) => { |
| 935 | const out = []; |
| 936 | for (const d of JSON.parse(await app.list_diamonds())) out.push(await app.export_diamond(d.id)); |
| 937 | return out; |
| 938 | }); |
| 939 | |
| 940 | /// Make the browser BE the device that disk came from. |
| 941 | const becomeDevice = (disk) => wasm(async (app, disk) => { |
| 942 | for (const d of JSON.parse(await app.list_diamonds())) await app.delete_diamond(d.id); |
| 943 | for (const pack of disk) await app.import_diamond(pack); |
| 944 | return JSON.parse(await app.list_diamonds()).length; |
| 945 | }, disk); |
| 946 | |
| 947 | /// What this device would push, and what a pull does with what arrives. |
| 948 | const parcelNow = () => page.evaluate(() => window.DaimondCore.collectSync()); |
| 949 | const pullParcel = (p) => page.evaluate(async (p) => { await window.DaimondCore.applySync(p); }, p); |
| 950 | |
| 951 | /// One Diamond's row as the rail reads it. |
| 952 | const rowOf = (id) => wasm(async (app, id) => |
| 953 | JSON.parse(await app.list_diamonds()).find(d => d.id === id) || null, id); |
| 954 | const orderNow = () => wasm(async (app) => |
| 955 | JSON.parse(await app.list_diamonds()).map(d => d.id).join(',')); |
| 956 | const entryOf = (p, id) => (p.diamonds || []).find(d => d.id === id) || {}; |
| 957 | const tagsOn = (row) => ((row && row.tags) || []).join(','); |
| 958 | |
| 959 | // A clean two-device world: one Diamond to tag, one beside it so the rail has |
| 960 | // an order to disturb. |
| 961 | const two = await wasm(async (app, crystal) => { |
| 962 | for (const d of JSON.parse(await app.list_diamonds())) await app.delete_diamond(d.id); |
| 963 | const a = await app.create_diamond('Tag-Travel'); |
| 964 | const b = await app.create_diamond('Tag-Neighbour'); |
| 965 | await app.write_crystal_data(a, crystal); |
| 966 | return { a, b }; |
| 967 | }, crystalOf('shared ground')); |
| 968 | const disk0 = await diskNow(); // what BOTH devices last agreed on |
| 969 | check('the tag fixture starts from one agreed copy on both devices', |
| 970 | !!(two.a && two.b) && disk0.length === 2, 'packs=' + disk0.length); |
| 971 | |
| 972 | // (1) A tag-only change reaches the other device. |
| 973 | await becomeDevice(disk0); |
| 974 | const orderBefore = await orderNow(); |
| 975 | const rowBefore = await rowOf(two.a); |
| 976 | await wasm((app, arg) => app.set_tags(arg.id, JSON.stringify([arg.tag])), { id: two.a, tag: TAG }); |
| 977 | const rowTagged = await rowOf(two.a); |
| 978 | check('tagging leaves `updated` alone, so the rail keeps its order', |
| 979 | !!rowTagged && rowTagged.updated === rowBefore.updated && (await orderNow()) === orderBefore, |
| 980 | 'updated ' + rowBefore.updated + ' -> ' + (rowTagged && rowTagged.updated)); |
| 981 | const parcel1 = await parcelNow(); |
| 982 | const disk1 = await diskNow(); // device 1: tagged, nothing else touched |
| 983 | |
| 984 | await becomeDevice(disk0); // device 2: the copy it last saw |
| 985 | const beforePull2 = await rowOf(two.a); |
| 986 | await pullParcel(parcel1); |
| 987 | const gotTag = await rowOf(two.a); |
| 988 | check('a tag-only change reaches the other device', |
| 989 | tagsOn(gotTag) === TAG, 'tags=[' + tagsOn(gotTag) + ']'); |
| 990 | check('and arriving does not reorder the rail it arrived on', |
| 991 | !!gotTag && gotTag.updated === beforePull2.updated && (await orderNow()) === orderBefore, |
| 992 | 'updated ' + beforePull2.updated + ' -> ' + (gotTag && gotTag.updated)); |
| 993 | |
| 994 | // (2) The other device then works on the SAME Diamond. Its copy is fresher, |
| 995 | // so it wins wholesale -- and because it had already received the tags, the |
| 996 | // tags come back with the rename rather than being wiped by it. |
| 997 | await wasm((app, id) => app.rename_diamond(id, 'Renamed-On-Two'), two.a); |
| 998 | const parcel2 = await parcelNow(); |
| 999 | await becomeDevice(disk1); // device 1 as it was: tagged, not renamed |
| 1000 | await pullParcel(parcel2); |
| 1001 | const afterWork = await rowOf(two.a); |
| 1002 | check('the other device working on that Diamond does not wipe the tags', |
| 1003 | !!afterWork && afterWork.name === 'Renamed-On-Two' && tagsOn(afterWork) === TAG, |
| 1004 | afterWork ? afterWork.name + ' [' + tagsOn(afterWork) + ']' : 'absent'); |
| 1005 | check('an imported copy keeps the stamp it was sent with (an import is not working)', |
| 1006 | !!afterWork && afterWork.updated === (entryOf(parcel2, two.a).updated || 0), |
| 1007 | 'here=' + (afterWork && afterWork.updated) + ' sent=' + entryOf(parcel2, two.a).updated); |
| 1008 | |
| 1009 | // (3) Two stores that have ALREADY diverged -- the state the user is in. |
| 1010 | // One side tagged the Diamond under a build that wrote no second stamp, so |
| 1011 | // both copies are stamped identically and neither is fresher than the other. |
| 1012 | const diverged = await page.evaluate(({ disk, id, tag }) => disk.map((p) => { |
| 1013 | const pack = JSON.parse(p); |
| 1014 | if (pack.id !== id) return p; |
| 1015 | const meta = JSON.parse(pack.files['.daimond/meta.json']); |
| 1016 | meta.tags = [tag]; |
| 1017 | delete meta.touched; // an old build: one stamp, and it did not move |
| 1018 | pack.files['.daimond/meta.json'] = JSON.stringify(meta); |
| 1019 | return JSON.stringify(pack); |
| 1020 | }), { disk: disk0, id: two.a, tag: TAG }); |
| 1021 | |
| 1022 | await becomeDevice(diverged); // device 1: tagged, stamps as they were |
| 1023 | const pD1 = await parcelNow(); |
| 1024 | await becomeDevice(disk0); // device 2: untagged, the same stamps |
| 1025 | const beforeConv = await rowOf(two.a); |
| 1026 | await pullParcel(pD1); |
| 1027 | const conv2 = await rowOf(two.a); |
| 1028 | check('two stores that already diverged converge: the untagged side gains the tag', |
| 1029 | tagsOn(conv2) === TAG, 'tags=[' + tagsOn(conv2) + ']'); |
| 1030 | check('and converging still does not reorder the rail', |
| 1031 | !!conv2 && conv2.updated === beforeConv.updated, |
| 1032 | 'updated ' + beforeConv.updated + ' -> ' + (conv2 && conv2.updated)); |
| 1033 | const pD2 = await parcelNow(); |
| 1034 | await becomeDevice(diverged); // device 1 once more |
| 1035 | await pullParcel(pD2); |
| 1036 | const conv1 = await rowOf(two.a); |
| 1037 | check('and the side that had the tag keeps it when the union comes back', |
| 1038 | tagsOn(conv1) === TAG, 'tags=[' + tagsOn(conv1) + ']'); |
| 1039 | |
| 1040 | // (4) A parcel from a device that predates the second stamp still merges by |
| 1041 | // the only stamp it carries. |
| 1042 | const oldStyle = await page.evaluate(({ p, id }) => { |
| 1043 | const e = (p.diamonds || []).find(d => d.id === id); |
| 1044 | if (!e) return p; |
| 1045 | const pack = JSON.parse(e.data); |
| 1046 | const meta = JSON.parse(pack.files['.daimond/meta.json']); |
| 1047 | meta.name = 'Named-By-An-Old-Build'; |
| 1048 | meta.updated = (e.updated || 0) + 60000; |
| 1049 | delete meta.touched; |
| 1050 | pack.files['.daimond/meta.json'] = JSON.stringify(meta); |
| 1051 | e.data = JSON.stringify(pack); |
| 1052 | e.updated = meta.updated; |
| 1053 | delete e.touched; |
| 1054 | return p; |
| 1055 | }, { p: await parcelNow(), id: two.a }); |
| 1056 | await pullParcel(oldStyle); |
| 1057 | const oldWon = await rowOf(two.a); |
| 1058 | check('a parcel with no second stamp still merges on the one it has', |
| 1059 | !!oldWon && oldWon.name === 'Named-By-An-Old-Build', oldWon ? oldWon.name : 'absent'); |
| 1060 | |
| 1061 | // ── (4g) Links across devices: the same flaw, one file over ─────── |
| 1062 | // A Diamond's links live in a sidecar inside its own directory, so they rode |
| 1063 | // the wholesale copy and nothing else -- and before `touched`, asserting or |
| 1064 | // removing a link stamped nothing at all, exactly as tagging did not. Two |
| 1065 | // stores could therefore hold different links at identical stamps for ever, |
| 1066 | // and the first thing either of them stamped replaced the other's links |
| 1067 | // wholesale. Tags were repaired by unioning them at equal stamps; this is |
| 1068 | // the same repair one file over, where a link's id is what says whether the |
| 1069 | // two copies mean the same link or two different ones. |
| 1070 | const EAST = 'link-east-1', WEST = 'link-west-1'; |
| 1071 | |
| 1072 | /// One stored sidecar line, as a hand or an older build would leave it. |
| 1073 | const linkLine = (id, from, to, rel) => JSON.stringify({ |
| 1074 | id: id, ts: 1700000000000, from: from, to: to, rel: rel, note: '', by: 'user', |
| 1075 | }) + '\n'; |
| 1076 | |
| 1077 | /// Every link one Diamond's own sidecar holds, as `id/rel`, sorted. The id |
| 1078 | /// is in there because a union that re-created the links it took would look |
| 1079 | /// identical by relation alone, and would then duplicate on every round. |
| 1080 | const linksOf = (id) => wasm(async (app, id) => |
| 1081 | JSON.parse(await app.all_links()).filter(l => l.owner === id) |
| 1082 | .map(l => l.id + '/' + l.rel).sort().join(','), id); |
| 1083 | |
| 1084 | /// A disk with one Diamond's sidecar replaced and its stamps left exactly as |
| 1085 | /// they were -- which is what a link written by a build that stamped nothing |
| 1086 | /// looks like from the outside. |
| 1087 | const withSidecar = (disk, id, text) => page.evaluate(({ disk, id, text }) => disk.map((p) => { |
| 1088 | const pack = JSON.parse(p); |
| 1089 | if (pack.id !== id) return p; |
| 1090 | pack.files['.daimond/links.jsonl'] = text; |
| 1091 | return JSON.stringify(pack); |
| 1092 | }), { disk: disk, id: id, text: text }); |
| 1093 | |
| 1094 | const three = await wasm(async (app, crystal) => { |
| 1095 | for (const d of JSON.parse(await app.list_diamonds())) await app.delete_diamond(d.id); |
| 1096 | const a = await app.create_diamond('Link-Travel'); |
| 1097 | const b = await app.create_diamond('Link-East'); |
| 1098 | const c = await app.create_diamond('Link-West'); |
| 1099 | await app.write_crystal_data(a, crystal); |
| 1100 | return { a: a, b: b, c: c }; |
| 1101 | }, crystalOf('shared ground')); |
| 1102 | const diskL = await diskNow(); // what BOTH devices last agreed on |
| 1103 | const BOTH = [EAST + '/east', WEST + '/west'].sort().join(','); |
| 1104 | const dev1 = await withSidecar(diskL, three.a, |
| 1105 | linkLine(EAST, 'diamond:' + three.a, 'diamond:' + three.b, 'east')); |
| 1106 | const dev2 = await withSidecar(diskL, three.a, |
| 1107 | linkLine(WEST, 'diamond:' + three.a, 'diamond:' + three.c, 'west')); |
| 1108 | |
| 1109 | await becomeDevice(dev1); |
| 1110 | const pL1 = await parcelNow(); |
| 1111 | await becomeDevice(dev2); |
| 1112 | const beforeUnion = await rowOf(three.a); |
| 1113 | const linksBefore = await linksOf(three.a); |
| 1114 | check('the link fixture is two stores that disagree at one identical stamp', |
| 1115 | linksBefore === WEST + '/west' |
| 1116 | && entryOf(pL1, three.a).touched === beforeUnion.touched, |
| 1117 | 'here=[' + linksBefore + '] stamps ' + entryOf(pL1, three.a).touched |
| 1118 | + ' / ' + beforeUnion.touched); |
| 1119 | |
| 1120 | await pullParcel(pL1); |
| 1121 | const unioned2 = await linksOf(three.a); |
| 1122 | const rowUnion = await rowOf(three.a); |
| 1123 | check('two stores that already diverged converge: both links, both ids kept', |
| 1124 | unioned2 === BOTH, '[' + unioned2 + ']'); |
| 1125 | check('and unioning links does not reorder the rail either', |
| 1126 | !!rowUnion && rowUnion.updated === beforeUnion.updated, |
| 1127 | 'updated ' + beforeUnion.updated + ' -> ' + (rowUnion && rowUnion.updated)); |
| 1128 | check('writing the union stamps this side, so it can travel back', |
| 1129 | !!rowUnion && rowUnion.touched > beforeUnion.touched, |
| 1130 | 'touched ' + beforeUnion.touched + ' -> ' + (rowUnion && rowUnion.touched)); |
| 1131 | |
| 1132 | const pL2 = await parcelNow(); // device 2, unioned and stamped |
| 1133 | const diskBoth = await diskNow(); |
| 1134 | await becomeDevice(dev1); // device 1 as it was: one link |
| 1135 | await pullParcel(pL2); |
| 1136 | const unioned1 = await linksOf(three.a); |
| 1137 | const rowBack = await rowOf(three.a); |
| 1138 | check('the union travels back, and the fresher side is taken wholesale', |
| 1139 | unioned1 === BOTH, '[' + unioned1 + ']'); |
| 1140 | check('the import lays that stamp down verbatim, so the union cannot bounce', |
| 1141 | !!rowBack && rowBack.touched === entryOf(pL2, three.a).touched, |
| 1142 | 'here=' + (rowBack && rowBack.touched) + ' sent=' + entryOf(pL2, three.a).touched); |
| 1143 | |
| 1144 | // Round three: the two sides now hold the same links at the same stamp, and |
| 1145 | // a union with nothing to add must write nothing at all -- otherwise each |
| 1146 | // side would stamp itself fresher than the other for ever. |
| 1147 | const pL3 = await parcelNow(); |
| 1148 | await becomeDevice(diskBoth); |
| 1149 | const quietBefore = await rowOf(three.a); |
| 1150 | await pullParcel(pL3); |
| 1151 | const quietAfter = await rowOf(three.a); |
| 1152 | check('once the two agree, the union writes nothing and moves no stamp', |
| 1153 | (await linksOf(three.a)) === BOTH |
| 1154 | && !!quietAfter && quietAfter.touched === quietBefore.touched, |
| 1155 | '[' + (await linksOf(three.a)) + '] touched ' + quietBefore.touched |
| 1156 | + ' -> ' + (quietAfter && quietAfter.touched)); |
| 1157 | |
| 1158 | // A link removed since the fix DOES stamp the Diamond, so the removal is |
| 1159 | // strictly fresher and is taken wholesale. The union only ever sees copies |
| 1160 | // that are equally fresh, so it cannot be what puts a deleted link back. |
| 1161 | await becomeDevice(diskBoth); |
| 1162 | await wasm((app, arg) => app.remove_link(arg.id, arg.link), { id: three.a, link: EAST }); |
| 1163 | const rowDel = await rowOf(three.a); |
| 1164 | const pDel = await parcelNow(); |
| 1165 | const diskDel = await diskNow(); |
| 1166 | check('removing a link stamps the Diamond, so the removal can travel at all', |
| 1167 | (await linksOf(three.a)) === WEST + '/west' |
| 1168 | && !!rowDel && rowDel.touched > quietBefore.touched, |
| 1169 | '[' + (await linksOf(three.a)) + '] touched ' + quietBefore.touched |
| 1170 | + ' -> ' + (rowDel && rowDel.touched)); |
| 1171 | |
| 1172 | await becomeDevice(diskBoth); // the other device: still holds both |
| 1173 | const pStale = await parcelNow(); // and would hand the deleted link back |
| 1174 | await pullParcel(pDel); |
| 1175 | const pDel2 = await parcelNow(); // that device, having taken the deletion |
| 1176 | check('a link deleted on another device goes here too', |
| 1177 | (await linksOf(three.a)) === WEST + '/west', '[' + (await linksOf(three.a)) + ']'); |
| 1178 | |
| 1179 | await becomeDevice(diskDel); // the device that did the deleting |
| 1180 | const staleRow = await rowOf(three.a); |
| 1181 | await pullParcel(pStale); |
| 1182 | // The outcome AND the mechanism: the copy that still holds the link is |
| 1183 | // strictly older, so it never reaches the union at all. That is the whole of |
| 1184 | // why unioning links cannot undo a deletion made since the stamp existed. |
| 1185 | check('and a stale copy that still holds it does not put it back', |
| 1186 | (await linksOf(three.a)) === WEST + '/west' |
| 1187 | && entryOf(pStale, three.a).touched < staleRow.touched, |
| 1188 | '[' + (await linksOf(three.a)) + '] stale ' + entryOf(pStale, three.a).touched |
| 1189 | + ' < here ' + staleRow.touched); |
| 1190 | const beforeAgreed = await rowOf(three.a); |
| 1191 | await pullParcel(pDel2); |
| 1192 | const afterAgreed = await rowOf(three.a); |
| 1193 | check('nor does an equal-stamped copy that has already taken the deletion', |
| 1194 | (await linksOf(three.a)) === WEST + '/west' |
| 1195 | && !!afterAgreed && afterAgreed.touched === beforeAgreed.touched, |
| 1196 | '[' + (await linksOf(three.a)) + '] touched ' + beforeAgreed.touched |
| 1197 | + ' -> ' + (afterAgreed && afterAgreed.touched)); |
| 1198 | |
| 1199 | // Every merge above left the store changed, so the engine has a push |
| 1200 | // scheduled. Let it land: a push still in flight when the next section stubs |
| 1201 | // the gateway is only rescheduled, and that section would then measure a chip |
| 1202 | // that says "Syncing…" for a reason that has nothing to do with it. |
| 1203 | await pushLanded(page, 'the merges of (4f)/(4g)'); |
| 1204 | await page.waitForTimeout(500); |
| 1205 | |
| 1206 | // ── (6) A parcel the gateway refuses as too large is VISIBLE ─────── |
| 1207 | // A 413 used to log to the console and stop, so sync simply stopped working |
| 1208 | // and nothing on the screen said so. The refusal is stubbed rather than |
| 1209 | // provoked: the real ceiling is 32 MB, and building that in the browser to |
| 1210 | // test a status chip would cost more than the behaviour it proves. |
| 1211 | const tooBig = await page.evaluate(async () => { |
| 1212 | const chip = () => { |
| 1213 | const c = document.getElementById('sync-chip'); |
| 1214 | if (!c) return null; |
| 1215 | return { |
| 1216 | state: c.dataset.state || '', |
| 1217 | text: (c.querySelector('.stext') || {}).textContent || '', |
| 1218 | title: c.title || '', |
| 1219 | shown: c.style.display !== 'none', |
| 1220 | }; |
| 1221 | }; |
| 1222 | // A genuine local change, or the push is skipped as a no-op and nothing |
| 1223 | // reaches the (stubbed) gateway at all. Made BEFORE the stub goes on, so |
| 1224 | // nothing the change does can be answered by it. |
| 1225 | const changed = await window.__bump('too-big-1'); |
| 1226 | const real = window.fetch; |
| 1227 | // Counted, so "the refusal was answered" is a measurement rather than an |
| 1228 | // assumption: the push below is driven until a parcel really leaves. |
| 1229 | let sent = 0; |
| 1230 | window.fetch = function (u, o) { |
| 1231 | const url = String((u && u.url) || u || ''); |
| 1232 | if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') { |
| 1233 | sent++; |
| 1234 | return Promise.resolve(new Response( |
| 1235 | JSON.stringify({ ok: false, error: 'Sync blob exceeds the size limit' }), |
| 1236 | { status: 413, headers: { 'content-type': 'application/json' } })); |
| 1237 | } |
| 1238 | return real.apply(this, arguments); |
| 1239 | }; |
| 1240 | const refused = await window.__pushSent(() => sent, 'too-big-1'); |
| 1241 | const stalled = chip(); |
| 1242 | const api = window.DaimondSync.state ? window.DaimondSync.state() : null; |
| 1243 | window.fetch = real; |
| 1244 | // And a later successful push clears it — a stall that outlives its cause |
| 1245 | // is the same lie the other way round. |
| 1246 | // |
| 1247 | // Driven until the engine says the stall is gone, for the third time in |
| 1248 | // this file: a bare push() here found a round in flight, rescheduled, and |
| 1249 | // the chip was read still holding the 413 it had never been given a |
| 1250 | // chance to clear. Waiting on the STALL, not on a timer, so a stall that |
| 1251 | // genuinely never clears still fails. |
| 1252 | const cleared = await window.__bump('too-big-2'); |
| 1253 | const tClear = Date.now(); |
| 1254 | while (window.DaimondSync.state().stalled && Date.now() - tClear < 15000) { |
| 1255 | await window.DaimondSync.push(); |
| 1256 | if (window.DaimondSync.state().stalled) await new Promise(r => setTimeout(r, 250)); |
| 1257 | } |
| 1258 | const after = chip(); |
| 1259 | return { changed, cleared, stalled, api, after, refused }; |
| 1260 | }); |
| 1261 | check('both local changes moved the parcel, so the 413 arm was reached at all', |
| 1262 | tooBig.changed.moved === true && tooBig.cleared.moved === true, |
| 1263 | [tooBig.changed.why, tooBig.cleared.why].filter(Boolean).join('; ')); |
| 1264 | check('and a parcel really reached the refusal', tooBig.refused.sent === true, |
| 1265 | JSON.stringify(tooBig.refused)); |
| 1266 | check('a 413 push shows a stalled state on the sync chip, held (not a flash)', |
| 1267 | !!(tooBig.stalled && tooBig.stalled.shown && tooBig.stalled.state === 'stalled'), |
| 1268 | JSON.stringify(tooBig.stalled)); |
| 1269 | check('and says on hover that the parcel is too large, naming what makes it large', |
| 1270 | !!(tooBig.stalled && /too large/i.test(tooBig.stalled.title) |
| 1271 | && /(Diamond|file)/i.test(tooBig.stalled.title)), |
| 1272 | (tooBig.stalled && tooBig.stalled.title || '').slice(0, 120)); |
| 1273 | check('the engine reports the stall through its own surface too', |
| 1274 | !!(tooBig.api && tooBig.api.stalled === true), JSON.stringify(tooBig.api)); |
| 1275 | check('a later successful push clears the stall', |
| 1276 | !!(tooBig.after && tooBig.after.state === 'synced'), JSON.stringify(tooBig.after)); |
| 1277 | const lastLine = await page.evaluate(() => { |
| 1278 | const c = document.getElementById('sync-chip'); |
| 1279 | return { title: c ? c.title : '', at: (window.DaimondSync.state ? DaimondSync.state().lastSyncedAt : 0) }; |
| 1280 | }); |
| 1281 | check('a successful sync records when it happened, and the chip can say so', |
| 1282 | lastLine.at > 0 && /synced/i.test(lastLine.title), |
| 1283 | JSON.stringify(lastLine).slice(0, 120)); |
| 1284 | |
| 1285 | // ── (7) A parked tab converges on focus, without a reload ────────── |
| 1286 | // Sync fired on idle, on the tab going away, and on auth. A device left open |
| 1287 | // on the desk therefore never caught up until it was reloaded. Coming back to |
| 1288 | // a window is exactly the moment its owner expects to see the other device's |
| 1289 | // work. |
| 1290 | // |
| 1291 | // Measured with the wake channel SHUT. The gateway taps every other device of |
| 1292 | // the account when the mailbox moves, so with the channel open this device |
| 1293 | // converges on its own and the pull it makes is counted here as a focus pull: |
| 1294 | // the storm reads as noise and the trigger under test is never the thing that |
| 1295 | // answered. Section 13 turns the channel back on and tests it in its own |
| 1296 | // right, which is where that behaviour belongs. |
| 1297 | await page.evaluate(() => window.DaimondSync.wakeVia('off')); |
| 1298 | // And from a quiet engine: a push still armed when the focus fires holds |
| 1299 | // `inFlight`, and `focusPull` stands aside for a round already under way. |
| 1300 | // Longer than the push debounce, so anything armed has fired and finished. |
| 1301 | await page.waitForTimeout(4000); |
| 1302 | const focus = await page.evaluate(async () => { |
| 1303 | const mark = 'FOCUSMARK-' + Date.now(); |
| 1304 | const state = await window.DaimondCore.collectSync(); |
| 1305 | state.chats = (state.chats || []).concat([{ |
| 1306 | id: 'focus-' + Date.now(), title: mark, updatedAt: Date.now(), |
| 1307 | messages: [{ role: 'user', content: mark, mid: 'fm-' + Date.now(), ts: Date.now() }], |
| 1308 | }]); |
| 1309 | const blob = await window.DaimondIdentity.wrap(JSON.stringify(state)); |
| 1310 | const r = await fetch('/api/sync', { |
| 1311 | method: 'POST', credentials: 'same-origin', |
| 1312 | headers: { 'content-type': 'application/json', 'x-daimond-api': '1' }, |
| 1313 | body: JSON.stringify({ base_version: window.DaimondSync.version(), device: 'other', blob: blob }), |
| 1314 | }); |
| 1315 | const posted = r.status; |
| 1316 | // What the chat store holds, not what localStorage holds: the transcripts |
| 1317 | // are in IndexedDB, so the old read saw an empty string and "this device |
| 1318 | // did not already hold it" passed for a device that held nothing at all. |
| 1319 | const holds = () => JSON.stringify(window.DaimondCore.chatStore().stored()).indexOf(mark) !== -1; |
| 1320 | const before = holds(); |
| 1321 | // Count the pulls, so a focus STORM is proved to coalesce into one. Every |
| 1322 | // pull is a bare `GET /api/sync`; the presence path (`?presence=1`) is a |
| 1323 | // different door and is not one of these, but two content pulls still are, |
| 1324 | // so the burst below is FOCUS EVENTS ONLY. |
| 1325 | // |
| 1326 | // A `visibilitychange` is deliberately NOT dispatched into the count. Since |
| 1327 | // devices began listening for dispatched errands (daimond.js |
| 1328 | // `peerCollectOnReturn`, wired to `visibilitychange`), returning to a visible |
| 1329 | // tab fires a SECOND, separate content pull — the peer-return recovery pull — |
| 1330 | // alongside the focus one. That pull is intended and bounded (one per return, |
| 1331 | // guarded by `_recovering`); it is simply not the focus-coalescing property |
| 1332 | // this check measures, and counting it here made a green throttle read as two |
| 1333 | // pulls. The visibility path shares the very same `scheduleFocusPull` throttle |
| 1334 | // as focus (js/sync.js), so five focus events exercise the coalescing in full: |
| 1335 | // a broken throttle pulls five times here, which is exactly what must go red. |
| 1336 | const real = window.fetch; |
| 1337 | let gets = 0; |
| 1338 | window.fetch = function (u, o) { |
| 1339 | const url = String((u && u.url) || u || ''); |
| 1340 | if (url.indexOf('/api/sync') !== -1 && url.indexOf('presence') === -1 |
| 1341 | && (!o || !o.method || o.method === 'GET')) gets++; |
| 1342 | return real.apply(this, arguments); |
| 1343 | }; |
| 1344 | for (let i = 0; i < 5; i++) window.dispatchEvent(new Event('focus')); |
| 1345 | await new Promise(res => setTimeout(res, 900)); |
| 1346 | const storm = gets; |
| 1347 | // And a focus arriving straight back is refused by the throttle. STRAIGHT |
| 1348 | // BACK means straight back: the throttle is three seconds rather than thirty |
| 1349 | // since 2026-08-28 (see FOCUS_PULL_MIN_MS in js/sync.js, and why it was |
| 1350 | // lowered), so this probe used to sit two and a half seconds inside a |
| 1351 | // thirty-second window and now sits one second inside a three-second one. |
| 1352 | // A probe calibrated to the old figure would be measuring the throttle |
| 1353 | // expiring rather than the throttle holding, and would pass or fail on how |
| 1354 | // busy the box was. |
| 1355 | window.dispatchEvent(new Event('focus')); |
| 1356 | await new Promise(res => setTimeout(res, 900)); |
| 1357 | const again = gets; |
| 1358 | // Only now let the storm's own pull finish landing: what it FETCHED is the |
| 1359 | // question above, and what it MERGED is the question here. |
| 1360 | await new Promise(res => setTimeout(res, 2500)); |
| 1361 | const after = holds(); |
| 1362 | window.fetch = real; |
| 1363 | return { posted, before, after, storm, again }; |
| 1364 | }); |
| 1365 | await page.evaluate(() => window.DaimondSync.wakeVia('ws')); |
| 1366 | check('the other device’s push landed on the mailbox', focus.posted === 200, 'HTTP ' + focus.posted); |
| 1367 | check('this device did not already hold it', focus.before === false); |
| 1368 | check('focus pulls the other device’s work in, with no reload', focus.after === true); |
| 1369 | check('a focus storm coalesces into ONE pull', focus.storm === 1, 'pulls=' + focus.storm); |
| 1370 | check('a focus straight afterwards is throttled, not another pull', |
| 1371 | focus.again === focus.storm, 'pulls=' + focus.again); |
| 1372 | |
| 1373 | // ── (8) Models and API keys travel with the work ─────────────────── |
| 1374 | // A second device that holds the account holds the same identity, so a key |
| 1375 | // sealed under it opens on both. That is what makes carrying keys safe, and |
| 1376 | // it is the only reason this is allowed at all: what travels is `keyEnc`, |
| 1377 | // never a readable key. |
| 1378 | const M = await page.evaluate(async () => { |
| 1379 | const S = window.DaimondModels; |
| 1380 | const r = { api: typeof S.exportSync === 'function' && typeof S.applySync === 'function' }; |
| 1381 | if (!r.api) return r; |
| 1382 | const now = Date.now(); |
| 1383 | const PLAIN = 'LOCAL-KEY-' + '4242'; |
| 1384 | const def0 = S.getDefault(); // put the app's own default back afterwards |
| 1385 | |
| 1386 | S.addProvider('groq', { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions' }); |
| 1387 | await S.setKey('groq', PLAIN); |
| 1388 | |
| 1389 | const e1 = JSON.stringify(S.exportSync()); |
| 1390 | const e2 = JSON.stringify(S.exportSync()); |
| 1391 | r.deterministic = e1 === e2; |
| 1392 | r.carriesKeyEnc = !!(S.exportSync().providers.groq || {}).keyEnc; |
| 1393 | r.plaintextAbsent = e1.indexOf(PLAIN) === -1; |
| 1394 | r.noMintedRow = !Object.prototype.hasOwnProperty.call(S.exportSync().providers, 'credits'); |
| 1395 | |
| 1396 | // A provider only the other device has arrives; one only this device has |
| 1397 | // survives. (The union is what makes a second device usable at all.) |
| 1398 | await S.applySync({ |
| 1399 | v: 2, def: { provider: '', model: '' }, defAt: 0, |
| 1400 | providers: { together: { |
| 1401 | name: 'Together AI', url: 'https://api.together.xyz/v1/chat/completions', |
| 1402 | models: ['m-b', 'm-a'], fetched: now, touched: now, keyEnc: 'REMOTE-SEALED', |
| 1403 | } }, |
| 1404 | }); |
| 1405 | let ex = S.exportSync(); |
| 1406 | r.remoteArrived = !!ex.providers.together && ex.providers.together.keyEnc === 'REMOTE-SEALED'; |
| 1407 | r.localSurvived = !!ex.providers.groq; |
| 1408 | r.modelsSorted = !!ex.providers.together |
| 1409 | && ex.providers.together.models.join(',') === 'm-a,m-b'; |
| 1410 | |
| 1411 | // An OLDER remote copy must not clobber a newer local one. |
| 1412 | const stamp = ex.providers.groq.touched; |
| 1413 | await S.applySync({ v: 2, providers: { groq: { |
| 1414 | name: 'Stale', url: 'https://stale.example/v1/chat/completions', |
| 1415 | models: [], fetched: 0, touched: stamp - 60000, keyEnc: 'STALE-SEALED', |
| 1416 | } } }); |
| 1417 | let g = S.exportSync().providers.groq; |
| 1418 | r.olderIgnored = g.keyEnc !== 'STALE-SEALED' && g.url.indexOf('groq.com') !== -1; |
| 1419 | |
| 1420 | // A fresher one wins, wholesale. |
| 1421 | await S.applySync({ v: 2, providers: { groq: { |
| 1422 | name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', |
| 1423 | models: ['fresh-1'], fetched: now + 60000, touched: stamp + 60000, keyEnc: 'FRESH-SEALED', |
| 1424 | } } }); |
| 1425 | g = S.exportSync().providers.groq; |
| 1426 | r.newerWon = g.keyEnc === 'FRESH-SEALED' && g.models.join(',') === 'fresh-1'; |
| 1427 | |
| 1428 | // The live session keeps the key it is running on: a merge must not lock |
| 1429 | // a working device out mid-turn. |
| 1430 | r.sessionKeyKept = S.keyFor('groq') === PLAIN; |
| 1431 | |
| 1432 | // The default follows the freshest side, and only to somewhere real. |
| 1433 | S.setDefault('groq', 'fresh-1'); |
| 1434 | const defAt = S.exportSync().defAt; |
| 1435 | await S.applySync({ v: 2, def: { provider: 'nowhere', model: 'x' }, defAt: defAt + 60000, providers: {} }); |
| 1436 | r.defGuarded = S.getDefault().provider === 'groq'; |
| 1437 | await S.applySync({ v: 2, def: { provider: 'together', model: 'm-a' }, defAt: defAt + 120000, providers: {} }); |
| 1438 | r.defFreshest = S.getDefault().provider === 'together' && S.getDefault().model === 'm-a'; |
| 1439 | await S.applySync({ v: 2, def: { provider: 'groq', model: 'fresh-1' }, defAt: 1, providers: {} }); |
| 1440 | r.defOlderIgnored = S.getDefault().provider === 'together'; |
| 1441 | |
| 1442 | // Enumeration order must not reach the wire: the push-skip comparison is a |
| 1443 | // string compare, so a store that enumerates differently would push for ever. |
| 1444 | S.addProvider('zzz-probe', { name: 'Z', url: 'https://z.example/v1/chat/completions' }); |
| 1445 | S.addProvider('aaa-probe', { name: 'A', url: 'https://a.example/v1/chat/completions' }); |
| 1446 | const ks = Object.keys(S.exportSync().providers); |
| 1447 | r.sortedKeys = JSON.stringify(ks) === JSON.stringify(ks.slice().sort()); |
| 1448 | |
| 1449 | // An old parcel carries no models section at all, and must still apply. |
| 1450 | let threw = ''; |
| 1451 | try { |
| 1452 | await S.applySync(undefined); |
| 1453 | await S.applySync({}); |
| 1454 | await S.applySync({ v: 1 }); |
| 1455 | } catch (e) { threw = String((e && e.message) || e); } |
| 1456 | r.oldParcelNoop = threw === '' && !!S.exportSync().providers.groq; |
| 1457 | |
| 1458 | ['groq', 'together', 'zzz-probe', 'aaa-probe'].forEach(function (id) { S.removeProvider(id); }); |
| 1459 | S.setDefault(def0.provider, def0.model); |
| 1460 | return r; |
| 1461 | }); |
| 1462 | check('models.js offers exportSync/applySync for the parcel', M.api === true); |
| 1463 | check('two exports of one store are byte-identical', M.deterministic === true); |
| 1464 | check('the export lists providers in sorted order (enumeration never reaches the wire)', |
| 1465 | M.sortedKeys === true); |
| 1466 | check('a model list travels sorted, for the same reason', M.modelsSorted === true); |
| 1467 | check('a sealed key travels', M.carriesKeyEnc === true); |
| 1468 | check('a readable key never does', M.plaintextAbsent === true); |
| 1469 | check('the minted credits row does not travel (it is minted per device)', |
| 1470 | M.noMintedRow === true); |
| 1471 | check('a provider only the other device has arrives, key and all', M.remoteArrived === true); |
| 1472 | check('a provider only this device has survives the merge', M.localSurvived === true); |
| 1473 | check('an older remote provider does not clobber a newer local one', M.olderIgnored === true); |
| 1474 | check('a fresher remote provider replaces the local one', M.newerWon === true); |
| 1475 | check('the running session keeps the key it holds', M.sessionKeyKept === true); |
| 1476 | check('the default follows the freshest side', M.defFreshest === true); |
| 1477 | check('an older default is ignored', M.defOlderIgnored === true); |
| 1478 | check('a default naming a provider nobody has is refused', M.defGuarded === true); |
| 1479 | check('a parcel with no models section applies as a no-op', M.oldParcelNoop === true); |
| 1480 | |
| 1481 | // The plaintext-at-rest key exists only where there is no identity to seal |
| 1482 | // under — and sync only runs WITH one. It is still checked, because the store |
| 1483 | // on disk may predate the identity and the export must not carry it out. |
| 1484 | const atRest = await page.evaluate(() => { |
| 1485 | const S = window.DaimondModels; |
| 1486 | if (typeof S.exportSync !== 'function') return { absent: false, noField: false }; |
| 1487 | const raw = localStorage.getItem('daimond-models-v2'); |
| 1488 | localStorage.setItem('daimond-models-v2', JSON.stringify({ |
| 1489 | v: 2, def: { provider: 'plainprov', model: 'm' }, |
| 1490 | providers: { plainprov: { |
| 1491 | name: 'Plain', url: 'https://plain.example/v1/chat/completions', |
| 1492 | key: 'AT-REST-PLAIN-9876', keyEnc: '', models: ['m'], fetched: 1, touched: 1, |
| 1493 | } }, |
| 1494 | })); |
| 1495 | S.init({}); |
| 1496 | const ex = S.exportSync(); |
| 1497 | const out = { |
| 1498 | absent: JSON.stringify(ex).indexOf('AT-REST-PLAIN-9876') === -1, |
| 1499 | noField: !Object.prototype.hasOwnProperty.call(ex.providers.plainprov || {}, 'key'), |
| 1500 | }; |
| 1501 | if (raw === null) localStorage.removeItem('daimond-models-v2'); |
| 1502 | else localStorage.setItem('daimond-models-v2', raw); |
| 1503 | S.init({}); |
| 1504 | return out; |
| 1505 | }); |
| 1506 | check('a plaintext-at-rest key is left behind by the export', |
| 1507 | atRest.absent === true && atRest.noField === true, JSON.stringify(atRest)); |
| 1508 | |
| 1509 | // ── (9) A provider deleted here stays deleted ────────────────────── |
| 1510 | // The provider merge is a UNION, so a row removed on this device and still |
| 1511 | // held on the other one was handed straight back on the next pull -- key and |
| 1512 | // all -- and removing it was something the user could not make stick. An |
| 1513 | // absence still means "that device never had it"; a TOMBSTONE means "it is |
| 1514 | // gone", and the stamps decide between a deletion and a re-add. |
| 1515 | const T = await page.evaluate(async () => { |
| 1516 | const S = window.DaimondModels; |
| 1517 | const r = {}; |
| 1518 | const now = Date.now(); |
| 1519 | S.addProvider('tombprov', { name: 'Tombed', url: 'https://tomb.example/v1/chat/completions' }); |
| 1520 | const born = S.exportSync().providers.tombprov.touched; |
| 1521 | S.removeProvider('tombprov'); |
| 1522 | let ex = S.exportSync(); |
| 1523 | r.gone = !ex.providers.tombprov; |
| 1524 | r.tombed = !!(ex.tombs && ex.tombs.tombprov && ex.tombs.tombprov >= born); |
| 1525 | // The other device still has it, and offers it back. |
| 1526 | await S.applySync({ v: 2, providers: { tombprov: { |
| 1527 | name: 'Tombed', url: 'https://tomb.example/v1/chat/completions', |
| 1528 | models: [], fetched: 0, touched: born, keyEnc: 'RESURRECTED', |
| 1529 | } } }); |
| 1530 | r.stayedGone = !S.exportSync().providers.tombprov; |
| 1531 | // A deletion made on the OTHER device reaches this one: the tombstone |
| 1532 | // arrives without the row, and the row here goes. |
| 1533 | S.addProvider('otherdev', { name: 'Other', url: 'https://other.example/v1/chat/completions' }); |
| 1534 | await S.applySync({ v: 2, providers: {}, tombs: { otherdev: Date.now() + 1000 } }); |
| 1535 | r.remoteDeleteHonoured = !S.exportSync().providers.otherdev; |
| 1536 | // A re-add AFTER the deletion wins by its stamp -- deleting a provider must |
| 1537 | // not make its id unusable for a week. |
| 1538 | S.addProvider('tombprov', { name: 'Back', url: 'https://back.example/v1/chat/completions' }); |
| 1539 | r.readdSurvives = !!S.exportSync().providers.tombprov; |
| 1540 | await S.applySync({ v: 2, providers: {}, tombs: { tombprov: now - 1000 } }); |
| 1541 | r.readdSurvivesMerge = !!S.exportSync().providers.tombprov; |
| 1542 | S.removeProvider('tombprov'); |
| 1543 | return r; |
| 1544 | }); |
| 1545 | check('removing a provider takes it out of the store', T.gone === true); |
| 1546 | check('and leaves a tombstone in the parcel', T.tombed === true); |
| 1547 | check('a deleted provider handed back by the other device does not come back', |
| 1548 | T.stayedGone === true); |
| 1549 | check('a provider deleted on the other device is deleted here', |
| 1550 | T.remoteDeleteHonoured === true); |
| 1551 | check('a provider re-added after its deletion survives', T.readdSurvives === true); |
| 1552 | check('and survives a merge carrying the older tombstone', T.readdSurvivesMerge === true); |
| 1553 | |
| 1554 | // ── (10) The two standing refusals, on one chip, in one order ────── |
| 1555 | // 402 (not on the tier) and 413 (parcel too large) both outlive the round |
| 1556 | // that found them, and both are reported on the chip alone. So neither may be |
| 1557 | // painted over by an ordinary round -- a pull SUCCEEDING used to show |
| 1558 | // "Synced" on a device whose pushes were paused by a 402, and a pull FAILING |
| 1559 | // used to blank a refusal that was still perfectly true. And the chip has to |
| 1560 | // lead somewhere: "Sync off" names Pro, and Pro is bought in Credits. |
| 1561 | const gate = await page.evaluate(async () => { |
| 1562 | const chip = () => { |
| 1563 | const c = document.getElementById('sync-chip'); |
| 1564 | if (!c) return null; |
| 1565 | return { state: c.dataset.state || '', title: c.title || '', |
| 1566 | text: (c.querySelector('.stext') || {}).textContent || '', |
| 1567 | shown: c.style.display !== 'none' }; |
| 1568 | }; |
| 1569 | // Every refusal below is only reached if a parcel is actually sent, so each |
| 1570 | // change records whether it moved the parcel and the file asserts on it. |
| 1571 | const bumps = []; |
| 1572 | const bump = async (tag) => { |
| 1573 | const r = await window.__bump(tag); |
| 1574 | bumps.push(tag + ': ' + (r.moved ? 'moved' : r.why)); |
| 1575 | return r.moved; |
| 1576 | }; |
| 1577 | const real = window.fetch; |
| 1578 | // Refusals ANSWERED, not merely offered. A push that finds a round in |
| 1579 | // flight reschedules and returns, so a single call could leave every |
| 1580 | // assertion below reporting on a stub nothing ever reached. |
| 1581 | let sent = 0; |
| 1582 | const stub = (post, get) => { |
| 1583 | window.fetch = function (u, o) { |
| 1584 | const url = String((u && u.url) || u || ''); |
| 1585 | const isSync = url.indexOf('/api/sync') !== -1; |
| 1586 | const method = (o && o.method) || 'GET'; |
| 1587 | const code = isSync ? (method === 'POST' ? post : get) : 0; |
| 1588 | if (code) { |
| 1589 | if (method === 'POST') sent++; |
| 1590 | return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'stub' }), |
| 1591 | { status: code, headers: { 'content-type': 'application/json' } })); |
| 1592 | } |
| 1593 | return real.apply(this, arguments); |
| 1594 | }; |
| 1595 | }; |
| 1596 | const out = {}; |
| 1597 | // A 413 first: the parcel is too large, and the chip stalls. |
| 1598 | stub(413, 0); |
| 1599 | await bump('gate-1'); |
| 1600 | out.sent413 = await window.__pushSent(() => sent, 'gate-1'); |
| 1601 | out.stalled = chip(); |
| 1602 | // Then a 402 on top of it. Not entitled outranks too large: an account |
| 1603 | // that may not sync at all cannot act on a parcel being oversized. |
| 1604 | stub(402, 0); |
| 1605 | await bump('gate-2'); |
| 1606 | sent = 0; |
| 1607 | out.sent402 = await window.__pushSent(() => sent, 'gate-2'); |
| 1608 | out.off = chip(); |
| 1609 | // A pull that WORKS must not paint "Synced" over it. |
| 1610 | window.fetch = real; |
| 1611 | await window.DaimondSync.pull(); |
| 1612 | out.afterGoodPull = chip(); |
| 1613 | // Nor may a pull that fails simply hide it. |
| 1614 | stub(0, 500); |
| 1615 | await window.DaimondSync.pull(); |
| 1616 | out.afterBadPull = chip(); |
| 1617 | window.fetch = real; |
| 1618 | // Clicking it goes where the sentence leads. |
| 1619 | document.getElementById('sync-chip').click(); |
| 1620 | await new Promise(r => setTimeout(r, 400)); |
| 1621 | const cv = document.getElementById('admin-credits'); |
| 1622 | out.creditsOpen = !!(cv && cv.style.display !== 'none' && cv.offsetParent !== null); |
| 1623 | out.creditsNote = (document.getElementById('credits-note') || {}).textContent || ''; |
| 1624 | if (window.DaimondAdmin && DaimondAdmin.close) DaimondAdmin.close(); |
| 1625 | // The tier comes back: the stall underneath it is still true and shows again. |
| 1626 | window.DaimondSync.recheck(); |
| 1627 | await new Promise(r => setTimeout(r, 1200)); |
| 1628 | out.afterRecheck = chip(); |
| 1629 | // And a push that fits clears the lot, so nothing below runs under a stall. |
| 1630 | await bump('gate-3'); |
| 1631 | await window.DaimondSync.push(); |
| 1632 | await new Promise(r => setTimeout(r, 400)); |
| 1633 | out.cleared = chip(); |
| 1634 | out.api = window.DaimondSync.state(); |
| 1635 | out.bumps = bumps; |
| 1636 | return out; |
| 1637 | }); |
| 1638 | check('each refusal below was answered to a parcel that really left', |
| 1639 | allMoved(gate.bumps), (gate.bumps || []).join(' | ')); |
| 1640 | check('and both refusals were reached by a parcel that was actually SENT', |
| 1641 | gate.sent413.sent === true && gate.sent402.sent === true, |
| 1642 | '413 ' + JSON.stringify(gate.sent413) + ', 402 ' + JSON.stringify(gate.sent402)); |
| 1643 | check('a 413 stalls the chip', gate.stalled && gate.stalled.state === 'stalled', |
| 1644 | JSON.stringify(gate.stalled)); |
| 1645 | check('a 402 on top of a stall shows "Sync off" — not entitled outranks too large', |
| 1646 | !!(gate.off && gate.off.state === 'off' && gate.off.shown), JSON.stringify(gate.off)); |
| 1647 | check('and says on hover that it is Pro, and that the chip can be clicked', |
| 1648 | !!(gate.off && /Pro/.test(gate.off.title) && /Credits/i.test(gate.off.title)), |
| 1649 | (gate.off && gate.off.title || '').replace(/\n/g, ' | ').slice(0, 140)); |
| 1650 | check('a pull that WORKS does not paint "Synced" over a device whose pushes are off', |
| 1651 | !!(gate.afterGoodPull && gate.afterGoodPull.state === 'off'), |
| 1652 | JSON.stringify(gate.afterGoodPull)); |
| 1653 | check('a pull that FAILS does not hide the refusal either', |
| 1654 | !!(gate.afterBadPull && gate.afterBadPull.state === 'off' && gate.afterBadPull.shown), |
| 1655 | JSON.stringify(gate.afterBadPull)); |
| 1656 | check('clicking the off chip opens the Pro offer in Credits', |
| 1657 | gate.creditsOpen === true && gate.creditsNote.length > 0, |
| 1658 | 'open=' + gate.creditsOpen + ' note=' + JSON.stringify(gate.creditsNote.slice(0, 60))); |
| 1659 | check('when the tier comes back the stall underneath is still reported', |
| 1660 | !!(gate.afterRecheck && gate.afterRecheck.state === 'stalled'), |
| 1661 | JSON.stringify(gate.afterRecheck)); |
| 1662 | check('and a push that fits clears both', |
| 1663 | !!(gate.cleared && gate.cleared.state === 'synced') |
| 1664 | && gate.api.stalled === false && gate.api.entitled === true, |
| 1665 | JSON.stringify(gate.cleared) + ' ' + JSON.stringify(gate.api)); |
| 1666 | |
| 1667 | // ── (11) Work done OUTSIDE a turn travels on its own ─────────────── |
| 1668 | // Pushes fired on exactly two things: a turn ending, and the tab going away. |
| 1669 | // Most of what a person does to a Diamond is neither. A user renamed a |
| 1670 | // Diamond on one machine, left the tab open and focused, and the new name |
| 1671 | // never reached the other machine — because nothing ever scheduled the push. |
| 1672 | // The other device's focus pull was working perfectly; the mailbox simply |
| 1673 | // still held the old name. |
| 1674 | // |
| 1675 | // Measured from a QUIET engine. A push that finds one in flight reschedules, |
| 1676 | // so `pushLanded` leaves a timer armed, and that stray timer would carry the |
| 1677 | // rename and hide the whole bug — which is exactly what it did the first time |
| 1678 | // this was written. |
| 1679 | |
| 1680 | /// Let anything already armed drain, and return once the version has stopped |
| 1681 | /// moving. A flat sleep is not enough: what has to be true is that the engine |
| 1682 | /// is quiet, not that some number of milliseconds passed. |
| 1683 | const quiesce = async (pg, ms = 20000) => { |
| 1684 | let last = -1, stable = Date.now(); |
| 1685 | const t0 = Date.now(); |
| 1686 | while (Date.now() - t0 < ms) { |
| 1687 | const v = await pg.evaluate(() => window.DaimondSync.state().version); |
| 1688 | if (v !== last) { last = v; stable = Date.now(); } |
| 1689 | else if (Date.now() - stable > 5000) return last; |
| 1690 | await pg.waitForTimeout(300); |
| 1691 | } |
| 1692 | return last; |
| 1693 | }; |
| 1694 | |
| 1695 | /// Wait for the engine to push on its OWN. Deliberately never calls push(): |
| 1696 | /// the point of the whole section is that the change leaves without asking. |
| 1697 | const ownPush = async (pg, v0, ms = 25000) => { |
| 1698 | const t0 = Date.now(); |
| 1699 | while (Date.now() - t0 < ms) { |
| 1700 | const v = await pg.evaluate(() => window.DaimondSync.state().version); |
| 1701 | if (v > v0) return { landed: true, took: Date.now() - t0 }; |
| 1702 | await pg.waitForTimeout(250); |
| 1703 | } |
| 1704 | return { landed: false, took: Date.now() - t0 }; |
| 1705 | }; |
| 1706 | |
| 1707 | /// Count POSTs to the mailbox, so "nothing was sent" is a measurement. |
| 1708 | const countPosts = (pg) => pg.evaluate(() => { |
| 1709 | window.__syncPosts = 0; |
| 1710 | const real = window.__syncRealFetch || window.fetch; |
| 1711 | window.__syncRealFetch = real; |
| 1712 | window.fetch = function (u, o) { |
| 1713 | const url = String((u && u.url) || u || ''); |
| 1714 | if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') window.__syncPosts++; |
| 1715 | return real.apply(this, arguments); |
| 1716 | }; |
| 1717 | }); |
| 1718 | const posts = (pg) => pg.evaluate(() => window.__syncPosts | 0); |
| 1719 | const unstub = (pg) => pg.evaluate(() => { |
| 1720 | if (window.__syncRealFetch) { window.fetch = window.__syncRealFetch; window.__syncRealFetch = null; } |
| 1721 | }); |
| 1722 | |
| 1723 | /// Rename a Diamond the way a person does: double-click its name in the rail, |
| 1724 | /// type into the dialog, press the button. No turn is taken and the tab is |
| 1725 | /// never hidden — which is the whole of the report. |
| 1726 | const renameDiamond = async (from, to) => { |
| 1727 | const found = await page.evaluate((nm) => { |
| 1728 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 1729 | .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm); |
| 1730 | if (!box) return false; |
| 1731 | box.querySelector('.session-box-name') |
| 1732 | .dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); |
| 1733 | return true; |
| 1734 | }, from); |
| 1735 | if (!found) return 'not in the rail'; |
| 1736 | await page.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 1737 | await page.evaluate((nm) => { |
| 1738 | const card = [...document.querySelectorAll('.dlg-card')].find(c => c.getClientRects().length); |
| 1739 | const inp = card.querySelector('input.dlg-input'); |
| 1740 | inp.value = nm; |
| 1741 | inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 1742 | card.querySelector('.dlg-ok').click(); |
| 1743 | }, to); |
| 1744 | await page.waitForTimeout(600); |
| 1745 | return 'ok'; |
| 1746 | }; |
| 1747 | |
| 1748 | /// What the MAILBOX holds, decrypted — what the other device would receive if |
| 1749 | /// it pulled this instant. The Diamond names, and what the trash says about |
| 1750 | /// each of them, because since the trash those are two different facts about |
| 1751 | /// the same Diamond and a delete moves only the second. |
| 1752 | const inMailbox = () => page.evaluate(async () => { |
| 1753 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 1754 | const j = await r.json(); |
| 1755 | if (!j.present) return { names: [], trash: {} }; |
| 1756 | try { |
| 1757 | const st = JSON.parse(await window.DaimondIdentity.unwrap(j.blob)); |
| 1758 | return { |
| 1759 | names: (st.diamonds || []).map((d) => { |
| 1760 | try { return JSON.parse(JSON.parse(d.data).files['.daimond/meta.json']).name; } |
| 1761 | catch (e) { return '?'; } |
| 1762 | }), |
| 1763 | trash: (st.trash && st.trash.items) || {}, |
| 1764 | }; |
| 1765 | } catch (e) { return { names: ['<undecryptable>'], trash: {} }; } |
| 1766 | }); |
| 1767 | const namesInMailbox = async () => (await inMailbox()).names; |
| 1768 | |
| 1769 | await newDiamond('Quiet-Alpha'); |
| 1770 | await pushLanded(page, 'Quiet-Alpha'); |
| 1771 | const vQuiet = await quiesce(page); |
| 1772 | check('the engine is quiet before the measurement, so nothing stray can carry it', |
| 1773 | vQuiet > 0, 'version=' + vQuiet); |
| 1774 | |
| 1775 | await countPosts(page); |
| 1776 | const renamed = await renameDiamond('Quiet-Alpha', 'Quiet-Renamed'); |
| 1777 | const own = await ownPush(page, vQuiet); |
| 1778 | const mailNames = await namesInMailbox(); |
| 1779 | check('a Diamond is renamed through the rail, with no turn and the tab visible', |
| 1780 | renamed === 'ok', renamed); |
| 1781 | check('and the rename pushes ON ITS OWN — no turn, no tab-hide, no explicit push', |
| 1782 | own.landed === true, 'version ' + vQuiet + ' -> ' |
| 1783 | + (await page.evaluate(() => window.DaimondSync.state().version)) |
| 1784 | + ' after ' + own.took + 'ms, posts=' + (await posts(page))); |
| 1785 | check('so the mailbox holds the NEW name, which is all the other device can read', |
| 1786 | mailNames.includes('Quiet-Renamed') && !mailNames.includes('Quiet-Alpha'), |
| 1787 | JSON.stringify(mailNames)); |
| 1788 | check('and it took ONE parcel, not one per keystroke', (await posts(page)) === 1, |
| 1789 | 'posts=' + (await posts(page))); |
| 1790 | |
| 1791 | // The receiving half, end to end: a device that has never seen the rename |
| 1792 | // pulls it. (Pull-on-focus is proved in section 7; what is proved here is |
| 1793 | // that there is now something on the mailbox for it to find.) |
| 1794 | const second = await page.evaluate(async () => { |
| 1795 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 1796 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 1797 | for (const d of JSON.parse(await app.list_diamonds())) await app.delete_diamond(d.id); |
| 1798 | localStorage.removeItem('daimond-sync-version'); |
| 1799 | await window.DaimondSync.pull(); |
| 1800 | return JSON.parse(await app.list_diamonds()).map(d => d.name); |
| 1801 | }); |
| 1802 | check('a second device pulls the rename in', second.includes('Quiet-Renamed'), |
| 1803 | JSON.stringify(second)); |
| 1804 | |
| 1805 | // A second real mutation path through the same funnel: deleting through the |
| 1806 | // rail, which writes the TRASH RECORD that has to travel with it. |
| 1807 | // |
| 1808 | // Not a tombstone, and not an absence. (4b) settled what a rail delete now |
| 1809 | // means -- the Diamond is trashed, its bytes still travel so the other device |
| 1810 | // can restore it, and only destroying it from the trash tombstones it -- and |
| 1811 | // this check went on asserting the contract that replaced. It failed on every |
| 1812 | // run, naming the Diamond it had just been told would still be there. |
| 1813 | await quiesce(page); |
| 1814 | await countPosts(page); |
| 1815 | const vDel = await page.evaluate(() => window.DaimondSync.state().version); |
| 1816 | const quietId = await wasm(async (app) => |
| 1817 | (JSON.parse(await app.list_diamonds()).find(d => d.name === 'Quiet-Renamed') || {}).id || ''); |
| 1818 | const dropped = await removeDiamond('Quiet-Renamed'); |
| 1819 | const delPush = await ownPush(page, vDel); |
| 1820 | const afterDel = await inMailbox(); |
| 1821 | const delRec = afterDel.trash[quietId] || null; |
| 1822 | check('deleting a Diamond in the rail also travels without a turn', |
| 1823 | dropped === 'ok' && delPush.landed === true, |
| 1824 | dropped + ', after ' + delPush.took + 'ms'); |
| 1825 | // The other Diamonds are named too, so an empty or unreadable mailbox cannot |
| 1826 | // pass this by holding nothing. |
| 1827 | check('and the mailbox says it is in the trash, while still holding the rest', |
| 1828 | !!quietId && !!delRec && delRec.at > delRec.back && afterDel.names.includes('Link-Travel'), |
| 1829 | 'id=' + quietId + ' rec=' + JSON.stringify(delRec) + ' ' + JSON.stringify(afterDel.names)); |
| 1830 | |
| 1831 | // A nudge is not a poll, and this is the failure mode the fix itself could |
| 1832 | // have: collectSync() persists the chats on its way past, so if THAT nudged, |
| 1833 | // every push would arm the next one for ever. |
| 1834 | // |
| 1835 | // Counted as parcels PACKED, not as requests sent. An unchanged parcel is |
| 1836 | // skipped before any request is made, so a POST counter watches a perfectly |
| 1837 | // quiet network while the app re-exports every Diamond every 2.5 seconds — |
| 1838 | // measured at six packs in fifteen seconds with the guard removed, and one |
| 1839 | // with it. Both numbers are checked, because both costs are real. |
| 1840 | await quiesce(page); |
| 1841 | // Refresh this device's roster stamp first: it is rewritten when it goes |
| 1842 | // stale (five minutes), and a run that crossed that boundary here would see |
| 1843 | // a genuinely changed parcel and read it as a spurious push. |
| 1844 | await page.evaluate(async () => { await window.DaimondCore.collectSync(); return true; }); |
| 1845 | await countPosts(page); |
| 1846 | const idle = await page.evaluate(async () => { |
| 1847 | let packed = 0; |
| 1848 | const real = window.DaimondCore.collectSync; |
| 1849 | window.DaimondCore.collectSync = function () { packed++; return real.apply(this, arguments); }; |
| 1850 | window.DaimondSync.nudge(); |
| 1851 | await new Promise(r => setTimeout(r, 15000)); |
| 1852 | window.DaimondCore.collectSync = real; |
| 1853 | return { packed: packed, posts: window.__syncPosts | 0 }; |
| 1854 | }); |
| 1855 | check('a nudge with nothing changed sends no parcel at all', idle.posts === 0, |
| 1856 | 'posts=' + idle.posts); |
| 1857 | check('and packing it does not arm the next one — the debounce is not a poll', |
| 1858 | idle.packed <= 1, 'parcels packed in 15s = ' + idle.packed); |
| 1859 | |
| 1860 | // A nudge must not walk through the standing refusals either. 402 stops |
| 1861 | // pushes until the tier is rechecked, and a mutation arriving afterwards |
| 1862 | // must not quietly restart them or repaint the chip. |
| 1863 | const refusal = await page.evaluate(async () => { |
| 1864 | const chip = () => { |
| 1865 | const c = document.getElementById('sync-chip'); |
| 1866 | return c ? { state: c.dataset.state || '', shown: c.style.display !== 'none' } : null; |
| 1867 | }; |
| 1868 | // One real change, made before the stub so nothing it does is answered by |
| 1869 | // it, and asserted on: without it the push below is skipped and the 402 |
| 1870 | // arm is never reached at all. |
| 1871 | const changed = await window.__bump('nudge-402'); |
| 1872 | const real = window.__syncRealFetch || window.fetch; |
| 1873 | window.__syncRealFetch = real; |
| 1874 | let sent = 0; |
| 1875 | window.fetch = function (u, o) { |
| 1876 | const url = String((u && u.url) || u || ''); |
| 1877 | if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') { |
| 1878 | sent++; |
| 1879 | return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'stub' }), |
| 1880 | { status: 402, headers: { 'content-type': 'application/json' } })); |
| 1881 | } |
| 1882 | return real.apply(this, arguments); |
| 1883 | }; |
| 1884 | // Pushed, refused: the engine is now paused on a 402. Driven until one |
| 1885 | // really goes -- a push over a round already in flight is only |
| 1886 | // rescheduled, and the pause below would then be nobody's. |
| 1887 | const reached = await window.__pushSent(() => sent, 'nudge-402'); |
| 1888 | const paused = { chip: chip(), entitled: window.DaimondSync.state().entitled, reached }; |
| 1889 | // Now a mutation of the kind that nudges. It must change nothing. |
| 1890 | const before = sent; |
| 1891 | await window.DaimondCore.collectSync(); // persistChats() runs on the way past |
| 1892 | window.DaimondSync.nudge(); |
| 1893 | await new Promise(r => setTimeout(r, 7000)); |
| 1894 | const out = { paused, after: chip(), tried: sent - before, changed: changed, |
| 1895 | entitled: window.DaimondSync.state().entitled }; |
| 1896 | window.fetch = real; |
| 1897 | window.__syncRealFetch = null; |
| 1898 | return out; |
| 1899 | }); |
| 1900 | check('the change that provoked the 402 really left this device', |
| 1901 | refusal.changed.moved === true, refusal.changed.why); |
| 1902 | check('a 402 pauses pushes and says so on the chip', |
| 1903 | !!(refusal.paused.chip && refusal.paused.chip.state === 'off') |
| 1904 | && refusal.paused.entitled === false && refusal.paused.reached.sent === true, |
| 1905 | JSON.stringify(refusal.paused)); |
| 1906 | check('a nudge afterwards does not restart them behind the refusal', |
| 1907 | refusal.tried === 0 && refusal.entitled === false, 'attempts=' + refusal.tried); |
| 1908 | check('nor repaint the chip over it', |
| 1909 | !!(refusal.after && refusal.after.state === 'off' && refusal.after.shown), |
| 1910 | JSON.stringify(refusal.after)); |
| 1911 | |
| 1912 | // Put the engine back, so nothing below runs paused. |
| 1913 | await unstub(page); |
| 1914 | await page.evaluate(() => window.DaimondSync.recheck()); |
| 1915 | await page.waitForTimeout(1500); |
| 1916 | await pushLanded(page, 'the tier coming back'); |
| 1917 | const back = await page.evaluate(() => window.DaimondSync.state()); |
| 1918 | check('and the tier coming back lifts it, with the engine syncing again', |
| 1919 | back.entitled === true && back.stalled === false, JSON.stringify(back)); |
| 1920 | |
| 1921 | // (9) The device roster travels, so a user can be told whether their account |
| 1922 | // is on more than one device. |
| 1923 | // |
| 1924 | // Nothing else can tell them. Pairing hands the second device the SAME |
| 1925 | // keypair, so the gateway sees one user and cannot count devices, and the |
| 1926 | // parked bundle is deleted on redeem, so there is no server record of the |
| 1927 | // pairing either. The count therefore has to come out of the parcel, which |
| 1928 | // means it has to survive the real encrypted round trip -- and the roster must |
| 1929 | // stay INSIDE the sealed blob while it does. The second device is simulated |
| 1930 | // the way this file simulates one everywhere else: by swapping what this |
| 1931 | // browser holds. |
| 1932 | const DEV_B = 'bbbb2222cccc3333'; |
| 1933 | const roster0 = await page.evaluate(() => ({ |
| 1934 | self: localStorage.getItem('daimond-device-id'), |
| 1935 | reg: localStorage.getItem('daimond-devices') || '{}', |
| 1936 | })); |
| 1937 | check('this device is on its own roster before any of it travels', |
| 1938 | /^[0-9a-f]{16}$/.test(roster0.self || '') && !!JSON.parse(roster0.reg)[roster0.self], |
| 1939 | roster0.reg.slice(0, 120)); |
| 1940 | |
| 1941 | await pushLanded(page, 'the device roster'); |
| 1942 | const sealed = await page.evaluate(async () => { |
| 1943 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 1944 | const j = await r.json(); |
| 1945 | return j.blob || ''; |
| 1946 | }); |
| 1947 | check('the roster rides inside the sealed blob — the gateway is told no device name', |
| 1948 | !sealed.includes('device') && !sealed.includes('Chromium') && !sealed.includes(roster0.self), |
| 1949 | 'blob ' + sealed.length + ' bytes'); |
| 1950 | |
| 1951 | // Device B: another install of the app, which has never seen this roster. |
| 1952 | const bSaw = await page.evaluate(async (b) => { |
| 1953 | localStorage.setItem('daimond-device-id', b); |
| 1954 | localStorage.setItem('daimond-devices', '{}'); |
| 1955 | await window.DaimondSync.pull(); |
| 1956 | return JSON.parse(localStorage.getItem('daimond-devices') || '{}'); |
| 1957 | }, DEV_B); |
| 1958 | check('the second device pulls and learns of the first', |
| 1959 | !!bSaw[roster0.self], Object.keys(bSaw).join(',')); |
| 1960 | await pushLanded(page, 'device B line'); |
| 1961 | |
| 1962 | // Device A again, knowing only itself, exactly as it was left. |
| 1963 | const aSaw = await page.evaluate(async (r) => { |
| 1964 | localStorage.setItem('daimond-device-id', r.self); |
| 1965 | localStorage.setItem('daimond-devices', r.reg); |
| 1966 | await window.DaimondSync.pull(); |
| 1967 | return JSON.parse(localStorage.getItem('daimond-devices') || '{}'); |
| 1968 | }, roster0); |
| 1969 | check('and the first device pulls and learns of the second', |
| 1970 | !!aSaw[DEV_B], Object.keys(aSaw).join(',')); |
| 1971 | check('so both ends see BOTH devices — the account is visibly on two', |
| 1972 | !!aSaw[roster0.self] && !!aSaw[DEV_B] && Object.keys(aSaw).length === 2, |
| 1973 | JSON.stringify(aSaw).slice(0, 200)); |
| 1974 | const lineB = aSaw[DEV_B] || {}; |
| 1975 | check('each line carries a name and a last-seen, so the list can be read', |
| 1976 | !!lineB.name && lineB.seen > 1.7e12 && lineB.created > 1.7e12, |
| 1977 | JSON.stringify(lineB)); |
| 1978 | |
| 1979 | // A name the USER gives a device, the whole way round. The second device |
| 1980 | // names the FIRST one's line -- which is the case that cannot work if the |
| 1981 | // name rides on `seen`, because the first device refreshes its own line and |
| 1982 | // would win it straight back. And the name is the user's own words, so it |
| 1983 | // must reach the other device WITHOUT the gateway ever holding it in the |
| 1984 | // clear: sync.js sends the coarse derived description as the mailbox label |
| 1985 | // and nothing else, so the typed name may exist only inside the sealed blob. |
| 1986 | const rename = await page.evaluate(async (r) => { |
| 1987 | const wait = (n) => new Promise(x => setTimeout(x, n)); |
| 1988 | const mine = JSON.parse(localStorage.getItem('daimond-devices') || '{}'); |
| 1989 | const out = { aBefore: mine[r.self] }; |
| 1990 | // Device B, naming device A's line through the drawer, as a user would. |
| 1991 | localStorage.setItem('daimond-device-id', r.b); |
| 1992 | DaimondAdmin.home(); |
| 1993 | const row = [...document.querySelectorAll('#admin-home .device-row')] |
| 1994 | .find(x => ((x.querySelector('.device-id') || {}).textContent || '') === r.self.slice(-4)); |
| 1995 | const btn = row && row.querySelector('.device-rename'); |
| 1996 | if (!btn) { out.renamed = false; return out; } |
| 1997 | btn.click(); |
| 1998 | await wait(80); |
| 1999 | const input = document.querySelector('.dlg .dlg-input'); |
| 2000 | const ok = document.querySelector('.dlg .dlg-ok'); |
| 2001 | if (!input || !ok) { out.renamed = false; return out; } |
| 2002 | input.value = 'Kitchen laptop'; |
| 2003 | ok.click(); |
| 2004 | await wait(200); |
| 2005 | DaimondAdmin.close(); |
| 2006 | out.renamed = (JSON.parse(localStorage.getItem('daimond-devices') || '{}')[r.self] || {}).label |
| 2007 | === 'Kitchen laptop'; |
| 2008 | return out; |
| 2009 | }, { self: roster0.self, b: DEV_B }); |
| 2010 | check('the second device can name the first one\'s line', rename.renamed === true, |
| 2011 | JSON.stringify(rename.aBefore)); |
| 2012 | |
| 2013 | await pushLanded(page, 'the typed device name'); |
| 2014 | const sealedNamed = await page.evaluate(async () => { |
| 2015 | const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 2016 | const j = await r.json(); |
| 2017 | return { blob: j.blob || '', label: j.device || j.label || '' }; |
| 2018 | }); |
| 2019 | check('the name the user typed is inside the sealed blob and nowhere else on the wire', |
| 2020 | !sealedNamed.blob.includes('Kitchen') && !sealedNamed.blob.includes('laptop') |
| 2021 | && !/kitchen/i.test(JSON.stringify(sealedNamed.label)), |
| 2022 | 'blob ' + sealedNamed.blob.length + ' bytes, mailbox label ' + JSON.stringify(sealedNamed.label)); |
| 2023 | |
| 2024 | // Back to device A, with the roster it had before any of this: its own line |
| 2025 | // is the FRESHER one, and it still has to take the name. |
| 2026 | const aNamed = await page.evaluate(async (r) => { |
| 2027 | localStorage.setItem('daimond-device-id', r.self); |
| 2028 | localStorage.setItem('daimond-devices', r.reg); |
| 2029 | const before = JSON.parse(r.reg)[r.self]; |
| 2030 | await window.DaimondSync.pull(); |
| 2031 | const after = JSON.parse(localStorage.getItem('daimond-devices') || '{}')[r.self]; |
| 2032 | DaimondAdmin.home(); |
| 2033 | const rows = [...document.querySelectorAll('#admin-home .device-row')] |
| 2034 | .map(x => x.innerText.replace(/\s+/g, ' ').trim()); |
| 2035 | DaimondAdmin.close(); |
| 2036 | return { before: before, after: after, rows: rows }; |
| 2037 | }, { self: roster0.self, reg: JSON.stringify(aSaw) }); |
| 2038 | check('and the first device pulls the name for ITSELF, though its own line is the fresher one', |
| 2039 | !!aNamed.after && aNamed.after.label === 'Kitchen laptop' |
| 2040 | && aNamed.after.seen >= aNamed.before.seen, |
| 2041 | JSON.stringify(aNamed.after)); |
| 2042 | check('so the drawer there now reads the name its owner gave it', |
| 2043 | aNamed.rows.some(x => /Kitchen laptop/.test(x) && /this device/i.test(x)), |
| 2044 | aNamed.rows.join(' | ')); |
| 2045 | // The drawer is the surface the user actually reads. |
| 2046 | const shown = await page.evaluate(() => { |
| 2047 | DaimondAdmin.home(); |
| 2048 | const rows = [...document.querySelectorAll('#admin-home .device-row')] |
| 2049 | .map(r => r.innerText.replace(/\s+/g, ' ').trim()); |
| 2050 | DaimondAdmin.close(); |
| 2051 | return rows; |
| 2052 | }); |
| 2053 | check('and the Admin drawer lists them both, with this one marked', |
| 2054 | shown.length === 2 && shown.filter(r => /this device/i.test(r)).length === 1, |
| 2055 | shown.join(' | ')); |
| 2056 | |
| 2057 | // (5) The export bundle carries the salt (without it, no second device could decrypt). |
| 2058 | const bundle = await page.evaluate(() => { |
| 2059 | const b = window.DaimondIdentity.exportBundle(); |
| 2060 | return b ? { hasSalt: !!b.salt, hasPriv: !!b.priv, hasPub: !!b.pub, v: b.v } : null; |
| 2061 | }); |
| 2062 | check('identity export bundle carries salt + wrapped key + pubkey', |
| 2063 | !!bundle && bundle.hasSalt && bundle.hasPriv && bundle.hasPub && bundle.v === 1); |
| 2064 | |
| 2065 | // ── (10) One bad section must not swallow the parcel ─────────────── |
| 2066 | // A parcel is written by ANOTHER device: a version behind, a version ahead, |
| 2067 | // or halfway through a write when it was packed. Every section of the merge |
| 2068 | // is meant to be best effort, so that one of them failing costs only itself. |
| 2069 | // The TOP of applySync was not: the chats ran outside any guard, so a |
| 2070 | // transcript that was not a list threw out of the whole function and the |
| 2071 | // Diamonds, the files, the providers and the mailboxes below it were never |
| 2072 | // reached. The pull that called it logged one console.debug line -- hidden in |
| 2073 | // DevTools unless Verbose is on -- adopted the version, and went on to push |
| 2074 | // this device's state over the top of the parcel it had just failed to merge. |
| 2075 | const poison = await page.evaluate(async () => { |
| 2076 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2077 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2078 | const id = await app.create_diamond('Poison-Local'); |
| 2079 | const pack = JSON.parse(await app.export_diamond(id)); |
| 2080 | const meta = JSON.parse(pack.files['.daimond/meta.json']); |
| 2081 | meta.name = 'Poison-Survivor'; |
| 2082 | meta.touched = Date.now() + 60000; // the other device's copy is the fresher one |
| 2083 | pack.files['.daimond/meta.json'] = JSON.stringify(meta); |
| 2084 | const parcel = await window.DaimondCore.collectSync(); |
| 2085 | parcel.diamonds = [{ id: id, updated: meta.updated || 0, touched: meta.touched, |
| 2086 | model: null, data: JSON.stringify(pack) }]; |
| 2087 | // A chat THIS DEVICE ALREADY HOLDS, arriving with a transcript that is |
| 2088 | // not a list. A chat nobody here has seen is simply stored; one that is |
| 2089 | // held is MERGED, and the union walks the transcript -- so this is where |
| 2090 | // a parcel written by another build, or half written when it was packed, |
| 2091 | // actually reaches. Nothing is left behind by it: the throw happens |
| 2092 | // before the merged chats are written back. |
| 2093 | // From the store the chats actually live in. Read out of localStorage, this |
| 2094 | // was always an empty list: the parcel was never poisoned, and the three |
| 2095 | // checks below passed on a merge that had nothing to fail at. |
| 2096 | const store = window.DaimondCore.chatStore(); |
| 2097 | const held = store.stored(); |
| 2098 | const victim = held.length ? held[0].id : ''; |
| 2099 | parcel.chats = (parcel.chats || []).map(c => |
| 2100 | (c && c.id === victim) ? Object.assign({}, c, { messages: 'not-a-list' }) : c); |
| 2101 | let threw = '', report = null; |
| 2102 | try { report = await window.DaimondCore.applySync(parcel); } |
| 2103 | catch (e) { threw = String(e && e.message || e); } |
| 2104 | const row = JSON.parse(await app.list_diamonds()).find(d => d.id === id) || null; |
| 2105 | const kept = store.stored(); |
| 2106 | return { threw, report, victim, name: row ? row.name : '(gone)', |
| 2107 | chatsIntact: kept.length === held.length |
| 2108 | && kept.every(c => Array.isArray(c.messages)) }; |
| 2109 | }); |
| 2110 | check('the poisoned parcel names a chat this device really holds', !!poison.victim, poison.victim); |
| 2111 | check('a malformed section does not throw out of applySync', poison.threw === '', poison.threw); |
| 2112 | check('and the Diamond in the same parcel still arrives', |
| 2113 | poison.name === 'Poison-Survivor', poison.name); |
| 2114 | check('and the merge SAYS which section it could not apply', |
| 2115 | !!(poison.report && Array.isArray(poison.report.failed) && poison.report.failed.indexOf('chats') !== -1), |
| 2116 | JSON.stringify(poison.report)); |
| 2117 | check('and the section that failed left this device’s own chats as they were', |
| 2118 | poison.chatsIntact === true); |
| 2119 | |
| 2120 | // ── (11) Giving up is said out loud ──────────────────────────────── |
| 2121 | // The pull-merge-retry loop is bounded, and running out of attempts used to |
| 2122 | // be one console.debug line. Worse: the last thing on the chip was the |
| 2123 | // "Synced" the reconciling PULL put there, so a device whose work never left |
| 2124 | // looked exactly like a device that had just saved. |
| 2125 | const exhausted = await page.evaluate(async () => { |
| 2126 | const chip = () => { |
| 2127 | const c = document.getElementById('sync-chip'); |
| 2128 | return c ? { state: c.dataset.state || '', text: (c.querySelector('.stext') || {}).textContent || '', |
| 2129 | title: c.title || '', shown: c.style.display !== 'none' } : null; |
| 2130 | }; |
| 2131 | // Something of this device's own to lose, made before the stub: with an |
| 2132 | // unchanged parcel the push never leaves and there is no conflict to |
| 2133 | // exhaust. |
| 2134 | const changed = await window.__bump('storm-note'); |
| 2135 | const real = window.fetch; |
| 2136 | let posts = 0; |
| 2137 | window.fetch = function (u, o) { |
| 2138 | const url = String((u && u.url) || u || ''); |
| 2139 | if (url.indexOf('/api/sync') !== -1 && o && o.method === 'POST') { |
| 2140 | posts++; |
| 2141 | return Promise.resolve(new Response( |
| 2142 | JSON.stringify({ ok: false, conflict: true, version: 9999, device: 'the other one' }), |
| 2143 | { status: 409, headers: { 'content-type': 'application/json' } })); |
| 2144 | } |
| 2145 | return real.apply(this, arguments); |
| 2146 | }; |
| 2147 | // Driven until a parcel really leaves. A single push() here measured a |
| 2148 | // gateway nobody had spoken to: the call found a round already in flight, |
| 2149 | // rescheduled, and returned exactly as it does when it sent -- posts=0, |
| 2150 | // the chip still reading "Syncing…" from the other round, and all four |
| 2151 | // checks below red for a reason that had nothing to do with conflicts. |
| 2152 | // |
| 2153 | // `posts` is zeroed before each attempt, so what the bound below measures |
| 2154 | // is ONE push's retries and not the sum of every round in the window. |
| 2155 | let attempts = 0; |
| 2156 | const t0 = Date.now(); |
| 2157 | while (posts === 0 && Date.now() - t0 < 15000) { |
| 2158 | attempts++; |
| 2159 | if (attempts > 1) await window.__bump('storm-note-' + attempts); |
| 2160 | posts = 0; |
| 2161 | await window.DaimondSync.push(); |
| 2162 | if (posts === 0) await new Promise(r => setTimeout(r, 300)); |
| 2163 | } |
| 2164 | const out = { posts, attempts, changed, chip: chip(), api: window.DaimondSync.state() }; |
| 2165 | window.fetch = real; |
| 2166 | return out; |
| 2167 | }); |
| 2168 | check('the work the conflict is about really left this device', |
| 2169 | exhausted.changed.moved === true, exhausted.changed.why); |
| 2170 | check('a permanent conflict is retried, and bounded', exhausted.posts >= 2 && exhausted.posts <= 6, |
| 2171 | 'posts=' + exhausted.posts + ' over ' + exhausted.attempts + ' push attempts'); |
| 2172 | check('and running out of attempts is SAID, not swallowed', |
| 2173 | !!(exhausted.chip && exhausted.chip.state === 'stalled' && exhausted.chip.shown), |
| 2174 | JSON.stringify(exhausted.chip)); |
| 2175 | check('with a reason that names what is happening — another device writing too', |
| 2176 | !!(exhausted.chip && /device/i.test(exhausted.chip.title)), |
| 2177 | (exhausted.chip && exhausted.chip.title || '').slice(0, 140)); |
| 2178 | check('and the engine reports the stall through its own surface', |
| 2179 | !!(exhausted.api && exhausted.api.stalled === true), JSON.stringify(exhausted.api)); |
| 2180 | |
| 2181 | // Clear the stall before the second device runs below. |
| 2182 | await bumped(page, 'calm-note'); |
| 2183 | await pushLanded(page, 'clearing the stall'); |
| 2184 | |
| 2185 | // ── (12) Two REAL devices converge, both ways ────────────────────── |
| 2186 | // Every check above simulates the second device inside this browser. That |
| 2187 | // cannot see the thing that actually broke in the field: two windows, both |
| 2188 | // open and both FOCUSED, on two machines. Neither raises a focus event, so |
| 2189 | // the focus pull never fires, and the app settling only ever schedules a |
| 2190 | // PUSH -- which is skipped outright when this device has nothing new to send. |
| 2191 | // A device that is not editing therefore never asked the gateway anything at |
| 2192 | // all, and the other device's work sat in the mailbox unread for as long as |
| 2193 | // the window stayed where it was. |
| 2194 | child = await open({ name: 'syncmate', signIn: false, connect: false }); |
| 2195 | await child.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 15000 }).catch(() => {}); |
| 2196 | const code = await page.evaluate(() => DaimondPairing.create()); |
| 2197 | await child.page.evaluate(c => DaimondPairing.redeem(c), code.code); |
| 2198 | await child.page.reload({ waitUntil: 'domcontentloaded' }); |
| 2199 | await signInAs(child, 'sync'); |
| 2200 | await child.page.waitForFunction( |
| 2201 | () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed, |
| 2202 | null, { timeout: 15000 }).catch(() => {}); |
| 2203 | const mate = await child.page.evaluate(() => ({ |
| 2204 | authed: DaimondGateway.state().authed, |
| 2205 | same: window.DaimondIdentity.publicKeyB64url(), |
| 2206 | })); |
| 2207 | const mine = await page.evaluate(() => window.DaimondIdentity.publicKeyB64url()); |
| 2208 | check('a second REAL device holds the same account and an authed session', |
| 2209 | mate.authed === true && mate.same === mine, JSON.stringify(mate).slice(0, 80)); |
| 2210 | |
| 2211 | /// One Diamond's name, as each device's own store reads it. |
| 2212 | const nameOn = (pg, id) => pg.evaluate(async (id) => { |
| 2213 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2214 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2215 | return ((JSON.parse(await app.list_diamonds()).find(d => d.id === id)) || {}).name || '(absent)'; |
| 2216 | }, id); |
| 2217 | |
| 2218 | /// The same name, once the store has SETTLED on it. Every assertion below |
| 2219 | /// that expects a particular name is asserting the end of a merge, and |
| 2220 | /// `pull()` resolving is not the same instant as the store having finished |
| 2221 | /// rewriting the directory -- two `list_diamonds()` calls microseconds apart |
| 2222 | /// were observed disagreeing, which is what (4c) settles rather than |
| 2223 | /// snapshots for the same reason. |
| 2224 | /// |
| 2225 | /// Bounded, and it returns whatever it last read: a name that never arrives |
| 2226 | /// fails the caller's check with the name it actually found, rather than |
| 2227 | /// hanging or passing. |
| 2228 | const nameSettles = async (pg, id, want, ms = 6000) => { |
| 2229 | const t0 = Date.now(); |
| 2230 | let seen = ''; |
| 2231 | do { |
| 2232 | seen = await nameOn(pg, id); |
| 2233 | if (seen === want) return seen; |
| 2234 | await pg.waitForTimeout(150); |
| 2235 | } while (Date.now() - t0 < ms); |
| 2236 | return seen; |
| 2237 | }; |
| 2238 | |
| 2239 | // A shared Diamond, on both devices, agreed. |
| 2240 | const shared = await page.evaluate(async () => { |
| 2241 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2242 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2243 | return await app.create_diamond('Both-Devices'); |
| 2244 | }); |
| 2245 | await pushLanded(page, 'the shared Diamond'); |
| 2246 | await child.page.evaluate(() => window.DaimondSync.pull()); |
| 2247 | check('the second device pulls the shared Diamond down', |
| 2248 | (await nameSettles(child.page, shared, 'Both-Devices')) === 'Both-Devices', |
| 2249 | await nameOn(child.page, shared)); |
| 2250 | // …and it pushes once, so it has something it believes it last sent. This is |
| 2251 | // the state every idle device is in. |
| 2252 | await child.page.evaluate(async () => { |
| 2253 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2254 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2255 | await app.create_diamond('Made-On-The-Mate'); |
| 2256 | }); |
| 2257 | await pushLanded(child.page, 'the mate own Diamond'); |
| 2258 | await page.evaluate(() => window.DaimondSync.pull()); |
| 2259 | // Quiesce the mate, so it is in the state every idle device is in: whatever |
| 2260 | // it would send, it has already sent. |
| 2261 | await child.page.evaluate(() => window.DaimondSync.push()); |
| 2262 | await child.page.waitForTimeout(600); |
| 2263 | await child.page.evaluate(() => window.DaimondSync.push()); |
| 2264 | |
| 2265 | // (12a) The idle device. This one renames; the other has nothing to send and |
| 2266 | // never leaves the window it is in. Only the app settling fires there. |
| 2267 | await page.evaluate(async (id) => { |
| 2268 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2269 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2270 | await app.rename_diamond(id, 'Renamed-Over-There'); |
| 2271 | }, shared); |
| 2272 | await pushLanded(page, 'the rename for the idle device'); |
| 2273 | const idleLearn = await child.page.evaluate(async () => { |
| 2274 | const v0 = window.DaimondSync.state().version; |
| 2275 | // The app settling, twice, which is all a device that is not being typed |
| 2276 | // at ever gets. Twice because the catch-up is rate-limited, and the |
| 2277 | // quiescing pushes just above have only recently spent that budget. |
| 2278 | window.dispatchEvent(new Event('daimond:idle')); |
| 2279 | await new Promise(r => setTimeout(r, 4000)); |
| 2280 | window.dispatchEvent(new Event('daimond:idle')); |
| 2281 | await new Promise(r => setTimeout(r, 5500)); |
| 2282 | return { v0, v1: window.DaimondSync.state().version }; |
| 2283 | }); |
| 2284 | check('a device with nothing to send still learns what the other one did', |
| 2285 | (await nameSettles(child.page, shared, 'Renamed-Over-There')) === 'Renamed-Over-There', |
| 2286 | 'version ' + idleLearn.v0 + ' -> ' + idleLearn.v1); |
| 2287 | |
| 2288 | // (12b) Both devices editing at once, pushing on every change, as seq 50's |
| 2289 | // nudge makes them. Each works on its OWN Diamond, so nothing here is a |
| 2290 | // question of whose copy wins: a merge that reconciles at all must end with |
| 2291 | // both names on both devices. |
| 2292 | const mateOwn = await child.page.evaluate(async () => { |
| 2293 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2294 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2295 | return (JSON.parse(await app.list_diamonds()).find(d => d.name === 'Made-On-The-Mate') || {}).id || ''; |
| 2296 | }); |
| 2297 | check('the mate device owns a Diamond of its own to work on', !!mateOwn, mateOwn); |
| 2298 | for (let round = 1; round <= 3; round++) { |
| 2299 | await page.evaluate(async (arg) => { |
| 2300 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2301 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2302 | await app.rename_diamond(arg.id, 'Here-' + arg.round); |
| 2303 | }, { id: shared, round }); |
| 2304 | await child.page.evaluate(async (arg) => { |
| 2305 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2306 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2307 | await app.rename_diamond(arg.id, 'There-' + arg.round); |
| 2308 | }, { id: mateOwn, round }); |
| 2309 | // Both at once: one of them is pushing from a base the other just moved. |
| 2310 | await Promise.all([ |
| 2311 | page.evaluate(() => window.DaimondSync.push()), |
| 2312 | child.page.evaluate(() => window.DaimondSync.push()), |
| 2313 | ]); |
| 2314 | await page.waitForTimeout(400); |
| 2315 | } |
| 2316 | // Let each side have its ordinary settling trigger, three times, and no more: |
| 2317 | // no reload, no focus, nothing a user would have to think of. |
| 2318 | for (let i = 0; i < 3; i++) { |
| 2319 | await Promise.all([ |
| 2320 | page.evaluate(() => window.dispatchEvent(new Event('daimond:idle'))), |
| 2321 | child.page.evaluate(() => window.dispatchEvent(new Event('daimond:idle'))), |
| 2322 | ]); |
| 2323 | await page.waitForTimeout(6000); |
| 2324 | } |
| 2325 | const hereName = await nameSettles(page, shared, 'Here-3'); |
| 2326 | const thereName = await nameSettles(page, mateOwn, 'There-3'); |
| 2327 | const mateHere = await nameSettles(child.page, shared, 'Here-3'); |
| 2328 | const mateThere = await nameSettles(child.page, mateOwn, 'There-3'); |
| 2329 | check('after a storm of simultaneous pushes, this device holds both sides’ work', |
| 2330 | hereName === 'Here-3' && thereName === 'There-3', hereName + ' / ' + thereName); |
| 2331 | check('and so does the other one — the conflict path converges, both ways', |
| 2332 | mateHere === 'Here-3' && mateThere === 'There-3', mateHere + ' / ' + mateThere); |
| 2333 | const chips = await Promise.all([ |
| 2334 | page.evaluate(() => { const c = document.getElementById('sync-chip'); return c ? c.dataset.state : '(none)'; }), |
| 2335 | child.page.evaluate(() => { const c = document.getElementById('sync-chip'); return c ? c.dataset.state : '(none)'; }), |
| 2336 | ]); |
| 2337 | check('and neither device is left claiming to be synced while it is stalled', |
| 2338 | chips.every(c => c !== 'stalled'), chips.join(' / ')); |
| 2339 | |
| 2340 | // ── (13) The gateway taps an idle device ─────────────────────────── |
| 2341 | // (12a) proved the idle device catches up when the app settles. But the app |
| 2342 | // settling is still something that happened HERE, and a window sitting |
| 2343 | // unfocused on a second desk settles once and then never again: no turn ends, |
| 2344 | // nothing is renamed, the user is not in it. That window used to converge |
| 2345 | // only when somebody came back to it, which is the complaint this section |
| 2346 | // exists for. The trigger now comes from the gateway. |
| 2347 | // |
| 2348 | // Everything below runs with the second device touched in exactly one way: |
| 2349 | // reading its state. No focus, no visibility change, no settling event, no |
| 2350 | // reload. Those are counted, and the count must stay at zero. |
| 2351 | // First, what it was like without one. With the channel shut this device has |
| 2352 | // exactly the triggers it had before the channel existed -- a focus that will |
| 2353 | // not come, a turn that will not end, a push with nothing to send -- and ten |
| 2354 | // seconds go by with the other device's work sitting unread in the mailbox. |
| 2355 | // This is the live complaint, kept as a test so the channel cannot quietly |
| 2356 | // stop being the thing that answers it. |
| 2357 | await child.page.evaluate(() => window.DaimondSync.wakeVia('off')); |
| 2358 | const blindV = await child.page.evaluate(() => window.DaimondSync.state().version); |
| 2359 | await page.evaluate(async (id) => { |
| 2360 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2361 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2362 | await app.rename_diamond(id, 'Unheard-Over-There'); |
| 2363 | }, shared); |
| 2364 | await pushLanded(page, 'the rename the shut channel must miss'); |
| 2365 | const blind = await child.page.evaluate(async (v0) => { |
| 2366 | const t0 = Date.now(); |
| 2367 | while (Date.now() - t0 < 10000 && window.DaimondSync.state().version <= v0) { |
| 2368 | await new Promise(r => setTimeout(r, 200)); |
| 2369 | } |
| 2370 | return { v0, v1: window.DaimondSync.state().version }; |
| 2371 | }, blindV); |
| 2372 | check('WITHOUT the channel, an idle unfocused device never hears — this is the bug', |
| 2373 | blind.v1 === blind.v0 && (await nameOn(child.page, shared)) !== 'Unheard-Over-There', |
| 2374 | 'version stayed at ' + blind.v1); |
| 2375 | |
| 2376 | // Now with it. Same device, same conditions, one thing different. |
| 2377 | await child.page.evaluate(() => window.DaimondSync.wakeVia('ws')); |
| 2378 | await child.page.waitForFunction( |
| 2379 | () => { const w = window.DaimondSync.wake(); return w.open && (w.mode === 'ws' || w.mode === 'poll'); }, |
| 2380 | null, { timeout: 15000 }).catch(() => {}); |
| 2381 | // A channel that has just opened says what the version is NOW, so a device |
| 2382 | // that missed something while it was away learns about it on connecting |
| 2383 | // rather than on the next push somebody else happens to make. |
| 2384 | await child.page.waitForFunction( |
| 2385 | v0 => window.DaimondSync.state().version > v0, blind.v1, { timeout: 15000 }).catch(() => {}); |
| 2386 | check('opening the channel catches the device up on what it missed while it was shut', |
| 2387 | (await nameSettles(child.page, shared, 'Unheard-Over-There')) === 'Unheard-Over-There', |
| 2388 | 'version ' + blind.v1 + ' -> ' + (await child.page.evaluate(() => window.DaimondSync.state().version))); |
| 2389 | const chan = await child.page.evaluate(() => { |
| 2390 | window.__wakeProbe = { focus: 0, vis: 0, idle: 0 }; |
| 2391 | window.addEventListener('focus', () => window.__wakeProbe.focus++, true); |
| 2392 | document.addEventListener('visibilitychange', () => window.__wakeProbe.vis++, true); |
| 2393 | window.addEventListener('daimond:idle', () => window.__wakeProbe.idle++, true); |
| 2394 | return { wake: window.DaimondSync.wake(), version: window.DaimondSync.state().version }; |
| 2395 | }); |
| 2396 | check('the idle device holds a wake channel open to the gateway', |
| 2397 | chan.wake.open === true && (chan.wake.mode === 'ws' || chan.wake.mode === 'poll'), |
| 2398 | JSON.stringify(chan.wake)); |
| 2399 | check('and the two devices name different channels, so neither is woken by itself', |
| 2400 | chan.wake.id !== (await page.evaluate(() => window.DaimondSync.wake().id)), |
| 2401 | chan.wake.id); |
| 2402 | |
| 2403 | // The other device renames a Diamond and pushes. Nothing at all happens on |
| 2404 | // the idle one. |
| 2405 | const selfBefore = await page.evaluate(() => window.DaimondSync.wake().wakes); |
| 2406 | await page.evaluate(async (id) => { |
| 2407 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2408 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2409 | await app.rename_diamond(id, 'Woken-Over-There'); |
| 2410 | }, shared); |
| 2411 | const beganWake = Date.now(); |
| 2412 | await pushLanded(page, 'the rename the wake channel carries'); |
| 2413 | const woke = await child.page.evaluate(async (v0) => { |
| 2414 | const t0 = Date.now(); |
| 2415 | while (Date.now() - t0 < 10000 && window.DaimondSync.state().version <= v0) { |
| 2416 | await new Promise(r => setTimeout(r, 100)); |
| 2417 | } |
| 2418 | return { |
| 2419 | v1: window.DaimondSync.state().version, |
| 2420 | probe: window.__wakeProbe, |
| 2421 | wake: window.DaimondSync.wake(), |
| 2422 | }; |
| 2423 | }, chan.version); |
| 2424 | const wakeMs = Date.now() - beganWake; |
| 2425 | check('an idle, unfocused device applies the other one’s work with no trigger of its own', |
| 2426 | (await nameSettles(child.page, shared, 'Woken-Over-There')) === 'Woken-Over-There', |
| 2427 | 'version ' + chan.version + ' -> ' + woke.v1 + ' in ' + wakeMs + 'ms'); |
| 2428 | check('and it converges within five seconds of the push, not on the next thing the user does', |
| 2429 | woke.v1 > chan.version && wakeMs < 5000, wakeMs + 'ms'); |
| 2430 | check('and nothing on that device caused it — no focus, no visibility change, no settling', |
| 2431 | woke.probe.focus === 0 && woke.probe.vis === 0 && woke.probe.idle === 0, |
| 2432 | JSON.stringify(woke.probe)); |
| 2433 | check('and the pull was the wake channel’s doing, by its own count', |
| 2434 | woke.wake.wakes > chan.wake.wakes && woke.wake.heard >= woke.v1, |
| 2435 | JSON.stringify(woke.wake)); |
| 2436 | |
| 2437 | // The device that pushed is not woken by its own push: it already knows the |
| 2438 | // version it just wrote, and a channel that told it would double every round. |
| 2439 | const selfWake = await page.evaluate(() => window.DaimondSync.wake()); |
| 2440 | check('and the device that pushed was not woken by its own push', |
| 2441 | selfWake.wakes === selfBefore, |
| 2442 | 'wakes ' + selfBefore + ' -> ' + selfWake.wakes); |
| 2443 | |
| 2444 | // (13b) The channel survives the gateway going away and coming back. A |
| 2445 | // restart is the ordinary case -- a deploy -- and a device that did not |
| 2446 | // reconnect would go quiet until its owner touched it, which is exactly the |
| 2447 | // state this whole section exists to end. |
| 2448 | // |
| 2449 | // ── ONE SIGHTING, UNEXPLAINED, 2026-08-28 ───────────────────────── |
| 2450 | // `pushLanded` below came back `api handler error` -- a plain-text 500 to |
| 2451 | // its GET of /api/sync, which threw out of `res.json()` and took the rest |
| 2452 | // of the file with it: 163 passed, and sections 13c and 14 never ran. The |
| 2453 | // store had been created eleven minutes earlier by the run before it and |
| 2454 | // was being restarted for the first time in its life. |
| 2455 | // |
| 2456 | // WHAT WAS ESTABLISHED. It did not recur: the same code answered 177/177 |
| 2457 | // twice afterwards against the same store, once warm. So it is not the |
| 2458 | // catch-up added to js/sync.js that day, and it is not this section. |
| 2459 | // |
| 2460 | // WHAT WAS NOT. Whether a cold o3db can 500 a read shortly after its first |
| 2461 | // restart. The restarted gateway is spawned here with `stdio: 'ignore'`, so |
| 2462 | // the one process that could have said why wrote nowhere -- which is the |
| 2463 | // first thing to change if this is seen again. It was not chased further, |
| 2464 | // deliberately: one sighting is a sighting, and a speculative guard against |
| 2465 | // a fault nobody has characterised is how a fix grows a second defect. |
| 2466 | const restarted = await restartGateway(); |
| 2467 | check('the gateway comes back after a restart', restarted === true, String(restarted)); |
| 2468 | if (restarted === true) { |
| 2469 | await child.page.waitForFunction( |
| 2470 | () => window.DaimondSync.wake().open === true, null, { timeout: 60000 }).catch(() => {}); |
| 2471 | const back = await child.page.evaluate(() => window.DaimondSync.wake()); |
| 2472 | check('and the idle device’s wake channel comes back with it, unprompted', |
| 2473 | back.open === true, JSON.stringify(back)); |
| 2474 | |
| 2475 | const v2 = await child.page.evaluate(() => window.DaimondSync.state().version); |
| 2476 | await page.evaluate(async (id) => { |
| 2477 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2478 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2479 | await app.rename_diamond(id, 'Woken-After-Restart'); |
| 2480 | }, shared); |
| 2481 | const beganAgain = Date.now(); |
| 2482 | await pushLanded(page, 'the rename after the restart'); |
| 2483 | await child.page.evaluate(async (v0) => { |
| 2484 | const t0 = Date.now(); |
| 2485 | while (Date.now() - t0 < 15000 && window.DaimondSync.state().version <= v0) { |
| 2486 | await new Promise(r => setTimeout(r, 100)); |
| 2487 | } |
| 2488 | }, v2); |
| 2489 | check('and it is woken again on the far side of the restart', |
| 2490 | (await nameSettles(child.page, shared, 'Woken-After-Restart')) === 'Woken-After-Restart', |
| 2491 | 'took ' + (Date.now() - beganAgain) + 'ms'); |
| 2492 | } |
| 2493 | |
| 2494 | // (13c) The plain-HTTP fallback does the same job. A front door that will not |
| 2495 | // carry a WebSocket upgrade is not a reason for an idle device to go blind: |
| 2496 | // the same wake arrives as a parked request answered early, and a completed |
| 2497 | // response wakes a throttled background tab exactly as a frame does. |
| 2498 | // |
| 2499 | // Every parked request this device makes from here on, and when each was |
| 2500 | // answered, so the second half of this section can say whether the one the |
| 2501 | // push woke was WAITING for it. Attached before the channel is switched over: |
| 2502 | // a park lasts three quarters of a minute, and a listener added later would |
| 2503 | // miss the one already open and conclude there was none. |
| 2504 | const parks = []; |
| 2505 | const above = (p) => p ? (p.url.match(/[?&]above=(\d+)/) || [])[1] : undefined; |
| 2506 | const onPark = (r) => { |
| 2507 | if (r.url().includes('/api/sync?above=')) parks.push({ r, url: r.url(), began: Date.now(), ended: 0, body: null }); |
| 2508 | }; |
| 2509 | const onParkDone = (r) => { |
| 2510 | const p = parks.find(q => q.r === r && !q.ended); |
| 2511 | if (!p) return; |
| 2512 | p.ended = Date.now(); |
| 2513 | p.body = r.response().then(res => res.text()).catch(() => ''); |
| 2514 | }; |
| 2515 | child.page.on('request', onPark); |
| 2516 | child.page.on('requestfinished', onParkDone); |
| 2517 | |
| 2518 | const pollOn = await child.page.evaluate(() => window.DaimondSync.wakeVia('poll')); |
| 2519 | check('the channel can be put onto parked requests instead of a socket', pollOn === 'poll', pollOn); |
| 2520 | await child.page.waitForFunction( |
| 2521 | () => window.DaimondSync.wake().open === true, null, { timeout: 15000 }).catch(() => {}); |
| 2522 | const pollChan = await child.page.evaluate(() => { |
| 2523 | window.__wakeProbe = { focus: 0, vis: 0, idle: 0 }; |
| 2524 | return { wake: window.DaimondSync.wake(), version: window.DaimondSync.state().version }; |
| 2525 | }); |
| 2526 | check('and parking one is enough to hold the channel open', pollChan.wake.open === true, |
| 2527 | JSON.stringify(pollChan.wake)); |
| 2528 | await page.evaluate(async (id) => { |
| 2529 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2530 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2531 | await app.rename_diamond(id, 'Woken-Without-A-Socket'); |
| 2532 | }, shared); |
| 2533 | const beganPoll = Date.now(); |
| 2534 | await pushLanded(page, 'the rename the parked request carries'); |
| 2535 | // What the push itself cost, which is most of what the budget below spends: |
| 2536 | // `pushLanded` re-pushes and re-reads the mailbox until this device's own |
| 2537 | // parcel is what it holds, and that loop is not the wake. |
| 2538 | const landedPoll = Date.now(); |
| 2539 | const polled = await child.page.evaluate(async (v0) => { |
| 2540 | const t0 = Date.now(); |
| 2541 | while (Date.now() - t0 < 10000 && window.DaimondSync.state().version <= v0) { |
| 2542 | await new Promise(r => setTimeout(r, 100)); |
| 2543 | } |
| 2544 | return { v1: window.DaimondSync.state().version, probe: window.__wakeProbe, wake: window.DaimondSync.wake() }; |
| 2545 | }, pollChan.version); |
| 2546 | const pollMs = Date.now() - beganPoll; |
| 2547 | check('a parked request wakes the idle device just as a socket does', |
| 2548 | (await nameSettles(child.page, shared, 'Woken-Without-A-Socket')) === 'Woken-Without-A-Socket' |
| 2549 | && polled.v1 > pollChan.version, |
| 2550 | 'version ' + pollChan.version + ' -> ' + polled.v1 + ' in ' + pollMs + 'ms'); |
| 2551 | check('and that route converges inside five seconds too', pollMs < 5000, |
| 2552 | pollMs + 'ms, of which the push took ' + (landedPoll - beganPoll) + 'ms'); |
| 2553 | check('and still with nothing happening on the device itself', |
| 2554 | polled.probe.focus === 0 && polled.probe.vis === 0 && polled.probe.idle === 0, |
| 2555 | JSON.stringify(polled.probe)); |
| 2556 | // The same question the socket route above is asked, and for the same reason: |
| 2557 | // WHAT pulled? push() catches up on its own every five seconds when it has |
| 2558 | // nothing to send (IDLE_PULL_MIN_MS, sync.js), which is inside the budget the |
| 2559 | // check above allows, so the version moving is no evidence at all that the |
| 2560 | // channel did it. The channel counts the pulls it causes, and only `wakeTo` |
| 2561 | // -- a version the channel itself heard -- increments that count. |
| 2562 | // |
| 2563 | // What is asked of `heard` is that the channel heard something ABOVE what the |
| 2564 | // device held, rather than that it heard as much as the device ended up with: |
| 2565 | // a pull answers with the mailbox as it stands, which may already have moved |
| 2566 | // past the version that was announced, and that is the mailbox being busy |
| 2567 | // rather than the channel being wrong. |
| 2568 | check('and that pull was the parked channel’s doing too, by its own count', |
| 2569 | polled.wake.wakes > pollChan.wake.wakes && polled.wake.heard > pollChan.version, |
| 2570 | 'wakes ' + pollChan.wake.wakes + ' -> ' + polled.wake.wakes |
| 2571 | + ', heard ' + polled.wake.heard + ' while holding ' + pollChan.version |
| 2572 | + ', pulled to ' + polled.v1); |
| 2573 | |
| 2574 | // And WAS it parked? The count above cannot say, and this is the half that was |
| 2575 | // never measured -- the gateway's own "waited past" line does not appear for |
| 2576 | // this account at all. A request that reaches the gateway AFTER the push is |
| 2577 | // answered on the spot, out of the version already in the store, and it |
| 2578 | // increments the very same counter: a channel whose park had lapsed and whose |
| 2579 | // next one turned up late is indistinguishable, by the count, from one that |
| 2580 | // waited. Without this, the section is satisfied by the idle catch-up in |
| 2581 | // push() and the parked wake is assumed rather than shown. |
| 2582 | // |
| 2583 | // So the request itself is watched, from the moment it leaves the browser. |
| 2584 | // What is waited for is one the gateway is demonstrably HOLDING: still |
| 2585 | // unanswered a full second after it was made, which no answer served out of |
| 2586 | // the store on arrival ever is, and with more of its own declared wait left |
| 2587 | // than the push below can take even at its slowest. Only then does the other |
| 2588 | // device push. A request in that state, answered after the push was made and |
| 2589 | // saying the version changed, came from the branch that was waiting for it and |
| 2590 | // from nowhere else. |
| 2591 | // |
| 2592 | // Both devices are let settle first: a round still on its way would end the |
| 2593 | // park before the push could, and prove nothing either way. |
| 2594 | await quiesce(child.page, 15000); |
| 2595 | await quiesce(page, 15000); |
| 2596 | // Longer than an answer made on arrival takes -- those come back in tens of |
| 2597 | // milliseconds on this loopback, and are seen doing so in the same run. |
| 2598 | const HELD_MS = 1000; |
| 2599 | const budget = (p) => ((p.url.match(/[?&]ms=(\d+)/) || [])[1] | 0); |
| 2600 | const parked = await (async () => { |
| 2601 | const t0 = Date.now(); |
| 2602 | while (Date.now() - t0 < 90000) { |
| 2603 | // The channel holds one park at a time, so the newest unanswered one is |
| 2604 | // the live one; anything older is an orphan of a torn-down loop. |
| 2605 | const open = parks.filter(q => !q.ended); |
| 2606 | const p = open.length ? open[open.length - 1] : null; |
| 2607 | // `pushLanded` gives up after 25s, so a park with more than that left |
| 2608 | // cannot run its wait out from under the push. |
| 2609 | if (p && Date.now() - p.began >= HELD_MS && p.began + budget(p) - Date.now() > 25000) return p; |
| 2610 | await sleep(200); |
| 2611 | } |
| 2612 | return null; |
| 2613 | })(); |
| 2614 | check('the gateway is HOLDING a request of the idle device’s own, unanswered', |
| 2615 | !!parked, parked |
| 2616 | ? parked.url.replace(/^.*\/api/, '/api') + ', open ' + (Date.now() - parked.began) |
| 2617 | + 'ms of its ' + budget(parked) + 'ms wait' |
| 2618 | : 'no request of the channel’s own was left open long enough to be woken, in 90s'); |
| 2619 | |
| 2620 | await page.evaluate(async (id) => { |
| 2621 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 2622 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 2623 | await app.rename_diamond(id, 'Woken-While-Parked'); |
| 2624 | }, shared); |
| 2625 | const parkPush = Date.now(); |
| 2626 | await pushLanded(page, 'the rename made while the other device had a request parked'); |
| 2627 | const parkLanded = Date.now(); |
| 2628 | for (let i = 0; i < 150 && parked && !parked.ended; i++) await sleep(100); |
| 2629 | child.page.off('request', onPark); |
| 2630 | child.page.off('requestfinished', onParkDone); |
| 2631 | let answer = null; |
| 2632 | try { answer = parked && parked.body ? JSON.parse(await parked.body) : null; } |
| 2633 | catch (e) { answer = null; } |
| 2634 | check('and the push wakes THAT request, rather than leaving it to run its wait out', |
| 2635 | !!answer && answer.waited === true && answer.changed === true |
| 2636 | && (answer.version | 0) > (above(parked) | 0) |
| 2637 | && parked.began < parkPush && parked.ended > parkPush, |
| 2638 | parked |
| 2639 | ? 'held on ' + above(parked) + ' for ' + (parked.ended - parked.began) |
| 2640 | + 'ms of its ' + budget(parked) + 'ms wait, answered ' + (parked.ended - parkPush) |
| 2641 | + 'ms after the push was made (which landed after ' + (parkLanded - parkPush) |
| 2642 | + 'ms): ' + JSON.stringify(answer) |
| 2643 | : 'nothing was parked to wake'); |
| 2644 | const applied = await (async () => { |
| 2645 | const name = await nameSettles(child.page, shared, 'Woken-While-Parked'); |
| 2646 | const after = await child.page.evaluate(() => ({ |
| 2647 | version: window.DaimondSync.state().version, |
| 2648 | probe: window.__wakeProbe, |
| 2649 | wake: window.DaimondSync.wake(), |
| 2650 | })); |
| 2651 | return { name, ...after }; |
| 2652 | })(); |
| 2653 | check('and the device applies what that answer told it, still with nothing happening on it', |
| 2654 | !!parked && applied.name === 'Woken-While-Parked' && applied.version > (above(parked) | 0) |
| 2655 | && applied.probe.focus === 0 && applied.probe.vis === 0 && applied.probe.idle === 0 |
| 2656 | && applied.wake.wakes > polled.wake.wakes, |
| 2657 | 'version ' + (parked ? above(parked) : '?') + ' -> ' + applied.version + ', wakes ' |
| 2658 | + polled.wake.wakes + ' -> ' + applied.wake.wakes + ', ' + JSON.stringify(applied.probe)); |
| 2659 | |
| 2660 | // The channel carries version integers and nothing else. What the gateway |
| 2661 | // parks on and answers with is read here directly, so a future change that |
| 2662 | // smuggled content down it would be caught rather than assumed against. |
| 2663 | // |
| 2664 | // The second half of this asks what a wait NOTHING MOVED UNDER answers, so |
| 2665 | // the premise has to be established rather than assumed. Both are needed and |
| 2666 | // neither was there: the mate had just pulled, and a pull schedules a push of |
| 2667 | // whatever this device adds over the pulled base -- so a parcel was still on |
| 2668 | // its way while the probe parked. And `above` was read from THIS device's |
| 2669 | // cursor, which the mate's round had already left behind, so the probe was |
| 2670 | // parking over a change that had happened rather than waiting for one that |
| 2671 | // had not. It reported `changed: true`, correctly, and the check called it an |
| 2672 | // invention. |
| 2673 | await quiesce(child.page, 15000); |
| 2674 | await quiesce(page, 15000); |
| 2675 | const bare = await page.evaluate(async () => { |
| 2676 | // The version the GATEWAY holds this instant, not this device's belief |
| 2677 | // about it. |
| 2678 | const r0 = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 2679 | const v = ((await r0.json()).version) | 0; |
| 2680 | const r = await fetch('/api/sync?above=' + v + '&ms=1000&w=wkprobe', |
| 2681 | { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 2682 | return { status: r.status, json: await r.json(), above: v }; |
| 2683 | }); |
| 2684 | check('a wake answer carries a version and nothing else — no blob, no device, no account', |
| 2685 | bare.status === 200 && bare.json.waited === true |
| 2686 | && Object.keys(bare.json).sort().join(',') === 'changed,ok,version,waited', |
| 2687 | JSON.stringify(bare.json).slice(0, 160)); |
| 2688 | check('and a wait that nothing moved under says so rather than inventing a change', |
| 2689 | bare.json.changed === false, 'parked above ' + bare.above + ' — ' + JSON.stringify(bare.json)); |
| 2690 | |
| 2691 | // The gateway is deliberately restarted above, so a wake socket that was open |
| 2692 | // across it reports a failed connection while it is down. That is the reconnect |
| 2693 | // working, not a fault, and it is the only WebSocket noise this run may make. |
| 2694 | const wakeNoise = /WebSocket connection to '[^']*\/api\/sync\/ws/; |
| 2695 | const cerrs = child.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|409|426|502|Unauthorized/.test(e) && !wakeNoise.test(e)); |
| 2696 | check('no unexpected console errors on the second device', cerrs.length === 0, |
| 2697 | cerrs.slice(0, 3).join(' | ')); |
| 2698 | |
| 2699 | const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|409|426|502|Unauthorized/.test(e) && !wakeNoise.test(e)); |
| 2700 | check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 2701 | } catch (e) { |
| 2702 | check('verify_sync ran without throwing', false, String(e && e.message || e)); |
| 2703 | } finally { |
| 2704 | await child?.close?.().catch?.(() => {}); |
| 2705 | await s.close?.().catch?.(() => {}); |
| 2706 | } |
| 2707 | |
| 2708 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 2709 | process.exit(bad.length ? 1 : 0); |