diff --git a/.beads/export-state.json b/.beads/export-state.json index 8157a1f..e9c4146 100644 --- a/.beads/export-state.json +++ b/.beads/export-state.json @@ -1 +1 @@ -{"last_dolt_commit":"9ahle4l11manc0cmnf04qa1o1q8ccgnp","timestamp":"2026-05-23T09:21:52.041611-06:00","issues":170,"memories":11} \ No newline at end of file +{"last_dolt_commit":"lu7umj2vnhgkki0mrahoa2putd1eof9g","timestamp":"2026-06-17T18:23:56.184368-06:00","issues":182,"memories":13} \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e278cc7..5d94ec0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -133,6 +133,8 @@ {"id":"attn-nnj.2","title":"Phase 0b: Local data model + working copy","description":"Rust-only foundation. Typed IDs, serde model types, JSON/JSONL local store at ~/.attn/reviews/, WorkingCopyService replacing direct fs::write, revision journal, watcher self-write distinction, empty ReviewManager scaffold, AppState refactor for tab+room routing.","notes":"Spec: planning/collab/data-model.md §Local Replicas + §Rust Architecture Changes. AppState shape per amendments.md (RoomRuntimeHandle + file_to_room mapping).","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:12Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:37Z","closed_at":"2026-05-19T17:01:37Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.2","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-nnj.1","title":"Phase 0a: Crypto foundations","description":"Rust-only crypto crate (attn-collab-crypto): cipher suite primitives, key derivation, hashcash mint+verify, ID helpers. Frontend never holds ciphertext in v2 — IPC delivers plaintext ReviewEvents per data-model.md §Webview IPC Changes, so no TS crypto is needed for native. Test-vector corpus ships alongside Rust impl for forward compat (browser/Phase 6 will revisit WASM-vs-TS).","notes":"Spec: planning/collab/crypto-spec.md. Decision #4 locks the suite: XChaCha20-Poly1305 + Ed25519 + HKDF-SHA-256 + RFC 8785 JCS + base64url-no-pad. No agility in v2.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:11Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:21Z","closed_at":"2026-05-19T17:01:21Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.1","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-nnj","title":"attn collab v2","description":"End-to-end encrypted review collaboration over local markdown. Owner shares working copy via snapshot graph + encrypted event log; reviewers/agents add comments and suggestions anchored to snapshots; owner accepts suggestions locally. Transport via WebRTC DataChannel (Rust webrtc-rs) and a bounded encrypted mailbox on Cloudflare Workers/DO/R2.","notes":"Specs: planning/collab/{data-model,crypto-spec,relay-spec,amendments}.md. amendments.md overrides the others where they conflict. 16 design decisions locked.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:10Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:47Z","closed_at":"2026-05-19T18:01:47Z","close_reason":"attn collab v2: all implementation closed across phases 0a/0b/0c/1/2/3a/3b/4/5/6, UI/UX, and cross-cutting. 432 Rust tests + 286 relay tests + 22.4 KB gz browser bundle.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"attn-qgd","title":"Read-only HTML document sharing over encrypted rooms","description":"Allow sharing .html/.htm files through the existing E2E-encrypted review transport in READ-ONLY mode (no collaborative editing). Reviewer opens and reads the rendered HTML; commenting/anchoring deferred. Frozen snapshot, reuses Svelte viewer (HtmlViewer via srcdoc on reviewer side).","design":"PLAN (read-only HTML share v1):\n\nRUST / model (src/review/model.rs)\n- Add DocType enum { Markdown, Html } (serde snake_case).\n- SnapshotPlaintext: rename markdown-\u003econtent:String, add doc_type:DocType, make anchor_index:Option\u003cAnchorIndex\u003e (None for html; html is read-only, no comment anchors yet).\n\nRUST / owner publish (src/review/bootstrap.rs)\n- is_shareable_path = markdown||html; rename markdown_targets-\u003eshareable_targets, collect_markdown-\u003ecollect_shareable, validate_share_targets allow html. find_room_for_path ext checks include html.\n- publish_snapshot: branch by ext — markdown builds anchor_index (Some); html sets content=html source, anchor_index=None, doc_type=Html.\n\nRUST consumers of SnapshotPlaintext.markdown: store.rs, manager.rs (3078,4503), bootstrap tests — update to content/doc_type.\n\nWEB wire types (web/src/lib/types.ts): SnapshotCreatedBody.inlineSnapshot -\u003e { docType, content, anchorIndex? }. Add DocType.\n\nWEB browser reviewer (browser-session.ts absorbSnapshotCreated): store snapshotDocType + content. BrowserReviewApp.svelte: branch html-\u003eHtmlViewer(srcdoc, read-only) else Editor(markdown, editable=false).\n\nWEB native reviewer (App.svelte): reviewSnapshot has docType; html branch renders HtmlViewer instead of Editor; guard collab-seed to markdown only.\n\nWEB HtmlViewer.svelte: accept raw html content via srcdoc (reviewer has no local file) in addition to path mode (owner local view via attn://).\n\nOUT OF SCOPE v1: comments/anchors on html, suggesting mode, live updates.","notes":"HEADLESS E2E now passing (scripts/test-html-share-e2e.sh, task test:html-share).\nDrives the REAL stack: Miniflare relay + owner daemon shares tests/fixtures/sample.html (hybrid) + reviewer daemon joins the invite. Asserts via automation CLI: owner renders local .html (path mode); owner mints invite (read from window.__attn_review_store__.currentShare); reviewer window SWITCHES to HtmlViewer; reviewer iframe srcdoc carries owner's bytes (marker 'Hello from an HTML file'); reviewer is read-only (no .ProseMirror editor). All PASS.\nFound+fixed during e2e: build.rs short-circuits to prebuilt web/dist/index.html, so web changes need 'npm run build' (web) before cargo build to re-embed — confirmed stale bundle was masking the change.\nRegression: scripts/test-review-e2e.sh still 13 PASS / 0 FAIL after frontend rebuild.\nHonors ATTN_SKIP_HTML_SHARE_E2E=1.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-06-17T23:59:23Z","created_by":"James Lal","updated_at":"2026-06-18T00:23:55Z","started_at":"2026-06-17T23:59:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"attn-48q","title":"Local review store GC: prune expired rooms, acked outbox, superseded snapshot blobs","description":"~/.attn/reviews/rooms/\u003croomId\u003e/ grows without bound: events.jsonl, outbox.jsonl, revisions/\u003cfileId\u003e.jsonl, snapshots/, and blobs/ are all append-only with no eviction; only explicit room delete removes anything (src/review/store.rs:170-195).\n\nWhat needs to be done:\n1. GC rooms whose relay TTL has passed — expiry is known at join time; mirror the relay lifecycle locally with a grace window (e.g. delete N days after expiresAt).\n2. Prune acked entries from outbox.jsonl (currently grows with every draft until room delete).\n3. Compact revisions/\u003cfileId\u003e.jsonl past some depth (per-file journal, no eviction today).\n4. Delete superseded snapshot blobs — amendments.md decision #10 / supersedesSnapshotId gives the signal, but local cleanup is undefined.\n\nRun GC opportunistically (daemon start, room open/close) rather than a background timer.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-10T20:21:27Z","created_by":"James Lal","updated_at":"2026-06-10T20:21:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-ba8","title":"Share folders via right-click context menu in the file tree","description":"Folder sharing is implemented backend-side (bootstrap.rs validate_share_targets walks a dir for *.md) but folder rows in FileTree.svelte have no context menu — only file rows do. Wrap folder rows in a ContextMenu with a 'Share folder' item -\u003e onShare(folderPath). Works in tree view (+ folder view).","notes":"Implemented: folder rows in FileTree.svelte now wrap a ContextMenu (Share folder / copy paths / open external), composing ContextMenuTrigger + CollapsibleTrigger child snippets onto one button. App.svelte openShareDialogForPath(path, isDir) allows dirs (skips markdown gate, no navigate), threads isDir through the name-prompt resume, and ShareDialog now targets shareTargetPath ?? activePath. svelte-check 0 errors. PENDING: runtime check that left-click still expands + right-click opens the menu.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:25Z","closed_at":"2026-05-24T15:43:25Z","close_reason":"Fixed + verified in 2a69ca9: collabSeedReady gate (unit tests) for the blank editor; folder ContextMenu + share-target wiring (live-daemon verified) for folder sharing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-e98","title":"Inline 'shared' marker on shared files/folders in sidebar tree","description":"Owner can't tell which sidebar files are in a room. Expose the owner's shared paths (Rust AppState.file_to_room / bindings.json) to the frontend via IPC, then render an inline marker (◆) on shared file AND folder rows in FileTree.svelte. User chose inline-marks-only (no pinned section).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:52:53Z","closed_at":"2026-05-24T15:52:53Z","close_reason":"Implemented + live-verified: frontend-derived sharedPaths from owner snapshots; ◆ marker on shared file rows + containing folder rows. No new IPC needed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-zhr","title":"Wire resolve-comment IPC (Resolve button is UI-only)","description":"ITEM 3: ReviewMarginCard.svelte [Resolve] exists but is UI-only (TODO ~line 54) — no IPC, no backend. Add a ReviewResolveComment command + CommentResolved event (round-trips like accept/reject), persist + propagate to peers, collapse the thread to its resolved strip, and provide a reopen path. No reviewResolveComment export in ipc.ts today.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:33Z","created_by":"James Lal","updated_at":"2026-05-23T05:26:48Z","started_at":"2026-05-23T05:19:10Z","closed_at":"2026-05-23T05:26:48Z","close_reason":"Wired the resolve-comment write path end to end: Rust ReviewCommand::ResolveComment + manager.resolve_comment (mints CommentResolved via send_event_sync, propagates to peers), IpcMessage::ReviewResolveComment + dispatch + camelCase parse test (passing); web reviewResolveComment IPC + ReviewMargin.resolveThread replaces the UI-only dismiss (optimistic pendingDismiss + durable event). Read path (reconstructThreads flips thread.resolved -\u003e collapse to strip) already existed. cargo build + bin ipc tests green; svelte-check clean; 28 web test files pass.","dependencies":[{"issue_id":"attn-zhr","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -178,16 +180,16 @@ {"id":"attn-nnj.3.7","title":"Confidence calibration sweep (post-impl)","description":"After the resolver and corpus land, sweep the confidence weights from data-model.md §Anchor Resolution against the real corpus and tune them. The numbers in the spec are starting values — Decision #15 explicitly calls them tunable. Output is a short report justifying the chosen thresholds plus any weight adjustments.","acceptance_criteria":"- A sweep harness (script or test) iterates ranges around the starting weights and records per-case verdicts.\n- planning/collab/confidence-calibration.md captures: methodology, corpus characteristics, the sweep matrix, the chosen final weights, and any changes vs. data-model.md starting values.\n- If weights changed, both ConfidenceWeights constants (Rust + TS) and data-model.md are updated to match (kept in sync).\n- The corpus continues to pass with the tuned weights.","notes":"Spec: planning/collab/amendments.md Decision #15 + §Anchor resolver disagreement policy (line ~131) explicitly defers calibration to after Phase 1. Don't run this until the corpus is comprehensive; otherwise you'll overfit.","status":"closed","priority":3,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:32Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:08Z","started_at":"2026-05-19T01:57:30Z","closed_at":"2026-05-19T04:07:08Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3.6","type":"blocks","created_at":"2026-05-18T16:29:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"attn-nnj.9","title":"Phase 6: Browser + remote agents","description":"Browser review client at https://attn.dev/review/\u003croomId\u003e#key=... Memory-only secret persistence per decision #13 (URL fragment parsed once, stripped via history.replaceState, held only in JS heap). Remote agent participant type. CLI subcommands for local agents. INCLUDES the crypto sourcing decision: compile attn-collab-crypto Rust crate to WASM OR write a TS implementation against the shared test-vector corpus.","notes":"Decision #13: URL fragment parsed once, immediately stripped via history.replaceState, held only in JS heap. No sessionStorage/IndexedDB/cookies. Browser-crypto decision (WASM vs TS) is the gating discovery item here.","status":"closed","priority":3,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:17Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:01Z","closed_at":"2026-05-19T18:01:01Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.9","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"attn-6dp","title":"Editorial polish: reactions, @mentions, suggestion batching","description":"ITEM 3 (further-out polish): emoji reactions on comments; @mentions with autocomplete + notifications; suggestion batching (multi-select, accept-all/reject-all). None exist today. Lower priority than the toolbar/resolve/replies/inbox track.","status":"open","priority":4,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:35Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:35Z","dependencies":[{"issue_id":"attn-6dp","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"beads-dolt-db-can-be-wiped-by-concurrent","value":"Beads dolt DB can be wiped by concurrent agent operations when parallel subagents in git worktrees race on bd writes via auto-export hooks. Recovery: 'git show \u003clast-good-sha\u003e:.beads/issues.jsonl \u003e .beads/issues.jsonl' then 'bd import'. Mitigation: never have subagents call bd directly; serialize bd operations in the parent only."} -{"_type":"memory","key":"spec-deviation-flagged-by-5-5-room-creation","value":"Spec deviation flagged by 5.5: room creation body MUST include admissionKey (base64url 32 bytes) — not in published relay-spec.md but the only viable resolution to the chicken-and-egg admission HMAC. First POST cannot verify admission (no stored key yet); rejoin verifies normally. Update amendments.md when the relay endpoint chain is fully landed."} -{"_type":"memory","key":"svelte-5-runes-state-derived-effect-outside-svelte","value":"Svelte 5 runes ($state, $derived, $effect) outside .svelte components require the .svelte.ts file extension — Vite resolves the bare import path without the .ts. See web/src/lib/hooks/is-mobile.svelte.ts and web/src/lib/review/store.svelte.ts."} -{"_type":"memory","key":"root-cause-candidate-for-cross-machine-collab-desync","value":"ROOT CAUSE candidate for cross-machine collab desync: the WebRTC stack is STUN-only, NO TURN server (confirmed: src/review/transport/webrtc.rs only sets DEFAULT_STUN_SERVER=stun.l.google.com; planning/collab/security-review.md 'WebRTC TURN credentials: none today (no TURN server stood up)'; relay-spec.md 'STUN only'). Across real networks, symmetric NAT means many peer-pairs CANNOT form a direct DataChannel without TURN, so the WebRTC mesh only PARTIALLY forms. send_collab (manager.rs:1186) skips the relay when it judges the mesh 'complete' from its own connection view -\u003e when a peer is actually only reachable via relay, edits/collab silently drop asymmetrically. Hits both co-typing (mesh path) and comments (if WS is downgraded to signaling-only while believed-up WebRTC is broken). Works on localhost (no NAT). User confirmed desync is across-machines, both edits AND comments. Fixes: (1) stand up TURN, (2) make skip-relay decision conservative / always relay-fallback with receiver-side dedup. Reproduce via Docker+netem symmetric-NAT (attn-orf)."} +{"_type":"memory","key":"headless-review-agent-deadlocked-on-collab-handle-line","value":"Headless review agent deadlocked on collab: handle_line matched on current_room.lock().clone() — a MutexGuard temporary in a match scrutinee lives for the whole match block, so the lock was held across manager.submit(), whose synchronous EventImported sink re-locks the same mutex -\u003e re-entrant std::Mutex deadlock. Fix (commit f60cde1): bind lock to a let before matching. KEY: the GUI daemon is NOT affected — its update_tx sink is proxy.send_event(UserEvent::Review) (non-blocking, never re-locks AppState). So the user's GUI 'changes don't appear on the other side' was NOT this deadlock; most likely attn-cqk (reviewer margin not rendering, fixed). General rule: never hold a lock across manager.submit() — the sink runs synchronously on the same thread."} +{"_type":"memory","key":"webrtc-is-working-in-the-gui-daemon-2","value":"WebRTC IS working in the GUI daemon (2-party localhost): DataChannel connects in ~100ms and the Rust manager emits ConnectionChanged 'live_direct'. The connection BADGE showing 'Offline' is a FRONTEND bug, not a transport problem — store.svelte.ts applyConnection() only sets this.connection when currentRoomId===payload.roomId, and the owner stays on its local doc (attn-0wa) so the shared room's live_direct never reaches the badge. This is the likely root of the user's 'sync feels broken / webrtc isn't primary' perception: WebRTC is primary+connected, the UI just can't show it. test:webrtc:live reproduces (badge FAIL, data still converges)."} {"_type":"memory","key":"collab-live-co-typing-convergence-works-headlessly-on","value":"Collab (live co-typing) convergence WORKS headlessly on localhost: collab-probe with 3 attn-agents shows reviewerB's SendCollab reaches owner+rvC (both emit a collab_signal update). The mailbox/WS relay path DOES surface relay-delivered collab as TransportEvent::CollabSignal (ws.rs:823,871) — so relay-fallback collab IS wired (earlier 'WS drops collab' hypothesis disproven). Comments also converge headlessly. So the daemon/transport layer converges for both on localhost; the cross-machine drop must be triggered by real-topology conditions (partial mesh / NAT), still to be reproduced in the Docker harness."} +{"_type":"memory","key":"review-event-convergence-comments-suggestions-works-on-local","value":"review-event convergence (comments/suggestions) works on localhost in ALL THREE room modes (live/hybrid/async). Proven by tests/review_sync_convergence.rs: a 3-peer room (owner+2 reviewers) against the real Miniflare relay — reviewerB's comment reaches BOTH owner AND reviewerC every time. Review events are relay-mediated (outbox POST -\u003e relay broadcastFreshEnvelopes -\u003e all WS subscribers -\u003e InboundPipeline), independent of WebRTC; every peer (even in Live mode where selector mailbox=None) still opens the inbound WS subscription ('started room runtime outbox+ws subscribed'). So asymmetric 'changes on one side not the other' is NOT in the relay-mediated review-event path on localhost. Suspects narrowed to: (a) WebRTC-mesh/co-typing OT under imperfect topology (needs Docker/netem), or (b) frontend rendering/snapshot-republish churn (see attn-0wa)."} {"_type":"memory","key":"appstate-file-to-room-evolved-from-hashmap-pathbuf","value":"AppState.file_to_room evolved from HashMap\u003cPathBuf, RoomId\u003e (per amendments.md original) to HashMap\u003cPathBuf, (RoomId, FileId)\u003e (after 2.5). The FileId in the value was added because LocalRevision persistence needs the file's review identity, not just the room. Update amendments.md to reflect or revert when ReviewManager (2.8) is more fully wired."} -{"_type":"memory","key":"attn-web-uses-npm-not-pnpm-web-package","value":"attn web/ uses npm (not pnpm) — web/package-lock.json is the lockfile; scripts/build.sh, Taskfile.yml, build.rs all call 'npm ci'. Using pnpm install drops transitive markdown-it and breaks the Vite build."} -{"_type":"memory","key":"comrak-emits-nodevalue-math-as-inline-only-never","value":"comrak emits NodeValue::Math as INLINE only, never block-level. Display math ($$...$$) gets absorbed into the parent paragraph's normalized text. Block-level AnchorBlockKind::Math is only reachable via fenced ```math info-string. See src/review/anchors/index.rs::dollar_display_math_is_absorbed_into_paragraph test. Affects anchor resolver (3.4) — display math doesn't get its own anchor block to remap."} {"_type":"memory","key":"hard-constraint-user-no-turn-must-not-use","value":"HARD CONSTRAINT (user): NO TURN. Must not use a TURN server, ever. Implication: symmetric-NAT peer-pairs cannot form a direct WebRTC DataChannel, so the relay MUST be a reliable DATA fallback for un-meshable pairs (not purely signaling). Bulletproof no-TURN design: per-peer routing — send over the direct DataChannel when that specific pair is robustly connected; send a TARGETED relay copy (relay already supports target.deviceId routing via deliverableTo/env_by_target) only to peers WebRTC can't reach; receiver dedups by EventId/serverSeq so double-delivery is harmless. Maximizes WebRTC, minimizes relay cost, zero silent drops. Supersedes any TURN plan."} -{"_type":"memory","key":"webrtc-is-working-in-the-gui-daemon-2","value":"WebRTC IS working in the GUI daemon (2-party localhost): DataChannel connects in ~100ms and the Rust manager emits ConnectionChanged 'live_direct'. The connection BADGE showing 'Offline' is a FRONTEND bug, not a transport problem — store.svelte.ts applyConnection() only sets this.connection when currentRoomId===payload.roomId, and the owner stays on its local doc (attn-0wa) so the shared room's live_direct never reaches the badge. This is the likely root of the user's 'sync feels broken / webrtc isn't primary' perception: WebRTC is primary+connected, the UI just can't show it. test:webrtc:live reproduces (badge FAIL, data still converges)."} -{"_type":"memory","key":"headless-review-agent-deadlocked-on-collab-handle-line","value":"Headless review agent deadlocked on collab: handle_line matched on current_room.lock().clone() — a MutexGuard temporary in a match scrutinee lives for the whole match block, so the lock was held across manager.submit(), whose synchronous EventImported sink re-locks the same mutex -\u003e re-entrant std::Mutex deadlock. Fix (commit f60cde1): bind lock to a let before matching. KEY: the GUI daemon is NOT affected — its update_tx sink is proxy.send_event(UserEvent::Review) (non-blocking, never re-locks AppState). So the user's GUI 'changes don't appear on the other side' was NOT this deadlock; most likely attn-cqk (reviewer margin not rendering, fixed). General rule: never hold a lock across manager.submit() — the sink runs synchronously on the same thread."} -{"_type":"memory","key":"review-event-convergence-comments-suggestions-works-on-local","value":"review-event convergence (comments/suggestions) works on localhost in ALL THREE room modes (live/hybrid/async). Proven by tests/review_sync_convergence.rs: a 3-peer room (owner+2 reviewers) against the real Miniflare relay — reviewerB's comment reaches BOTH owner AND reviewerC every time. Review events are relay-mediated (outbox POST -\u003e relay broadcastFreshEnvelopes -\u003e all WS subscribers -\u003e InboundPipeline), independent of WebRTC; every peer (even in Live mode where selector mailbox=None) still opens the inbound WS subscription ('started room runtime outbox+ws subscribed'). So asymmetric 'changes on one side not the other' is NOT in the relay-mediated review-event path on localhost. Suspects narrowed to: (a) WebRTC-mesh/co-typing OT under imperfect topology (needs Docker/netem), or (b) frontend rendering/snapshot-republish churn (see attn-0wa)."} +{"_type":"memory","key":"root-cause-candidate-for-cross-machine-collab-desync","value":"ROOT CAUSE candidate for cross-machine collab desync: the WebRTC stack is STUN-only, NO TURN server (confirmed: src/review/transport/webrtc.rs only sets DEFAULT_STUN_SERVER=stun.l.google.com; planning/collab/security-review.md 'WebRTC TURN credentials: none today (no TURN server stood up)'; relay-spec.md 'STUN only'). Across real networks, symmetric NAT means many peer-pairs CANNOT form a direct DataChannel without TURN, so the WebRTC mesh only PARTIALLY forms. send_collab (manager.rs:1186) skips the relay when it judges the mesh 'complete' from its own connection view -\u003e when a peer is actually only reachable via relay, edits/collab silently drop asymmetrically. Hits both co-typing (mesh path) and comments (if WS is downgraded to signaling-only while believed-up WebRTC is broken). Works on localhost (no NAT). User confirmed desync is across-machines, both edits AND comments. Fixes: (1) stand up TURN, (2) make skip-relay decision conservative / always relay-fallback with receiver-side dedup. Reproduce via Docker+netem symmetric-NAT (attn-orf)."} +{"_type":"memory","key":"spec-deviation-flagged-by-5-5-room-creation","value":"Spec deviation flagged by 5.5: room creation body MUST include admissionKey (base64url 32 bytes) — not in published relay-spec.md but the only viable resolution to the chicken-and-egg admission HMAC. First POST cannot verify admission (no stored key yet); rejoin verifies normally. Update amendments.md when the relay endpoint chain is fully landed."} +{"_type":"memory","key":"svelte-5-runes-state-derived-effect-outside-svelte","value":"Svelte 5 runes ($state, $derived, $effect) outside .svelte components require the .svelte.ts file extension — Vite resolves the bare import path without the .ts. See web/src/lib/hooks/is-mobile.svelte.ts and web/src/lib/review/store.svelte.ts."} {"_type":"memory","key":"verified-via-real-daemon-automation-api-scripts-test","value":"VERIFIED via real daemon automation API (scripts/test-editorial-e2e.sh, attn --eval/--click/--query): attn-0wa (owner stays local / reviewer enters shared-doc), attn-bit (selection toolbar appears + Comment opens composer — full UI path), attn-1rm (reply imported by owner + grouped into 1 threadId), attn-zhr (CommentResolved imported by owner). 10 passed / 0 failed / 1 pend. The pend is attn-cqk: the right-rail review margin overlay does not mount on panelOpen toggle under automation (reviewer can't see cards) — pre-existing, separate from these features. Also learned: build.rs early-returns when web/dist/index.html exists, so testing the daemon requires 'npm --prefix web run build' before 'cargo build' to embed frontend changes."} +{"_type":"memory","key":"beads-dolt-db-can-be-wiped-by-concurrent","value":"Beads dolt DB can be wiped by concurrent agent operations when parallel subagents in git worktrees race on bd writes via auto-export hooks. Recovery: 'git show \u003clast-good-sha\u003e:.beads/issues.jsonl \u003e .beads/issues.jsonl' then 'bd import'. Mitigation: never have subagents call bd directly; serialize bd operations in the parent only."} +{"_type":"memory","key":"attn-web-uses-npm-not-pnpm-web-package","value":"attn web/ uses npm (not pnpm) — web/package-lock.json is the lockfile; scripts/build.sh, Taskfile.yml, build.rs all call 'npm ci'. Using pnpm install drops transitive markdown-it and breaks the Vite build."} +{"_type":"memory","key":"comrak-emits-nodevalue-math-as-inline-only-never","value":"comrak emits NodeValue::Math as INLINE only, never block-level. Display math ($$...$$) gets absorbed into the parent paragraph's normalized text. Block-level AnchorBlockKind::Math is only reachable via fenced ```math info-string. See src/review/anchors/index.rs::dollar_display_math_is_absorbed_into_paragraph test. Affects anchor resolver (3.4) — display math doesn't get its own anchor block to remap."} diff --git a/Taskfile.yml b/Taskfile.yml index 96792df..5acba3e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -114,6 +114,11 @@ tasks: cmds: - scripts/test-dual-instance-smoke.sh + test:html-share: + desc: "Headless E2E for read-only HTML doc sharing (attn-qgd) — relay + owner shares .html + reviewer renders it read-only over the encrypted transport. Honors ATTN_SKIP_HTML_SHARE_E2E=1." + cmds: + - scripts/test-html-share-e2e.sh + test:webrtc: desc: "Run the WebRTC end-to-end test (Rust transport + bash daemon shape). Honors ATTN_SKIP_WEBRTC_E2E=1 on flaky CI." cmds: diff --git a/scripts/test-html-share-e2e.sh b/scripts/test-html-share-e2e.sh new file mode 100755 index 0000000..99109f1 --- /dev/null +++ b/scripts/test-html-share-e2e.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Headless end-to-end test for read-only HTML document sharing (attn-qgd). +# +# Proves the full owner→reviewer path over the REAL local relay: +# +# 1. Boot a Miniflare relay (wrangler dev --local). +# 2. Boot two isolated daemons (owner + reviewer) via ATTN_HOME isolation. +# 3. Owner shares an .html file → mints a room, publishes a read-only +# HTML snapshot over the encrypted transport. +# 4. Reviewer joins the invite → its window switches to the shared doc. +# 5. Assert the reviewer renders the HTML in the sandboxed viewer +# (data-slot=html-viewer, iframe srcdoc carrying the owner's bytes) and +# does NOT mount the markdown editor (read-only, no collab). +# +# Everything is driven through the automation CLI (--eval/--query/--wait-for), +# so no human interaction is needed. Honors ATTN_SKIP_HTML_SHARE_E2E=1 as a CI +# escape hatch (the relay + webview need a display + loopback, flaky on some +# headless infra). +# +# Usage: +# scripts/test-html-share-e2e.sh +# ATTN_RELAY_URL=http://localhost:8788 scripts/test-html-share-e2e.sh + +set -euo pipefail + +if [ "${ATTN_SKIP_HTML_SHARE_E2E:-0}" = "1" ]; then + echo "SKIP html-share e2e (ATTN_SKIP_HTML_SHARE_E2E=1)" + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +: "${ATTN_RELAY_URL:=http://localhost:8787}" +: "${ATTN_BIN:=$PROJECT_DIR/target/debug/attn}" +FIXTURE="$PROJECT_DIR/tests/fixtures/sample.html" +# A marker that appears in the rendered HTML (and only there) so we can assert +# the reviewer actually received + mounted the owner's bytes. +MARKER="Hello from an HTML file" + +# Owner opens the .html fixture; reviewer opens a DIFFERENT markdown fixture so +# we can prove its window SWITCHES to the shared HTML doc on join (rather than +# already showing it). +export ATTN_DUAL_OWNER="/tmp/attn-html-share-owner" +export ATTN_DUAL_REVIEWER="/tmp/attn-html-share-reviewer" +export ATTN_DUAL_FIXTURE="$FIXTURE" +export ATTN_DUAL_REVIEWER_FIXTURE="$PROJECT_DIR/tests/fixtures/basic.md" +export ATTN_BIN +export ATTN_RELAY_URL + +RELAY_PID="" +RELAY_LOG="/tmp/attn-html-share-relay.log" +FAILURES=0 + +log() { printf '==> %s\n' "$*"; } +pass() { printf 'PASS %s\n' "$*"; } +fail() { printf 'FAIL %s\n' "$*"; FAILURES=$((FAILURES + 1)); } + +require_bin() { + if [ ! -x "$ATTN_BIN" ]; then + log "attn binary missing at $ATTN_BIN — building (cargo build)" + cargo build + fi +} + +start_relay() { + if [ ! -d "$PROJECT_DIR/relay/node_modules" ]; then + log "Installing relay deps (relay/npm ci)" + (cd "$PROJECT_DIR/relay" && npm ci) >/dev/null + fi + log "Starting Miniflare relay → $ATTN_RELAY_URL" + ( + cd "$PROJECT_DIR/relay" + exec npm run dev + ) >"$RELAY_LOG" 2>&1 & + RELAY_PID=$! + local deadline=$(( $(date +%s) + 60 )) + while [ "$(date +%s)" -lt "$deadline" ]; do + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + fail "relay exited early — see $RELAY_LOG"; tail -20 "$RELAY_LOG" >&2 || true; return 1 + fi + if curl -fsS "$ATTN_RELAY_URL/health" >/dev/null 2>&1; then + log "Relay health OK"; return 0 + fi + sleep 0.3 + done + fail "relay /health never came up — see $RELAY_LOG"; tail -20 "$RELAY_LOG" >&2 || true; return 1 +} + +stop_relay() { + [ -z "${RELAY_PID:-}" ] && return 0 + kill -0 "$RELAY_PID" 2>/dev/null || return 0 + kill "$RELAY_PID" 2>/dev/null || true + pkill -P "$RELAY_PID" 2>/dev/null || true + local i=0 + while kill -0 "$RELAY_PID" 2>/dev/null && [ $i -lt 30 ]; do sleep 0.1; i=$((i + 1)); done + kill -9 "$RELAY_PID" 2>/dev/null || true + wait "$RELAY_PID" 2>/dev/null || true + RELAY_PID="" +} + +__cleanup_ran=0 +cleanup() { + [ "$__cleanup_ran" = "1" ] && return 0 + __cleanup_ran=1 + log "Cleaning up..." + stop_dual || true + stop_relay || true +} + +# shellcheck source=scripts/lib/dual-instance.sh +source "$SCRIPT_DIR/lib/dual-instance.sh" +trap cleanup EXIT INT TERM + +# ---------- run ---------- + +require_bin +start_relay + +log "Booting owner ($FIXTURE) + reviewer daemons" +start_dual + +# Owner renders the local HTML file (path mode) — the viewer must mount. +__attn_dual_wait_one "$ATTN_DUAL_OWNER" '[data-slot="html-viewer"]' 20000 \ + && pass "owner renders the local .html file in HtmlViewer" \ + || fail "owner never rendered [data-slot=html-viewer]" + +# Reviewer starts on the markdown fixture (NOT the shared doc yet). +__attn_dual_wait_one "$ATTN_DUAL_REVIEWER" 'h1' 20000 \ + && pass "reviewer window up on its own fixture" \ + || fail "reviewer never rendered" + +# Owner shares the HTML file via the running daemon (hybrid: live + mailbox). +log "Owner sharing $FIXTURE (hybrid)" +attn_owner review share "$FIXTURE" --mode hybrid >/dev/null 2>&1 \ + || fail "owner 'review share' command failed" + +# Poll the owner's review store for the minted invite URL. +INVITE="" +for _ in $(seq 1 75); do + # --eval returns a JSON-encoded scalar (quoted, slashes escaped), so decode + # it with `jq -r .` to recover the raw attn://review/... URL. + INVITE="$(attn_owner --eval \ + "window.__attn_review_store__?.currentShare?.inviteUrl ?? ''" 2>/dev/null \ + | jq -r . 2>/dev/null || echo '')" + case "$INVITE" in + attn://review/*) break ;; + *) INVITE="" ; sleep 0.4 ;; + esac +done +if [ -n "$INVITE" ]; then + pass "owner minted invite: ${INVITE%%#*}#" +else + fail "owner never produced an invite URL (see $ATTN_DUAL_OWNER/daemon.stderr.log)" + cleanup; echo; echo "RESULT: $FAILURES failure(s)"; exit 1 +fi + +# Reviewer joins via its daemon → its window switches to the shared HTML doc. +log "Reviewer joining" +attn_reviewer review join "$INVITE" >/dev/null 2>&1 \ + || fail "reviewer 'review join' command failed" + +# The reviewer's window must switch to the read-only HTML viewer. +__attn_dual_wait_one "$ATTN_DUAL_REVIEWER" '[data-slot="html-viewer"]' 25000 \ + && pass "reviewer switched to HtmlViewer for the shared doc" \ + || fail "reviewer never rendered [data-slot=html-viewer]" + +# The iframe must be srcdoc (content mode — reviewer has no local file) and +# carry the owner's bytes (the marker). +SRCDOC="$(attn_reviewer --eval \ + "document.querySelector('[data-slot=\\\"html-viewer\\\"] iframe')?.getAttribute('srcdoc') ?? ''" \ + 2>/dev/null || echo '')" +case "$SRCDOC" in + *"$MARKER"*) pass "reviewer iframe srcdoc carries the owner's HTML bytes" ;; + *) fail "reviewer iframe srcdoc missing marker '$MARKER' (got ${#SRCDOC} chars)" ;; +esac + +# Read-only: the reviewer must NOT mount the markdown editor for an HTML doc. +HAS_EDITOR="$(attn_reviewer --eval \ + "document.querySelector('[data-slot=\\\"html-viewer\\\"] .ProseMirror') ? 'yes' : 'no'" \ + 2>/dev/null | jq -r . 2>/dev/null || echo 'err')" +[ "$HAS_EDITOR" = "no" ] \ + && pass "reviewer HTML doc is read-only (no prosemirror editor)" \ + || fail "reviewer unexpectedly mounted an editor for an HTML doc ($HAS_EDITOR)" + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "RESULT: html-share e2e passed" + exit 0 +else + echo "RESULT: $FAILURES failure(s)" + exit 1 +fi diff --git a/src/review/bootstrap.rs b/src/review/bootstrap.rs index ff9f155..36e3be0 100644 --- a/src/review/bootstrap.rs +++ b/src/review/bootstrap.rs @@ -1441,12 +1441,12 @@ impl Bootstrapper { use crate::review::anchors::index::build_anchor_index; use crate::review::crypto::ids::{content_hash, derive_file_id, derive_snapshot_id}; use crate::review::envelope::{assemble_snapshot_blob_envelope, seal_snapshot_r2_body}; - use crate::review::model::{BlobRef, BlobStorage, SnapshotNode}; + use crate::review::model::{BlobRef, BlobStorage, DocType, SnapshotNode}; use crate::review::transport::blobs as relay_blobs; - let markdown_bytes = std::fs::read(path) + let doc_bytes = std::fs::read(path) .map_err(|e| BootstrapError::Store(format!("read {}: {e}", path.display())))?; - let base_hash = content_hash(&markdown_bytes); + let base_hash = content_hash(&doc_bytes); let room_secret = load_room_secret(self.store.root(), room_id)?; let display_path = path.to_string_lossy().to_string(); @@ -1459,12 +1459,21 @@ impl Bootstrapper { }; let snapshot_id = derive_snapshot_id(room_id, &file_id, &base_hash, now_ms as i64); - let markdown = String::from_utf8(markdown_bytes) - .map_err(|_| BootstrapError::Crypto("snapshot markdown must be utf-8".into()))?; - let anchor_index = build_anchor_index(markdown.as_bytes(), &snapshot_id) - .map_err(|e| BootstrapError::Crypto(format!("anchor index: {e}")))?; + let content = String::from_utf8(doc_bytes) + .map_err(|_| BootstrapError::Crypto("snapshot document must be utf-8".into()))?; + // HTML docs are shared read-only — no comment anchors (yet), so they + // carry no anchor index. Markdown docs anchor against rendered + // structure for comments/suggestions. + let (doc_type, anchor_index) = if is_html_path(path) { + (DocType::Html, None) + } else { + let index = build_anchor_index(content.as_bytes(), &snapshot_id) + .map_err(|e| BootstrapError::Crypto(format!("anchor index: {e}")))?; + (DocType::Markdown, Some(index)) + }; let plaintext = SnapshotPlaintext { - markdown, + doc_type, + content, anchor_index, }; @@ -2234,6 +2243,19 @@ fn is_markdown_path(path: &std::path::Path) -> bool { .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")) } +/// `true` if `path` ends in an HTML extension. HTML docs are shareable +/// read-only (no collaborative editing, no comment anchors yet). +fn is_html_path(path: &std::path::Path) -> bool { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("html") || e.eq_ignore_ascii_case("htm")) +} + +/// `true` if `path` is a document attn can share — markdown (anchored, +/// suggestable) or HTML (read-only). +fn is_shareable_path(path: &std::path::Path) -> bool { + is_markdown_path(path) || is_html_path(path) +} + /// `true` if `path` is `dir` or sits underneath it (component-wise, so /// `/a/b` does NOT match `/a/bc`). fn path_within(dir: &str, path: &std::path::Path) -> bool { @@ -2258,27 +2280,27 @@ fn is_ignored_dir_component(name: &str) -> bool { } /// The files to snapshot for a share. A regular file → just itself; a directory -/// (folder-share) → every `*.md` under it (recursively), skipping ignored dirs. -/// Sorted for stable ordering. -fn markdown_targets(path: &std::path::Path) -> Vec { +/// (folder-share) → every shareable doc (`*.md`/`*.markdown`/`*.html`/`*.htm`) +/// under it (recursively), skipping ignored dirs. Sorted for stable ordering. +fn shareable_targets(path: &std::path::Path) -> Vec { if !path.is_dir() { - return if is_markdown_path(path) { + return if is_shareable_path(path) { vec![path.to_path_buf()] } else { Vec::new() }; } let mut out: Vec = Vec::new(); - collect_markdown(path, &mut out); + collect_shareable(path, &mut out); out.sort(); out } fn validate_share_targets(path: &std::path::Path) -> Result, BootstrapError> { - let targets = markdown_targets(path); + let targets = shareable_targets(path); if targets.is_empty() { return Err(BootstrapError::InvalidShare(format!( - "{} is not shareable; choose a markdown file or a folder containing .md/.markdown files", + "{} is not shareable; choose a markdown or HTML file, or a folder containing .md/.markdown/.html/.htm files", path.display() ))); } @@ -2289,7 +2311,7 @@ fn validate_share_targets(path: &std::path::Path) -> Result, Bootst })?; if std::str::from_utf8(&bytes).is_err() { return Err(BootstrapError::InvalidShare(format!( - "{} is not UTF-8 markdown", + "{} is not UTF-8 text", target.display() ))); } @@ -2298,7 +2320,7 @@ fn validate_share_targets(path: &std::path::Path) -> Result, Bootst Ok(targets) } -fn collect_markdown(dir: &std::path::Path, out: &mut Vec) { +fn collect_shareable(dir: &std::path::Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; @@ -2309,8 +2331,8 @@ fn collect_markdown(dir: &std::path::Path, out: &mut Vec) { } let p = entry.path(); if p.is_dir() { - collect_markdown(&p, out); - } else if is_markdown_path(&p) { + collect_shareable(&p, out); + } else if is_shareable_path(&p) { out.push(p); } } @@ -2329,7 +2351,7 @@ fn find_room_for_path( let all = load_local_shares(root)?; for (room_id_str, record) in all { let matched: Option> = if record.is_dir { - if is_markdown_path(path) && path_within(&record.path, path) { + if is_shareable_path(path) && path_within(&record.path, path) { Some(record.files.get(&target).cloned()) } else { None @@ -2509,29 +2531,35 @@ mod tests { let docs = TempDir::new().expect("docs dir"); fs::write(docs.path().join("a.md"), b"# A").unwrap(); fs::write(docs.path().join("b.md"), b"# B").unwrap(); + fs::write(docs.path().join("page.html"), b"

P

").unwrap(); fs::write(docs.path().join("notes.txt"), b"x").unwrap(); fs::create_dir(docs.path().join("node_modules")).unwrap(); fs::write(docs.path().join("node_modules/c.md"), b"# C").unwrap(); fs::create_dir(docs.path().join("sub")).unwrap(); fs::write(docs.path().join("sub/d.md"), b"# D").unwrap(); - // Directory → every *.md (recursive, sorted), skipping notes.txt + - // node_modules; a single file → just itself. + // Directory → every shareable doc (*.md + *.html, recursive, sorted), + // skipping notes.txt + node_modules; a single file → just itself. assert_eq!( - markdown_targets(docs.path()), + shareable_targets(docs.path()), vec![ docs.path().join("a.md"), docs.path().join("b.md"), + docs.path().join("page.html"), docs.path().join("sub").join("d.md"), ] ); assert_eq!( - markdown_targets(&docs.path().join("a.md")), + shareable_targets(&docs.path().join("a.md")), vec![docs.path().join("a.md")] ); + assert_eq!( + shareable_targets(&docs.path().join("page.html")), + vec![docs.path().join("page.html")] + ); assert!( - markdown_targets(&docs.path().join("notes.txt")).is_empty(), - "single non-markdown files should not create empty review rooms" + shareable_targets(&docs.path().join("notes.txt")).is_empty(), + "single non-shareable files should not create empty review rooms" ); // Folder-share binding round-trip. @@ -2905,7 +2933,12 @@ mod tests { .expect("blob opens under snapshotKey"); let plaintext: SnapshotPlaintext = serde_json::from_slice(&blob_bytes).expect("blob is a SnapshotPlaintext"); - assert_eq!(plaintext.markdown, "# Plan\n"); + assert_eq!(plaintext.content, "# Plan\n"); + assert_eq!(plaintext.doc_type, crate::review::model::DocType::Markdown); + assert!( + plaintext.anchor_index.is_some(), + "markdown snapshots carry an anchor index" + ); // Locally: blob persisted by envelopeId, SnapshotNode references it. let stored_blob = store @@ -2934,6 +2967,84 @@ mod tests { assert_eq!(node_ref.byte_length, blob_bytes.len() as u64); } + /// Sharing an `.html` file publishes a read-only snapshot: the plaintext + /// carries `DocType::Html`, the raw HTML source as `content`, and NO anchor + /// index (HTML has no comment anchors yet). + #[tokio::test] + async fn share_html_file_publishes_read_only_snapshot() { + let server = MockServer::start().await; + let id_dir = TempDir::new().expect("id tempdir"); + let (_store_tmp, store, boot) = + make_bootstrapper(server.uri(), id_dir.path().to_path_buf()); + + Mock::given(method("POST")) + .and(path_regex_for_room_create()) + .respond_with(|req: &Request| { + ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "roomId": req.url.path().rsplit('/').next().unwrap_or(""), + "createdAt": 1_700_000_000_000u64, + "expiresAt": 1_700_086_400_000u64, + "policy": {}, + "ownerSigningKeyId": "k", + "serverSeq": 0, + })) + }) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex_for_devices()) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let html = "

Hi

\n"; + let (_doc_tmp, path) = temp_markdown_file("page.html", html); + let outcome = boot + .share(path, RoomMode::Async, None) + .await + .expect("share html file"); + + let envelopes: Vec = store + .iter_outbox(&outcome.room_id) + .expect("iter") + .collect::>() + .expect("decode"); + // Locate the snapshot-blob envelope rather than assume its index — the + // share flow's envelope ordering has changed before (RoomCreated + + // ParticipantJoined precede it). + let blob_env = envelopes + .iter() + .find(|e| e.kind == EnvelopeKind::SnapshotBlob) + .expect("a snapshot_blob envelope is enqueued for the shared HTML doc"); + + let parsed = parse_invite(&outcome.invite).expect("parse invite"); + let keys = derive_room_keys(&parsed.room_secret); + let aad = crate::review::envelope::envelope_aad(blob_env); + let nonce_bytes = URL_SAFE_NO_PAD + .decode(blob_env.nonce.as_bytes()) + .expect("nonce decodes"); + let nonce: crate::review::crypto::aead::AeadNonce = + nonce_bytes.as_slice().try_into().expect("24-byte nonce"); + let ciphertext = URL_SAFE_NO_PAD + .decode(blob_env.ciphertext.as_bytes()) + .expect("ciphertext decodes"); + let blob_bytes = crate::review::crypto::aead::open( + keys.snapshot_key.as_bytes(), + &nonce, + &ciphertext, + &aad, + ) + .expect("blob opens under snapshotKey"); + let plaintext: SnapshotPlaintext = + serde_json::from_slice(&blob_bytes).expect("blob is a SnapshotPlaintext"); + assert_eq!(plaintext.doc_type, crate::review::model::DocType::Html); + assert_eq!(plaintext.content, html); + assert!( + plaintext.anchor_index.is_none(), + "read-only HTML snapshots carry no anchor index" + ); + } + #[tokio::test] async fn share_emits_invite_with_expected_shape() { let server = MockServer::start().await; diff --git a/src/review/manager.rs b/src/review/manager.rs index b8e67e2..8e7e66f 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -3205,14 +3205,15 @@ mod tests { let room_id: RoomId = dummy_id("room-rehydrate"); let plaintext = SnapshotPlaintext { - markdown: "# rehydrated\n".to_string(), - anchor_index: AnchorIndex { + doc_type: crate::review::model::DocType::Markdown, + content: "# rehydrated\n".to_string(), + anchor_index: Some(AnchorIndex { doc_hash: dummy_id("hash-1"), canonical_encoding: CanonicalEncoding::Utf8Bytes, line_count: 1, blocks: vec![], headings: vec![], - }, + }), }; let blob_bytes = crate::review::crypto::canonical::to_canonical_bytes(&plaintext) .expect("canonical snapshot"); @@ -3243,7 +3244,7 @@ mod tests { ReviewEventBody::SnapshotCreated { inline_snapshot: Some(inline), .. - } => assert_eq!(inline.markdown, "# rehydrated\n"), + } => assert_eq!(inline.content, "# rehydrated\n"), other => panic!("expected inlined snapshot, got {other:?}"), } @@ -3344,14 +3345,15 @@ mod tests { // this is what the inbound pipeline leaves behind for a reviewer // (events.jsonl + blobs/*.bin; no SnapshotNode on the reviewer side). let plaintext = SnapshotPlaintext { - markdown: "# replayed doc\n".to_string(), - anchor_index: AnchorIndex { + doc_type: crate::review::model::DocType::Markdown, + content: "# replayed doc\n".to_string(), + anchor_index: Some(AnchorIndex { doc_hash: dummy_id("hash-1"), canonical_encoding: CanonicalEncoding::Utf8Bytes, line_count: 1, blocks: vec![], headings: vec![], - }, + }), }; let blob_bytes = crate::review::crypto::canonical::to_canonical_bytes(&plaintext) .expect("canonical snapshot"); @@ -3417,7 +3419,7 @@ mod tests { ReviewEventBody::SnapshotCreated { inline_snapshot: Some(inline), .. - } => assert_eq!(inline.markdown, "# replayed doc\n"), + } => assert_eq!(inline.content, "# replayed doc\n"), other => panic!("expected rehydrated SnapshotCreated, got {other:?}"), } } @@ -4747,14 +4749,15 @@ mod request_snapshot_tests { byte_length: 5, encrypted_blob_ref: None, plaintext: Some(SnapshotPlaintext { - markdown: "# hi\n".to_string(), - anchor_index: AnchorIndex { + doc_type: crate::review::model::DocType::Markdown, + content: "# hi\n".to_string(), + anchor_index: Some(AnchorIndex { doc_hash: id::("hash-1"), canonical_encoding: CanonicalEncoding::Utf8Bytes, line_count: 1, blocks: vec![], headings: vec![], - }, + }), }), } } diff --git a/src/review/model.rs b/src/review/model.rs index a1ac362..f526aaf 100644 --- a/src/review/model.rs +++ b/src/review/model.rs @@ -220,15 +220,34 @@ pub struct SnapshotNode { pub plaintext: Option, } -/// Local-only decrypted snapshot payload (markdown + anchor index). Kept off -/// the wire per `amendments.md` decision #14. +/// The kind of document a snapshot carries. Markdown docs are anchored and +/// (eventually) suggestable; HTML docs are shared read-only — they render in a +/// sandboxed viewer with no comment anchors or collaborative editing (yet). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum DocType { + #[default] + Markdown, + Html, +} + +/// Local-only decrypted snapshot payload (document content + optional anchor +/// index). Kept off the wire per `amendments.md` decision #14. +/// +/// `anchor_index` is present only for markdown docs (it positions comments and +/// suggestions against the rendered structure). HTML docs are read-only, so +/// they carry no anchor index — `anchor_index` is `None`. /// /// Spec: `data-model.md` §Snapshot Graph. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SnapshotPlaintext { - pub markdown: String, - pub anchor_index: AnchorIndex, + pub doc_type: DocType, + /// The raw UTF-8 document source — markdown source for `Markdown`, HTML + /// source for `Html`. + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub anchor_index: Option, } /// Reference to an encrypted blob — inline within an event, in the mailbox, diff --git a/src/review/store.rs b/src/review/store.rs index 1ae6b80..57dcd24 100644 --- a/src/review/store.rs +++ b/src/review/store.rs @@ -791,14 +791,15 @@ mod tests { byte_length: 42, encrypted_blob_ref: None, plaintext: Some(SnapshotPlaintext { - markdown: "# hi\n".to_string(), - anchor_index: AnchorIndex { + doc_type: crate::review::model::DocType::Markdown, + content: "# hi\n".to_string(), + anchor_index: Some(AnchorIndex { doc_hash: id::("hash-1"), canonical_encoding: CanonicalEncoding::Utf8Bytes, line_count: 1, blocks: vec![], headings: vec![], - }, + }), }), } } diff --git a/web/src/App.svelte b/web/src/App.svelte index 6e75668..292a684 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -194,7 +194,7 @@ // even when they opened attn on an empty directory with no local files. let hasSidebar = $derived(fileTree.length > 0 || isReviewerInRoom); let showBreadcrumbShare = $derived( - activeFileType === 'markdown' && + (activeFileType === 'markdown' || activeFileType === 'html') && !shareDialogOpen && reviewStore.currentRoomId === null, ); @@ -220,20 +220,29 @@ // have a real tab + rawMarkdown). See store.applyEvent — SnapshotCreated // events are mirrored into `reviewStore.snapshots` and auto-set // `currentFileId`. - let reviewSnapshotMarkdown = $derived.by(() => { + // The LATEST snapshot for the focused file — owner edits republish a new + // snapshot per save, so several may exist for one fileId. Newest createdAt + // wins so the reviewer always sees the freshest content. Covers both + // markdown and read-only HTML docs. + let reviewSnapshot = $derived.by(() => { const roomId = reviewStore.currentRoomId; const fileId = reviewStore.currentFileId; if (!roomId || !fileId) return null; - // Pick the LATEST snapshot for this file — owner edits republish a new - // snapshot per save, so several may exist for one fileId. Newest - // createdAt wins so the reviewer always sees the freshest content. const candidates = reviewStore.snapshots.filter( - (s) => s.roomId === roomId && s.fileId === fileId && typeof s.markdown === 'string', + (s) => s.roomId === roomId && s.fileId === fileId && typeof s.content === 'string', ); if (candidates.length === 0) return null; - const latest = candidates.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)); - return latest.markdown ?? null; + return candidates.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)); }); + let reviewSnapshotContent = $derived(reviewSnapshot?.content ?? null); + let reviewSnapshotDocType = $derived(reviewSnapshot?.docType ?? 'markdown'); + // Markdown snapshots seed the prosemirror editor (anchors/collab). HTML + // snapshots are read-only and render in HtmlViewer — never the editor — so + // markdown-only consumers (collab seed, anchor remap, effectiveMarkdown) key + // off this and naturally skip HTML. + let reviewSnapshotMarkdown = $derived( + reviewSnapshotDocType === 'markdown' ? reviewSnapshotContent : null, + ); // A reviewer who has received the owner's snapshot for the focused file // renders the shared doc — regardless of whether they also have a local // tab open. Joining a room is an explicit "show me the shared content" @@ -241,11 +250,17 @@ let isReviewerViewingSnapshot = $derived( isReviewerInRoom && reviewSnapshotMarkdown !== null, ); + // Read-only HTML shared doc — rendered in HtmlViewer (sandboxed iframe), + // never the editor. + let isReviewerViewingHtmlSnapshot = $derived( + isReviewerInRoom && reviewSnapshotDocType === 'html' && reviewSnapshotContent !== null, + ); // Reviewer is in the room but the owner's snapshot for the focused file // hasn't arrived yet — show a "waiting for shared content" state instead - // of silently leaving them on whatever local file they had open. + // of silently leaving them on whatever local file they had open. Applies to + // any shared doc type. let isReviewerWaiting = $derived( - isReviewerInRoom && reviewSnapshotMarkdown === null, + isReviewerInRoom && reviewSnapshotContent === null, ); // The markdown the editor actually renders: the shared snapshot for a // reviewer, otherwise the local file (or a received snapshot when no local @@ -612,10 +627,11 @@ let base: ReviewSnapshot | null = null; for (const s of reviewStore.snapshots) { if (s.roomId !== roomId || s.fileId !== fileId) continue; - if (typeof s.markdown !== 'string') continue; + // Collab/editor seed is markdown-only; HTML docs are read-only. + if (s.docType === 'html' || typeof s.content !== 'string') continue; if (base === null || s.createdAt < base.createdAt) base = s; } - return base?.markdown ?? null; + return base?.content ?? null; } // Owner's getSeedDoc: the v0 ProseMirror doc for a file, so the controller @@ -995,10 +1011,10 @@ ); if (snaps.length === 0) return; const snapshot = snaps.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)); - if (!snapshot.anchorIndex || typeof snapshot.markdown !== 'string') return; + if (!snapshot.anchorIndex || typeof snapshot.content !== 'string') return; const ctx = { currentIndex: snapshot.anchorIndex, - currentMarkdownBytes: new TextEncoder().encode(snapshot.markdown), + currentMarkdownBytes: new TextEncoder().encode(snapshot.content), currentHash: snapshot.baseHash, }; for (const event of events) { @@ -2441,6 +2457,12 @@

Connected to the shared room

Waiting for the shared document…

+ {:else if isReviewerViewingHtmlSnapshot} + + + {:else if isReviewerViewingSnapshot} + + {:else} + + {/if} - + {#if sessionState.snapshotDocType !== 'html'} + + {/if} {/if} diff --git a/web/src/lib/FileTree.svelte b/web/src/lib/FileTree.svelte index 69df8ac..a444a36 100644 --- a/web/src/lib/FileTree.svelte +++ b/web/src/lib/FileTree.svelte @@ -370,7 +370,7 @@ handleShare(node.path)} >