Collaborative script editing: backend salvage + fix-up (Phase 1) - #1359
Conversation
Release 0.35.2
Ports the CRDT-based collaborative editing backend from the abandoned feature/collaborative-editing branch (Vue 2 attempt, issue #889) onto current dev via a 3-way merge, since the controller directory moved to controllers/api/v1/ across 236 commits of drift in the interim. The frontend on that branch was never fixed after a two-pass review found ~40 issues, including a critical bug where every keystroke did a full delete+insert on the shared Y.Text, defeating CRDT merge semantics entirely. None of the 6 planned fix batches were ever committed. The backend (room manager, Y.Doc<->DB conversion, WebSocket protocol) is framework-agnostic and sound, so it is salvaged here with the review's outstanding backend fixes folded in directly: - YJS_SYNC step=2 missing try/except around apply_update - checkpoint atomic-write could corrupt a good file or leak temp files on failure - close_active_room() exceptions in on_close's finally block went unhandled and unlogged - discard_active_room errors weren't surfaced to the HTTP caller - checkpoint failures didn't back off, flooding logs - ROOM_CLOSED broadcast swallowed all exceptions silently - DISCARD_SCRIPT_DRAFT was incorrectly blocked during a live session (it's the only recovery path if one starts mid-draft) - load-revision guard only checked the in-memory room, not a DB-persisted draft surviving a server restart - cycle/dangling-pointer defense added to the linked-list -> Y.Doc walk (dev had already fixed the dangling-pointer case independently via a composite FK constraint) - "Insufficient permissions" was duplicated 6x; centralized to a constant Also adds an Alembic migration for the new script_drafts table and sessions.is_cutting column, and closes several test coverage gaps identified in the original review (corrupt payload handling, live session guards on YJS ops, revision-switching room handoff, stale draft cleanup, checkpoint-due detection). Full backend suite: 859/859 passing. The Vue 3 frontend integration (client-v3 only; the Vue 2 client does not get this feature) is tracked as later phases in plans/COLLABORATIVE_EDITING_V3_PLAN.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
Client V3 Test Results39 tests 39 ✅ 0s ⏱️ Results for commit 82b096d. ♻️ This comment has been updated with latest results. |
Client Test Results132 tests 132 ✅ 0s ⏱️ Results for commit 82b096d. ♻️ This comment has been updated with latest results. |
Python Test Results 1 files 1 suites 2m 29s ⏱️ Results for commit 82b096d. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (chromium)221 tests 221 ✅ 2m 15s ⏱️ Results for commit 82b096d. ♻️ This comment has been updated with latest results. |
Playwright E2E Results (firefox)221 tests 221 ✅ 2m 13s ⏱️ Results for commit 82b096d. ♻️ This comment has been updated with latest results. |
Phase 1's backend port changed two live contracts the current client-v3 frontend still depends on, breaking CI: 1. GET /show/script/config dropped canRequestEdit/currentEditor in favor of the new editors/cutters/hasDraft shape. client-v3's ScriptEditor.vue still reads the old fields, so the Edit button stayed permanently disabled (E2E: "requests edit mode"). Restored both fields as a backward-compatible addition, computed from the new editors list, to be removed once client-v3 migrates onto the new shape. 2. no_active_script_draft was ported as an `async def` wrapper that awaits the wrapped method's return value. That's correct for the async POST/PATCH handlers it already decorated, but ScriptCutsController.put is a synchronous handler -- awaiting its None return raised TypeError, breaking PUT /show/script/cuts (E2E: "saves cuts"). Rewrote it as a plain sync wrapper matching the established pattern used by every sibling decorator in this file (requires_show, require_admin, no_live_session), which works transparently for both sync and async handlers since Tornado awaits a coroutine return value itself. Added an HTTP-level regression test for the PUT endpoint, since the existing cuts test only exercised the underlying query pattern directly and never went through the decorator stack. Also reduces the SonarCloud new-code duplication that failed the quality gate: extracted the near-identical show-lookup+live-session+RBAC-write-check block that was repeated across REQUEST_SCRIPT_EDIT, REQUEST_SCRIPT_CUTS, SAVE_SCRIPT_DRAFT, and DISCARD_SCRIPT_DRAFT into shared helpers on the WS controller, and merged a nested if flagged by S1066. Verified locally rather than just via pytest, since pytest cannot catch a frontend-contract break: full backend suite (860/860), ruff clean, and the full Playwright E2E suite on both chromium and firefox (221/221 each). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
The Phase 1 port's production-code duplication was already fixed by the RBAC-helper extraction in the previous commit, but new_duplicated_ lines_density was still failing (3.2%, threshold 3%) — the remaining duplication was Show/Script/Revision test-fixture boilerplate repeated near-verbatim across several ported test files. Adds test/helpers/script_fixtures.py (following the existing test/helpers/stage_fixtures.py convention) with a single create_show_script_revision() helper, and uses it in the test classes that duplicated the pattern: TestScriptStatusController, TestNoActiveScriptDraftDecorator, TestRevisionLifecycleGuards, and TestWSControllerIntegration / TestLiveSessionGuards. The first attempt at deduplicating test_ws_controller.py's two classes was wrong: making TestLiveSessionGuards subclass TestWSControllerIntegration also inherited its ~30 test_* methods, silently re-running them under the subclass and inflating the suite from 860 to 890 tests. Fixed by extracting a plain (non-TestCase) _WSTestHelpers mixin holding only _connect_and_auth, which both classes include alongside DigiScriptTestCase — sharing the helper without pytest/unittest picking up each other's tests. Back to 860 passing, confirming no test coverage was lost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
🤖 Automated Review (Claude Code)This is an automated review generated by Claude Code using its Session: https://claude.ai/code/session_017Nhmed96rrh8ZF5dK7hLGY Critical Issues (recommend fixing before merge)1. No authorization on 2. 3. Y.Doc build/join path has no error handling, unlike the rest of this PR 4. WS 5. Editor/cutter mutual exclusion is a TOCTOU race, not an enforced invariant Important Issues
Suggestions
Strengths
Findings are ranked by severity within each section but not otherwise deduplicated across the four agents beyond what's shown above. File:line references were verified by the reviewing agents against the PR branch at the time of review; re-verify against HEAD if the branch has moved on. |
Verified each finding against the actual code before fixing. Four of five Critical items and two Important items were confirmed real; one Critical (TOCTOU race on editor/cutter exclusion) was traced line-by-line and found not to occur in the current code (no await actually yields control between the check and the commit) and is deferred to Phase 4 hardening instead. Full triage in plans/collab_v3_pr1359_review_response.md. - YJS_UPDATE/YJS_SYNC step 2 had no role check: any joined viewer, not just an approved editor, could mutate the shared Y.Doc directly. Added a room.clients.get(self) == "editor" check before applying updates. - CueAssociation.group_id/sort_order were silently dropped on every collaborative line edit that touched an already-grouped/ordered cue — the migration only copied cue_id, unlike the equivalent REST PATCH path. Added the missing fields plus a regression test. - The Y.Doc build-from-DB path had no error handling, unlike its corrupted-draft-file sibling branch — a malformed revision would crash JOIN_SCRIPT_ROOM uncaught. Wrapped it and surfaced a COLLAB_ERROR. - SAVE_SCRIPT_DRAFT/DISCARD_SCRIPT_DRAFT called the deliberately-raising draft-cleanup helper unguarded, after already telling clients the save/discard succeeded. A cleanup failure left the revision reporting itself locked forever with no explanation. Both call sites now guard the cleanup and surface a clear error; discard no longer falsely announces success when cleanup fails. - save_draft's broadcast delta was captured after the deleted_line_ids array was already wiped, so the wipe never reached other clients (only the saving client's own doc got it). Moved the state_before capture earlier. - _has_line_changed never compared page, so a page-only line move was classified "unchanged" and left ScriptLine.page stale in the DB. Also restored a dev-side hardening fix that the earlier 3-way merge had silently dropped: write_message's WebSocketClosedError handler no longer called self.on_close(), leaking dead clients in room.clients forever. Backend suite: 861/861 passing (was 860). ruff clean. Full Playwright E2E suite green (chromium + firefox, run separately — this branch is backend-only so neither run touches new collab code paths). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8
|
Thanks for the review — went through all five Critical findings and the highest-value Important ones, verifying each against the actual code before acting. Fixed in 32d4238: Fixed (verified real):
Investigated, not fixed — C5 (TOCTOU race on editor/cutter exclusion): traced both handlers line-by-line. There's no Also found independently while checking the review (not something it flagged): the earlier 3-way merge had silently dropped a Deferred with reasons (test-coverage suggestions, two-socket convergence tests, an un-scoped Backend suite: 861/861 passing (was 860). |
|



