discourse-shared-edits/AGENTS.md
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
Major overhaul of the shared edits plugin to improve reliability, robustness,
and developer experience:

**Backend**
- Extract Revise service for cleaner controller orchestration
- Add StateValidator for base64/Yjs safety, corruption detection, and recovery
- Centralize protocol constants (Ruby + JS) to avoid hardcoded strings
- Add state hash sync verification and vector validation endpoints
- Harden security (guardian checks), error handling, and resource cleanup
- Resize shared edit columns migration; add state_hash column

**Frontend**
- Decompose shared-edit-manager into focused modules: yjs-document,
  markdown-sync, rich-mode-sync, network-manager, encoding-utils
- Add cursor overlay and caret coordinate tracking for selection sharing
- Add ProseMirror extension for rich-mode collaborative editing
- Cache-busted Yjs bundle loading via hashed filenames
- Fix scroll drift during sync

**Testing & Tooling**
- Extensive new specs: state_validator, revision_controller, model, revise service
- New Ember acceptance tests: cursor, lifecycle, sync flows
- Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery
- Add support/lint wrapper for full CI lint suite
- Update dependencies and rebuild Yjs/y-prosemirror bundles
2026-02-13 11:34:52 +11:00

9.1 KiB
Raw Permalink Blame History

Discourse Shared Edits Plugin AI Coding Agent Guide

  • Always start by reading ../../AGENTS.md to understand Discourse-wide conventions.
  • While working on the plugin always feel free to consult Discourse source for best practices, patterns, and utilities.
  • NEVER make commits to the repo, always leave it to humans to commit the code.

Linting

  • run support/lint to lint files
  • run support/lint --fix to attempt fixes

Scope & Feature Flags

  • Lives at plugins/discourse-shared-edits; everything here only runs when SiteSetting.shared_edits_enabled (defined in config/settings.yml) is true and the per-post custom field shared_edits_enabled has been toggled via SharedEditRevision.toggle_shared_edits!.
  • Guardian hook (lib/discourse_shared_edits/guardian_extension.rb) restricts enable/disable/reset/recover endpoints to staff or trust level 4+. Reuse guardian.ensure_can_toggle_shared_edits! for any new privileged action. Privileged endpoints must also call guardian.ensure_can_see!(@post) to prevent access to posts in restricted categories.
  • API routes live under /shared_edits (plugin.rb). Do not rename them without updating the Ember service and the Pretender fixtures in test/javascripts.

Backend Architecture & Expectations

  • app/controllers/discourse_shared_edits/revision_controller.rb is the only HTTP surface. Every new server feature must enforce requires_plugin, requires_login, and ensure_shared_edits guards, and must return JSON (never 204 when clients expect a body). Respond with message_bus_last_id whenever clients need to subscribe after fetching state.
  • app/services/discourse_shared_edits/revise.rb owns the non-recovery revise orchestration (client validation + SharedEditRevision.revise! + commit scheduling). State-vector divergence checks run inside SharedEditRevision.revise! under the post lock. Keep controller responses and rescue behavior compatible when changing this flow. Validation failures intentionally raise StateValidator::* exceptions that are rescued in the controller.
  • app/models/shared_edit_revision.rb stores every Yjs update. Treat raw as the authoritative, base64-encoded document snapshot and revision as the individual update payload. Always use the provided class methods (init!, revise!, commit!, toggle_shared_edits!, reset_history!, etc.) so Redis scheduling (ensure_will_commit + Jobs::CommitSharedRevision), message bus fan-out, editor attribution, and compaction invariants stay intact.
  • lib/discourse_shared_edits/state_validator.rb is the gatekeeper for base64/Yjs safety, max_post_length, health reports, and corruption recovery. Any code that manipulates Yjs blobs must run through the validator helpers (or add new helpers here) so that errors surface as StateCorruptionError and can trigger automatic recovery.
  • lib/discourse_shared_edits/yjs.rb wraps a shared MiniRacer::Context that executes the bundled public/javascripts/yjs-dist.js. Never eval ad-hoc scripts elsewhere; if you need a new primitive, add it to this wrapper so both Ruby and Ember flows stay aligned on how docs are encoded.
  • Protocol literals are centralized in lib/discourse_shared_edits/protocol.rb (Ruby) and assets/javascripts/discourse/lib/shared-edits/protocol.js (Ember). Reuse these constants for message actions/error codes instead of hardcoded strings.
  • Background commits: updates are throttled client-side, but the server still schedules Jobs::CommitSharedRevision 10 seconds out using a Redis key per post. If you change commit timing, update both ensure_will_commit and the job to avoid duplicate commits or missed flushes.
  • Recovery + maintenance endpoints: health, recover, and reset all use StateValidator and emit /shared_edits/:post_id message-bus resync events. When adding new maintenance operations, emit the same payload shape ({ action: "resync", version: <int> }) so the Ember service understands it.
  • Database: migrations live in db/migrate. The original table creation (20200721001123_migrate_shared_edits.rb) plus the column resize (20251124000123_resize_shared_edit_columns.rb) show expectations: always provide down paths, mark large operations algorithm: :concurrently when indexing, and protect edits on large tables.

