openpencil/crates/op-editor-core/src/document_install.rs
Kayshen-X 08a26548fe feat(web): daemon-side collaboration service behind /api/collab routes
Give the serve-web daemon a real collaboration runtime so browser and
VSCode-webview editors can drive public-relay sessions through REST,
mirroring the web_auth proxy pattern:

- WebCollabState + a dedicated driver thread (wake channel + 250/100ms
  tick) run CollabRuntime against the daemon document; documentRevision
  and collabSeq are separate so presence/UI changes never trigger a
  whole-document pull
- versioned wire DTOs (CollabStateWire/CollabActionWire, wireVersion 1)
  map through explicit validation onto the internal UI types instead of
  serde on opaque internals; GET state / POST action / POST presence
  routes ride the existing /api/ auth + origin guards
- document pushes during an Active session ingest through a split
  PreparedDocument::prepare (off-lock validation) +
  install_prepared_document (infallible, in-generation) inside a
  begin/finish_local_edit capture; identical pushes produce no txn
- gate_daemon_mutation centralizes session-time refusals (409
  collab-readonly/busy/active) across document push, sync-reset,
  open-recent, /mcp JSON-RPC, and AI apply paths, matching the desktop
  CollabGatePolicy semantics
2026-08-07 21:14:28 +08:00

346 lines
13 KiB
Rust