Summary
Phase 1 of the collaborative script editing rebuild (issue #889). Ports the CRDT backend from the abandoned
feature/collaborative-editingbranch (Vue 2 attempt, PR #905, never merged) onto currentdev, and closes the outstanding backend issues a two-pass review found on that branch but that were never actually fixed.Full design/rationale:
plans/COLLABORATIVE_EDITING_V3_PLAN.md(gitignored — ask if you want it pasted).Why this exists / why the old branch stalled: checked out
feature/collaborative-editingin an isolated worktree and found its 221 collab backend tests pass clean against currentdev— the backend design is sound. But a follow-up review of PR #905 found ~40 issues, including a critical one (every keystroke did a full delete+insert on the sharedY.Text, defeating CRDT merge semantics — concurrent editors would clobber each other on every keypress). A fix plan for all 40 was drafted but git history shows none of the 6 batches were ever committed. That, not just design churn, is why it never shipped.Given that, and that the feature is being rebuilt for
client-v3(Vue 3) only per the new plan, this PR:controllers/api/v1/in the interim)script_draftstable +sessions.is_cuttingcolumn migrationThe Vue 3 frontend integration is not in this PR — it's tracked as later phases in the plan doc. This PR adds backend surface only; without a frontend sending
JOIN_SCRIPT_ROOM, none of the new WebSocket ops are reachable, but it does touch shared, already-live code paths (revision guards, the script PATCH helpers, session model) so the full suite (not just collab tests) needs to stay green.What's fixed from the original review
YJS_SYNCstep=2 had no error handling aroundapply_update(crashed the WS coroutine on a malformed payload).yjs.tmpfiles on rename failureclose_active_room()exceptions insideon_close'sfinallyblock went unhandled and unloggeddiscard_active_roomerrors weren't surfaced to the HTTPDELETEcaller (silent divergence)ROOM_CLOSEDbroadcast swallowed all exceptions with a bareexcept: passDISCARD_SCRIPT_DRAFTwas incorrectly blocked during a live session (it's meant to be the one recovery path if a live session starts mid-draft)models/script.py)"Insufficient permissions"was duplicated 6× acrossws_controller.py; centralized to a constantAlso found and fixed one incidental bug while adding test coverage: collab WS handlers call
get_logger().trace(...), but the customTRACElog level is only registered bymain.pyat process start — the test harness never importsmain.py, so every test that actually reached these lines raisedAttributeError. This is why the coverage was missing in the first place. Fixed by registeringTRACEintest/conftest.pytoo.Automated review response (2026-09-08)
A
pr-review-toolkitbot review raised 5 Critical / ~9 Important / ~9 Suggestion findings. Full triage inplans/collab_v3_pr1359_review_response.md(gitignored — ask if you want it pasted). Verified and fixed:YJS_UPDATE/YJS_SYNCstep 2 had no role check — any joined viewer, not just an approved editor, could mutate the shared Y.Doc directlyCueAssociation.group_id/sort_orderwere silently dropped on every collaborative line edit that touched an already-grouped/ordered cueJOIN_SCRIPT_ROOMuncaughtSAVE_SCRIPT_DRAFT/DISCARD_SCRIPT_DRAFTcalled the (deliberately-raising) draft-cleanup helper unguarded, after already telling clients the save/discard succeededdev-side hardening fix (self.on_close()on a dead WS write) — restoredsave_draft's broadcast delta was captured after thedeleted_line_idsclear, so the clear never reached other clients_has_line_changednever comparedpage, so a page-only line move left the DB'sScriptLine.pagestaleOne Critical finding (a claimed TOCTOU race in editor/cutter mutual exclusion) was traced line-by-line and verified not to occur in the current code — no
awaitactually yields control between the check and the commit in either handler. Deferred to Phase 4 (where the mutual-exclusion model gets hardened anyway) rather than patched speculatively. Reasoning and remaining deferred/rejected items are in the ledger doc.Test plan
ruff check/ruff format --check: clean--autogenerateagainst a purpose-built pre-collab baseline DB (verified diff contains exactly the new table + column + index, nothing else)🤖 Generated with Claude Code
https://claude.ai/code/session_01TjTnfchFvvQvKZUZv2jpT8