Frontend Architecture & Expectations

  • assets/javascripts/discourse/services/shared-edit-manager.js is the heart of the client: it lazy-loads core Yjs via the URLs in lib/shared-edits/bundle-paths.js, loads the ProseMirror bundle when rich mode is active, mirrors composer text into a shared Y.Doc, throttles PUTs to /shared_edits/p/:post_id, and subscribes to /shared_edits/:post_id on messageBus. Preserve: message payload keys (version, update, client_id, user_id, username), selection/cursor broadcasting, throttling constants (THROTTLE_SAVE, THROTTLE_SELECTION), and cleanup of DOM listeners/cursor overlays to avoid leaks.
  • Composer integration lives in assets/javascripts/discourse/initializers/shared-edits-init.js and extend-composer-service.js. Always guard new behavior with siteSettings.shared_edits_enabled, register hooks via withPluginApi, and respect creatingSharedEdit/editingPost semantics so we never leave the composer in a half-shared state.
  • UI pieces: the post action replacement is in components/shared-edit-button.gjs; the composer “Done” button lives in connectors/composer-fields-below/shared-edit-buttons.gjs; shared styles are under assets/stylesheets/common/discourse-shared-edits.scss; cursor rendering utilities are in assets/javascripts/discourse/lib/{caret-coordinates,cursor-overlay}.js. Keep strings translatable (shared_edits.* keys exist on both client and server locales).
  • Asset bundling: public/javascripts/yjs-dist.js (core Yjs) and public/javascripts/yjs-prosemirror.js (y-prosemirror plugins) are generated via bin/rake shared_edits:yjs:build (lib/tasks/yjs.rake wraps pnpm exec esbuild …). The build also copies hashed variants (e.g. yjs-dist-<sha>.js) and updates assets/javascripts/discourse/lib/shared-edits/bundle-paths.js so the client always loads cache-busted URLs. Never hand-edit bundled files; re-bundle whenever yjs/y-prosemirror changes and commit the new artifacts.

Testing, Linting & Tooling

  • Ruby specs cover validators, model behavior, controller endpoints, and basic system flows. Run bin/rspec plugins/discourse-shared-edits/spec/<area> (requires LOAD_PLUGINS=1 when running outside the full suite). spec/system relies on page objects; avoid raw Capybara finders for new tests.
  • Ember acceptance tests live at plugins/discourse-shared-edits/test/javascripts/acceptance. Execute them with bin/qunit plugins/discourse-shared-edits/test/javascripts/acceptance/composer-test.js (or the directory to run them all).
  • Lint every file you touch: bin/lint plugins/discourse-shared-edits/<path> for Ruby/JS/SCSS and pnpm --filter discourse-shared-edits lint if you need the plugin-level configs from package.json. Stylelint and template lint configs already live alongside the plugin—respect them when adding files.
  • Node tooling: the plugin pins Node ≥ 22 and pnpm 9 (package.json). Use pnpm install inside the plugin when you add JS dependencies so lockfiles stay in plugins/discourse-shared-edits/pnpm-lock.yaml.

Operational Tips & Utilities

  • Manual QA: plugins/discourse-shared-edits/support/fake_writer uses Playwright to simulate concurrent editors. Run support/fake_writer POST_ID --speed=fast --headless=false against a dev instance to reproduce race conditions before shipping protocol changes.
  • Message bus hygiene: SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_* caps backlog size/age. Keep any new channels under the same limits or we risk unbounded Redis usage.
  • Edit reasons: SharedEditRevision.update_edit_reason builds shared_edits.reason strings listing everyone who contributed between commits. Editor usernames are stored structurally in the shared_edits_editor_usernames JSON post custom field (registered in plugin.rb) rather than parsed from the localized edit reason string. If you change commit batching or editor attribution, update both the method and translations.
  • Recovery workflow: corruption is surfaced in logs and bubbled to the client via state_recovered / state_corrupted error codes. When adding new error states, expose translated messaging in config/locales/client.* and wire them into the composer UI.
  • Selection sharing: the Ember service currently attempts to PUT /shared_edits/p/:post_id/selection. The endpoint is not implemented yet, so requests are best-effort and errors are ignored; reuse that route if you decide to ship cursor/selection sync so the client code does not need changing.
  • Knowledge sharing: keep this file current whenever you add new entry points, commands, or conventions. After completing any task that touches this plugin, spawn a review agent to compare your diff against plugins/discourse-shared-edits/AGENTS.md and confirm the instructions remain accurate.
  • Tip: Run plugins/discourse-shared-edits/support/lint from the repo root (add --fix/-f to trigger auto-fix variants) to execute the full GitHub lint suite without guessing binaries.