Repository navigation
Migrate DE-RTC as the third sync engine - #9
Merged
Merged
Conversation
Imported verbatim from the Gutenberg chriszarate/refreshed-de-rtc branch (lib/compat/wordpress-7.1/distributed-editing/automerge-php, itself a verbatim port of wordpress-develop add/distributed-editing, PR WordPress/wordpress-develop#12334). MIT, PHP 8.2+, namespace WordPress\DistributedEditing\Automerge, no WordPress dependency. The library's conformance suite (php tests/run.php) referenced an upstream/automerge submodule never committed on either source branch; the 11 fixture files it reads (3 storage fixtures + 8 fuzz-crashers) are now vendored under upstream/automerge/, fetched from automerge/automerge@8d7b12f (MIT; recorded in VENDORED_FROM_COMMIT.txt). Suite passes 680/680 locally. includes/lib/automerge-php-loader.php mirrors y-php-loader.php: a lazy PSR-4 shim plus gutenberg_sync_engines_automerge_php_is_supported() (PHP >= 8.2 + mbstring) for callers to gate on. Excluded from phpcs like y-php. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
includes/engines/de-rtc/merge-core.php is the exact call-graph closure (113 functions, ~6.4k lines) of the engine-facing entry points in the Gutenberg chriszarate/refreshed-de-rtc branch's de-rtc.php (itself a verbatim port of wordpress-develop add/distributed-editing): update construction/normalization, the serialized-block and block-identity three-way merges, the rich-text merge model, version snapshots, sync-meta format/parse, canonicalization/hashing, and the automerge runtime gate. Extracted token-exactly; the only deltas are the vendored library path and delegating autoloading to automerge-php-loader.php (marked DELTA in place). Excluded from phpcs and frozen like the intent-log cross-language core. The plugin loads it behind a function_exists guard so a Core/Gutenberg build that ships DE-RTC itself wins. tests/phpunit/wpDeRtcMergeCore.php drives the merge seam directly: disjoint block edits merge, non-overlapping rich-text edits within one paragraph merge, overlapping edits escalate (de_rtc_rebase_failed / manual_conflict_required), concurrent differing appends escalate by design, tampered/mismatched updates are rejected, engine-unaware saves get server-generated updates, and version snapshots trim at the limit. Two upstream-faithful behaviors are pinned rather than fixed: the legacy native-automerge-php-v1 lane is dead code (NativePort::merge accepts only document pairs, so operations-array updates are rejected as unverifiable) and same-edge concurrent appends require review. Full PHPUnit: 162 tests / 707 assertions green (baseline 148 + 14 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The third engine (slug de-rtc): server-governed three-way merges of
whole-content proposals. Clients send proposal updates ({proposalId,
baseVersion, proposedContent, clientUpdate}); under the per-room ingest
lock (three-way merges are order-dependent, so ingest serializes like
intent-log, unlike yjs-server's lock-free CRDT path) the engine resolves
the named base from its version snapshots, merges with
wp_de_rtc_get_automerge_retry_save_result(), and either advances the
canonical document (disposition applied + a server-authored content row
peers replay) or settles the proposal as escalated
(manual-conflict-required, requires-unfiltered-html) or voided
(unknown-base-version, invalid-payload, tamper/malformed details).
Canonical state ({version, content, sync_meta.version_snapshots}) lives
in room meta — not in a post_content sync-meta block, which is what
made most of upstream DE-RTC's periphery (revision scan, kses allowance,
preservation filters) unnecessary here. Genesis is deterministic from
post content and ADOPTS an existing upstream sync-meta block (version
lineage continues, content stripped). Server-driven checkpoints reuse
the shared retention invariant (trim behind the previous checkpoint,
floor-clamp stale cursors).
The capability lane escalates whole proposals kses would rewrite
(upstream sequesters just the risky blocks for review — documented gap
until the review lane exists). Registered in the plugin and on the
settings screen.
PHPUnit: 174 tests / 763 assertions green (12 new engine tests: SPI
identity, genesis+lineage, apply/broadcast, stale-base three-way rebase,
conflict escalation, kses escalation, per-row voids, type fencing,
persistence across instances, checkpoint trim + floor clamp).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client half of DE-RTC keeps Distributed Editing's core inversion: the client NEVER merges. A local Y.Doc (shared engines/yjs schema — same undo manager, awareness anchor, and record mapping as the yjs engines) bridges the editor; the session codec speaks the proposal wire: - One coalesced in-flight proposal: local edits mark the doc dirty and a whole-content proposal is built only when none is pending, so a typing burst costs one proposal per poll cycle. The base version is the version last APPLIED to the doc — stale bases are exactly what the server's three-way merge is for. - Canonical rows are deferred while local edits are dirty/in flight (applying them would overwrite edits the server has not seen); the newest deferred row applies when the local state settles. Escalated proposals are abandoned locally once canonical applies — the upstream review/decision UI is a listed follow-up, as is the client-side block-native descriptor builder (proposals send clientUpdate: null and the server derives operations via its engine-unaware writer lane). - Server genesis bootstraps the doc (hydrate is a no-op; pre-bootstrap local changes buffer and replay), mirroring yjs-server. - Collections are inert (the intent-log precedent): awareness flows, rows are ignored, nothing is proposed. The doc bridge serializes/parses through the editor's own block library (wp.blocks at runtime; resolved from the subtree in Jest/tsc, mocked in tests). Registered as the third adapter in src/index.ts. Jest: 347 tests green (8 new: identity, no-seed hydrate + genesis bootstrap, pre-bootstrap buffering, proposal coalescing + settle, remote apply + onRemoteChange + rebased base version, deferred canonical under in-flight, own-row slot clearing, inert collections). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three client fixes found by driving the real editor two-up (each verified with the spec + Playwright traces): 1. Own accepted rows advance the base version. A client's successive typing chunks were proposed against the same old base, so an author's continuation conflicted with their own just-accepted chunk (rich-text overlap at the same position). When our own row round-trips unchanged, advance the version WITHOUT touching the doc (newer keystrokes survive); the next chunk proposes against it. 2. Block-level incorporation of own merged rows. When the server merges peers' work into our proposal, adopt canonical blocks we have not edited since proposing, keep locally-edited blocks (reconciled by the next proposal), handle peer tail-insertions, and bail to deferral on any other structure change. Truly concurrent same-block edits resolve block-level last-writer-wins — the same class of silent register policy yjs-server carries, at coarser grain (documented in the engine comparison). 3. Dispositions only settle the proposal they name. A previous proposal's disposition arrives in the response AFTER the one whose rows already settled it; clearing the in-flight slot unconditionally let a peer's canonical row clobber unproposed local edits, which the next incorporation then wrongly pinned as 'local'. The e2e spec (three scenarios: server-side genesis + bidirectional text sync, concurrent different-block edits surviving the three-way merge, and the save flow persisting both users' settled edits) passes repeatedly in-suite and solo. It opens sessions through a local helper that force-closes the second user's welcome guide when the fixture's preference dispatch loses to late preference hydration (an open modal aria-hides the page and fails discovery even though sync is healthy). Deliberately absent scenarios are the v1 gaps: title sync, and the empty-post concurrent-append case (a BY-DESIGN escalation under DE-RTC policy). Known pre-existing full-suite flake, verified NOT caused by these changes: http-only/collaboration-sync-body-size can fail after any engine-flip suite under load (control: yjs suite + body-size fails identically without de-rtc involved); solo it is green, CI retries absorb it. Jest 347 green; de-rtc e2e 3/3; full e2e 31/32 (the flake above). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AGENTS.md: engines list, repo-layout entries for the frozen merge core and the vendored automerge-php (incl. the PORTING_STATUS.json rewrite side-effect and the fact that the shipping merge path never calls the library), new green baselines (Jest 347, PHPUnit 174/763, e2e 32/32 with the pre-existing body-size-after-engine-flip flake called out), and the de-rtc known-gaps entry. - docs/engine-comparison.md: de-rtc added to the one-sentence list, the parity and host-resource tables, known gaps (no benchmark profile, no review UI/title sync, block-level LWW on same-block concurrency, no client-side proof descriptors), and the bottom line. - README.md: de-rtc in the engines list. - CI: run the vendored automerge-php conformance suite (680 mapped upstream tests, plain php runner, no WordPress) alongside y-php's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The automerge-php conformance step failed on CI while passing locally: its grapheme-cluster paths split text with PCRE2's \X, and PCRE2 10.42 (bundled by PHP <= 8.3 builds, including the stock runner's 8.3.6 and Ubuntu 24.04 packages) carries a GB11 bug where two ADJACENT emoji-ZWJ sequences count as ONE extended grapheme cluster. PCRE2 10.44 (bundled from PHP 8.4) counts two. Exactly 2 of the 680 mapped upstream tests fail under the old tables — grapheme cursor tracking through an inserted family emoji, and a UTF-16-boundary splice — reproduced in php:8.2/8.3-cli containers (678/680) vs php:8.4-cli (680/680). CI now pins PHP 8.4 via setup-php for the conformance suites (y-php is 7.4+-compatible and unaffected), and the automerge step keeps its JSON report visible — a pass prints the summary line, a failure dumps the failing entries — instead of discarding stdout, which had swallowed the report entirely (the step 'failed silently' in 1.6 s). AGENTS.md documents the PCRE2 >= 10.43 parity requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates DE-RTC (Distributed Editing, design post) from the Gutenberg
chriszarate/refreshed-de-rtcbranch into this plugin as the third engine, slugde-rtc, alongside intent-log and yjs-server.Fixes #2
What "the de-rtc engine" means here
The prior port was a framework feature (~167k lines: a 28k-line
de-rtc.php, 18 REST routes, presence tables, review/proof lanes, a 22k-line editor store). Reconnaissance showed the shipping merge path (native-automerge-blocks-v1) never calls Automerge — it is a hand-written block-aware three-way merge. So the engine is that merge core behind the framework SPI, and only the essentials came over:includes/lib/automerge-php/— the library vendored verbatim (MIT, PHP ≥ 8.2 + mbstring, no WP dependency), y-php-style: lazy loader, phpcs-excluded, own conformance suite in CI (680/680; its 11 upstream fixtures are now vendored — the source branches referenced a never-committed submodule). It backs only the dead legacy lane and future repair paths; tests pin that upstream-faithful behavior.includes/engines/de-rtc/merge-core.php— the exact call-graph closure (113 functions, ~6.4k lines) of the engine-facing entry points, extracted token-exactly and frozen like the intent-log core. Only deltas: vendored-library path + loader delegation.wp:sync-metablock inpost_content, which makes the revision-scan/kses-allowance/preservation machinery unnecessary. Genesis still adopts an embedded upstream sync-meta block, so version lineage continues.How it works
Server (
WP_De_RTC_Engine): clients send proposals ({proposalId, baseVersion, proposedContent, clientUpdate}) — the prototype's retry-save contract on the room protocol. Under a per-roomGET_LOCK(three-way merges are order-dependent, unlike yjs-server's CRDT path) the engine resolves the named base from version snapshots, merges viawp_de_rtc_get_automerge_retry_save_result(), and either advances the canonical document (server-authoredcontentrows peers replay) or settles per-proposal dispositions: escalated (manual-conflict-required,requires-unfiltered-html) or voided (unknown-base-version, tamper/malformed). Checkpoint/trim every 100 rows, deterministic genesis, engine lineage stamped.Client (
src/engines/de-rtc/): the client never merges. A local Y.Doc (sharedengines/yjs/schema, undo, awareness) bridges the editor; the codec keeps ONE coalesced in-flight proposal against the last-incorporated version, defers canonical rows while local edits are pending, advances version-only when its own row round-trips unchanged, and does block-level incorporation when the server merged peers' work in. Dispositions only settle the proposal they name (a stale disposition freeing the slot early was the subtlest bug — found via Playwright network traces).Verification
collaboration-sync-body-sizeafter any engine-flip suite; control-verified with yjs alone; green solo)Known gaps (documented in
docs/engine-comparison.md)No benchmark authoring profile yet; no review-lane UI (escalations are observable dispositions only, and the client abandons an escalated proposal once canonical applies); no title sync; clients send
clientUpdate: null(the server's engine-unaware-writer lane derives operations — the client-side descriptor builder + cross-language fingerprint vectors are the next step to restore proof-carrying proposals); truly concurrent same-block edits resolve block-level last-writer-wins (yjs-server's silent-register-LWW class, coarser grain).🤖 Generated with Claude Code