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

47 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.