//! Atomic installation of a pre-validated collaboration document.
//!
//! The collaboration protocol owns wire validation, canonical hashes, and
//! operation replay. This neutral editor-core seam validates document identity
//! invariants, prebuilds editor-derived state, and swaps the document without
//! depending on transport or collaboration types.
use crate::components::ComponentLibrary;
use crate::edit_transaction::EditOrigin;
use crate::fills::{first_solid_fill_hex, first_solid_stroke_hex};
use crate::history::History;
use crate::pen_node_ext::PenNodeExt;
use crate::selection::SelectionState;
use crate::state::EditorState;
use crate::ui_draft::{UiDraftState, VariableUiState};
use crate::variables_resolve::default_theme;
use crate::NodeId;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::PenDocument;
use std::collections::{BTreeMap, HashMap, HashSet};
/// Summary of a successful document installation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DocumentInstallReport {
/// Source that selected the install semantics.
pub origin: EditOrigin,
/// Number of previously selected node ids retained on the active page.
pub retained_selection: usize,
/// Whether the active logical page changed.
pub active_page_changed: bool,
}
/// Structural document failure detected before installation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocumentInstallError {
/// An explicit page has no stable id.
EmptyPageId { page_index: usize },
/// A node has no stable id.
EmptyNodeId,
/// A page or node id occurs more than once in the document.
DuplicateId { id: String },
/// Explicit pages and legacy root children cannot both carry content.
ExplicitPagesWithRootChildren,
}
impl std::fmt::Display for DocumentInstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyPageId { page_index } => {
write!(f, "page at index {page_index} has an empty id")
}
Self::EmptyNodeId => f.write_str("document contains a node with an empty id"),
Self::DuplicateId { id } => write!(f, "document id is not globally unique: {id}"),
Self::ExplicitPagesWithRootChildren => {
f.write_str("a document with explicit pages must have empty root children")
}
}
}
}
impl std::error::Error for DocumentInstallError {}
struct PreparedInstall {
components: ComponentLibrary,
active_page_index: usize,
active_page_changed: bool,
selection: SelectionState,
variables: VariableUiState,
}
/// A document that has cleared every structural check an install performs.
///
/// Validation walks the whole node forest, so a server holding one shared
/// editor behind a mutex must not pay for it under the lock. Splitting the
/// fallible half out lets a caller validate off-lock and then install
/// infallibly, which is what makes an in-generation ingest atomic with
/// respect to a concurrently running collaboration session.
#[derive(Debug, Clone)]
pub struct PreparedDocument {
doc: PenDocument,
}
impl PreparedDocument {
/// Run every structural check an install would run.
pub fn prepare(doc: PenDocument) -> Result<Self, DocumentInstallError> {
validate_install_document(&doc)?;
Ok(Self { doc })
}
/// The validated document, for a caller that needs to compare before
/// deciding to install.
pub fn document(&self) -> &PenDocument {
&self.doc
}
/// Give the validated document back, abandoning the install.
pub fn into_document(self) -> PenDocument {
self.doc
}
}
impl EditorState {
/// Atomically install a collaboration document after neutral validation.
///
/// Viewport, tool, editor settings, chat sessions, and clipboard remain
/// untouched. Document-derived drafts and caches are rebuilt or cleared.
/// `RemoteCommit` and `Replay` preserve the current generation and legacy
/// history without adding an undo entry. `Snapshot` starts a new document
/// generation and clears stale undo history.
pub fn install_verified_document(
&mut self,
doc: PenDocument,
origin: EditOrigin,
) -> Result<DocumentInstallReport, DocumentInstallError> {
Ok(self.install_prepared_document(PreparedDocument::prepare(doc)?, origin))
}
/// Install an already-validated document.
///
/// Infallible by construction — [`PreparedDocument::prepare`] has already
/// done the only fallible work — so this half can run under a lock without
/// an error path that would leave a half-applied document behind.
///
/// Every origin except `Snapshot` keeps the current
/// [`document_generation`](EditorState::document_generation). That is what
/// makes this usable as the in-generation content swap inside an open
/// [`begin_local_edit`](EditorState::begin_local_edit) capture: a bumped
/// generation would make the matching `end_local_edit` fail, and the
/// collaboration runtime reads that failure as a lost session.
pub fn install_prepared_document(
&mut self,
prepared: PreparedDocument,
origin: EditOrigin,
) -> DocumentInstallReport {
let doc = prepared.doc;
let prepared = self.prepare_document_install(&doc);
let local_snapshot = (origin == EditOrigin::Local).then(|| self.snapshot_for_history());
let old_doc = std::mem::replace(&mut self.doc, doc);
self.components = prepared.components;
self.selection = prepared.selection;
self.clear_document_drafts(prepared.variables.active_theme);
self.ui.active_page_index = prepared.active_page_index;
self.ui.variables.fill_refs = prepared.variables.fill_refs;
self.ui.variables.stroke_refs = prepared.variables.stroke_refs;
self.editor_ui.clear_document_derived();
self.codegen.reset_for_document_replacement();
self.app_state_owner.clear();
self.instance_write_virtual_anchor = None;
match origin {
EditOrigin::Local => {
self.history_push_past(local_snapshot.expect("local snapshot was prepared"));
}
EditOrigin::RemoteCommit | EditOrigin::Replay => {
self.mark_document_changed();
}
EditOrigin::Snapshot => {
self.history = History::new();
self.history_push_count = 0;
self.document_generation = self.document_generation.saturating_add(1);
self.mark_document_changed();
}
}
jian_ops_schema::image_thumbs::activate_for_document(&self.doc);
crate::state::drop_document_after_replace(old_doc);
DocumentInstallReport {
origin,
retained_selection: self.selection.len(),
active_page_changed: prepared.active_page_changed,
}
}
fn prepare_document_install(&self, doc: &PenDocument) -> PreparedInstall {
let old_page = active_page_identity(&self.doc, self.ui.active_page_index);
let active_page_index =
choose_active_page(doc, self.ui.active_page_index, old_page.as_deref());
let new_page = active_page_identity(doc, active_page_index);
let active_page_changed = old_page != new_page;
let live_selection_ids = collect_forest_ids(active_children_for(doc, active_page_index));
let set: Vec<_> = self
.selection
.set
.iter()
.filter(|id| live_selection_ids.contains(*id))
.cloned()
.collect();
let selection = SelectionState {
anchor: set.last().cloned().unwrap_or(NodeId::NONE),
set,
};
let (fill_refs, stroke_refs) = collect_color_refs(doc);
let active_theme = reconcile_active_theme(doc, &self.ui.variables.active_theme);
PreparedInstall {
components: ComponentLibrary::from_document(doc),
active_page_index,
active_page_changed,
selection,
variables: VariableUiState {
active_theme,
fill_refs,
stroke_refs,
},
}
}
pub(crate) fn clear_document_drafts(&mut self, active_theme: BTreeMap<String, String>) {
let active_page_index = self.ui.active_page_index;
let fill_refs = std::mem::take(&mut self.ui.variables.fill_refs);
let stroke_refs = std::mem::take(&mut self.ui.variables.stroke_refs);
self.ui = UiDraftState::new();
self.ui.active_page_index = active_page_index;
self.ui.variables = VariableUiState {
active_theme,
fill_refs,
stroke_refs,
};
}
}
fn validate_install_document(doc: &PenDocument) -> Result<(), DocumentInstallError> {
if doc
.pages
.as_ref()
.is_some_and(|pages| !pages.is_empty() && !doc.children.is_empty())
{
return Err(DocumentInstallError::ExplicitPagesWithRootChildren);
}
let mut seen = HashSet::new();
let mut stack = Vec::new();
if let Some(pages) = doc.pages.as_ref() {
for (page_index, page) in pages.iter().enumerate() {
if page.id.is_empty() {
return Err(DocumentInstallError::EmptyPageId { page_index });
}
if !seen.insert(page.id.clone()) {
return Err(DocumentInstallError::DuplicateId {
id: page.id.clone(),
});
}
stack.extend(page.children.iter());
}
}
stack.extend(doc.children.iter());
while let Some(node) = stack.pop() {
let id = node.id_str();
if id.is_empty() {
return Err(DocumentInstallError::EmptyNodeId);
}
if !seen.insert(id.to_string()) {
return Err(DocumentInstallError::DuplicateId { id: id.to_string() });
}
if let Some(children) = node.children() {
stack.extend(children.iter());
}
}
Ok(())
}
fn active_page_identity(doc: &PenDocument, index: usize) -> Option<String> {
match doc.pages.as_ref() {
Some(pages) if !pages.is_empty() => pages
.get(index.min(pages.len() - 1))
.map(|page| page.id.clone()),
_ => None,
}
}
fn choose_active_page(doc: &PenDocument, old_index: usize, old_id: Option<&str>) -> usize {
let Some(pages) = doc.pages.as_ref().filter(|pages| !pages.is_empty()) else {
return 0;
};
old_id
.and_then(|id| pages.iter().position(|page| page.id == id))
.unwrap_or_else(|| old_index.min(pages.len() - 1))
}
fn active_children_for(doc: &PenDocument, index: usize) -> &[PenNode] {
match doc.pages.as_ref() {
Some(pages) if !pages.is_empty() => &pages[index.min(pages.len() - 1)].children,
_ => &doc.children,
}
}
fn collect_forest_ids(nodes: &[PenNode]) -> HashSet<NodeId> {
let mut out = HashSet::new();
let mut stack: Vec<_> = nodes.iter().collect();
while let Some(node) = stack.pop() {
out.insert(NodeId::new(node.id_str()));
if let Some(children) = node.children() {
stack.extend(children.iter());
}
}
out
}
fn collect_color_refs(doc: &PenDocument) -> (HashMap<NodeId, String>, HashMap<NodeId, String>) {
let mut fill_refs = HashMap::new();
let mut stroke_refs = HashMap::new();
let mut stack: Vec<_> = doc.children.iter().collect();
if let Some(pages) = doc.pages.as_ref() {
for page in pages {
stack.extend(page.children.iter());
}
}
while let Some(node) = stack.pop() {
let id = NodeId::new(node.id_str());
if let Some(name) = first_solid_fill_hex(node).and_then(variable_name) {
fill_refs.insert(id.clone(), name.to_string());
}
if let Some(name) = first_solid_stroke_hex(node).and_then(variable_name) {
stroke_refs.insert(id, name.to_string());
}
if let Some(children) = node.children() {
stack.extend(children.iter());
}
}
(fill_refs, stroke_refs)
}
fn variable_name(value: &str) -> Option<&str> {
value.strip_prefix('$').filter(|name| !name.is_empty())
}
fn reconcile_active_theme(
doc: &PenDocument,
current: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut reconciled = default_theme(doc.themes.as_ref());
let Some(themes) = doc.themes.as_ref() else {
return reconciled;
};
for (axis, current_value) in current {
if themes
.get(axis)
.is_some_and(|values| values.contains(current_value))
{
reconciled.insert(axis.clone(), current_value.clone());
}
}
reconciled
}