[EPIC] feat: Component Subscription System #27

Open
opened 2026-02-16 15:38:20 +00:00 by forbes · 0 comments
Owner

Context-Aware Part Subscription System — Workbench UI and Sync

Repository: silo-mod
Type: Feature
Depends on: silo-client subscription + edit session + checkpoint methods, silo server endpoints


Summary

Add subscription management, automatic background sync, edit session locking, and editing-context-driven checkpoint pushing to the Silo workbench. The system hooks into Kindred Create's EditingContextResolver at both ends of the editing lifecycle:

  • On context entry (downward transition): acquire an edit session lock from the server. If hard interference → block entry. If soft → allow with visual warning.
  • On context exit (upward transition): push checkpoint + release lock atomically. Server notifies subscribers.

The result: real-time presence awareness, conflict prevention before it happens, and automatic file distribution — all driven by the signals the EditingContextResolver already emits.


Architecture

EditingContextResolver (C++, src/Gui/EditingContext.h)
    │
    ├─ contextChanged signal: downward transition detected
    │   │
    │   ▼
    │  ContextSyncHook._on_entering_context()
    │   │
    │   ├─ client.acquire_edit_session(pn, ws_id, level, object_id, cone)
    │   │   │
    │   │   ├─ 200 interference: "none" → allow entry
    │   │   ├─ 200 interference: "soft" → allow entry + show warning
    │   │   └─ 409 hard interference → BLOCK ENTRY, show holder info
    │   │
    │   └─ store session_id for later release
    │
    ├─ contextChanged signal: upward transition detected
    │   │
    │   ▼
    │  ContextSyncHook._on_exiting_context()
    │   │
    │   ├─ doc.save()
    │   ├─ extract DAG
    │   └─ client.push_checkpoint(pn, path, context, session_id, dag)
    │       │
    │       └─ Server atomically:
    │           ├─ stores checkpoint
    │           ├─ syncs DAG
    │           ├─ releases edit session
    │           └─ emits subscription.checkpoint → subscribers
    │
    └─ SSE events from server
        │
        ▼
       SyncManager (background thread)
        │
        ├─ edit.session_acquired → update presence indicators
        ├─ edit.session_released → clear presence indicators
        ├─ edit.handoff_requested → show handoff dialog
        ├─ subscription.checkpoint → download file from other user
        └─ subscription.revision → download formal revision

Phase A: Edit Session Enforcement

Context Entry Gate

The ContextSyncHook intercepts downward context transitions (shallower → deeper) on Silo-tracked documents and gates them through the server's edit session system.

CONTEXT_DEPTH = {
    None: 0,            # document level
    "Assembly": 1,
    "PartDesign": 2,
    "Sketcher": 3,
}

# Map Create context types to edit session levels
CONTEXT_TO_LEVEL = {
    "Sketcher": LEVEL_SKETCH,
    "PartDesign": LEVEL_PARTDESIGN,
    "Assembly": LEVEL_ASSEMBLY,
}

class ContextSyncHook:
    def __init__(self, client, sync_manager):
        self._client = client
        self._sync_manager = sync_manager
        self._active_sessions = {}  # context_key → session_id

        resolver = get_editing_context_resolver()
        resolver.contextChanged.connect(self._on_context_changed)

    def _on_context_changed(self, old_context, new_context):
        doc = FreeCAD.ActiveDocument
        if not doc or not self._is_silo_tracked(doc):
            return

        old_depth = CONTEXT_DEPTH.get(old_context.type, 0)
        new_depth = CONTEXT_DEPTH.get(new_context.type, 0)

        if new_depth > old_depth:
            self._on_entering_context(doc, new_context)
        elif new_depth < old_depth:
            self._on_exiting_context(doc, old_context, new_context)

Acquiring Locks on Entry

    def _on_entering_context(self, doc, context):
        if not self._settings.get("edit_locking_enabled", True):
            return

        part_number = doc.SiloPartNumber
        level = CONTEXT_TO_LEVEL.get(context.type)
        if not level:
            return

        object_id = self._get_object_id(context)
        cone = self._compute_dependency_cone(doc, object_id)

        try:
            result = self._client.acquire_edit_session(
                part_number=part_number,
                workstation_id=self._sync_manager.workstation_id,
                context_level=level,
                object_id=object_id,
                dependency_cone=cone,
            )
        except HardInterferenceError as e:
            # BLOCK ENTRY — cancel the context transition
            self._block_context_entry(doc, context, e.holder, e.message)
            return
        except ConnectionError:
            # Offline — allow entry with warning (optimistic)
            self._warn_offline_editing(doc, context)
            return

        # Store session for later release
        key = f"{part_number}:{level}:{object_id}"
        self._active_sessions[key] = result.session_id

        if result.interference == "soft":
            self._show_soft_interference_warning(doc, context, result.conflicts)

Blocking Hard Interference

When hard interference is detected, the user is prevented from entering the editing context. This requires integration with the EditingContextResolver to cancel or reject the transition:

    def _block_context_entry(self, doc, context, holder, message):
        """
        Prevent the user from entering this editing context.
        Show a dialog with the lock holder's info and options.
        """
        from PySide6.QtWidgets import QMessageBox

        dialog = QMessageBox(FreeCADGui.getMainWindow())
        dialog.setIcon(QMessageBox.Warning)
        dialog.setWindowTitle("Editing Blocked")
        dialog.setText(
            f"{holder.user} is currently editing {holder.object_id} "
            f"on {holder.workstation}."
        )
        dialog.setInformativeText(
            f"Started: {holder.acquired_at.strftime('%H:%M')}\n\n"
            "You cannot edit this object until they finish."
        )

        request_btn = dialog.addButton("Request Handoff", QMessageBox.ActionRole)
        dialog.addButton("OK", QMessageBox.AcceptRole)
        dialog.exec()

        if dialog.clickedButton() == request_btn:
            self._client.request_handoff(
                doc.SiloPartNumber,
                # Need the holder's session_id — included in the error
                holder.session_id,
                message=f"{self._current_user()} would like to edit {holder.object_id}",
            )

        # Cancel the context entry — EditingContextResolver resets to previous state
        self._cancel_context_entry(context)

The _cancel_context_entry method calls the EditingContextResolver to reject the transition. Implementation detail depends on whether the resolver supports a cancelTransition() API or whether we need to immediately call resetEdit() to back out.

Soft Interference Warning

Soft interference allows entry but shows a non-blocking indicator:

    def _show_soft_interference_warning(self, doc, context, conflicts):
        """
        Show a non-modal warning in the Activity pane and feature tree.
        User can proceed — this is informational.
        """
        for conflict in conflicts:
            msg = (
                f"⚠ {conflict.user} is editing {conflict.object_id} — "
                f"shares downstream features: {', '.join(conflict.shared_nodes[:3])}"
            )
            self._sync_manager.soft_interference.emit(
                doc.SiloPartNumber, conflict.user,
                conflict.object_id, conflict.shared_nodes,
            )
        # TODO: highlight affected nodes in the feature tree (Phase D polish)

Presence Indicators via SSE

The sync manager listens for edit.session_acquired and edit.session_released events to maintain a live map of who's editing what. This is displayed before the user attempts to enter a context:

    # In SyncManager SSE handler:
    def _handle_edit_session_event(self, event):
        if event.type == SSE_EDIT_SESSION_ACQUIRED:
            self._active_remote_sessions[event.session_id] = {
                "user": event.user,
                "item": event.part_number,
                "level": event.context_level,
                "object": event.object_id,
            }
            self.presence_changed.emit(event.part_number)

        elif event.type == SSE_EDIT_SESSION_RELEASED:
            self._active_remote_sessions.pop(event.session_id, None)
            self.presence_changed.emit(event.part_number)

The Start Panel, feature tree, and Silo_Info dialog can query sync_manager.active_remote_sessions to show who's editing what before the user even tries to enter edit mode.


Phase B: Checkpoint Push on Context Exit

Releasing Locks on Exit

    def _on_exiting_context(self, doc, old_context, new_context):
        if not self._settings.get("push_on_context_close", True):
            return

        part_number = doc.SiloPartNumber
        context = self._classify_transition(old_context, new_context)
        if not context:
            return

        # Find the session to release
        level = CONTEXT_TO_LEVEL.get(old_context.type)
        object_id = self._get_object_id(old_context)
        key = f"{part_number}:{level}:{object_id}"
        session_id = self._active_sessions.pop(key, None)

        # Queue checkpoint + release in background
        self._sync_manager.queue_checkpoint(
            doc, context, session_id=session_id
        )

    def _classify_transition(self, old_ctx, new_ctx) -> Optional[str]:
        old_type = old_ctx.type
        new_type = new_ctx.type

        if old_type == "Sketcher" and new_type == "PartDesign":
            return CONTEXT_SKETCH_CLOSE
        elif old_type == "Sketcher":
            return CONTEXT_BODY_CLOSE
        elif old_type == "PartDesign":
            return CONTEXT_BODY_CLOSE
        elif old_type == "Assembly":
            return CONTEXT_ASSEMBLY_CLOSE
        return None

Checkpoint Queue Processing

class SyncManager(QObject):
    def queue_checkpoint(self, doc, context, session_id=None):
        part_number = doc.SiloPartNumber
        file_path = Path(doc.FileName)

        if self._file_unchanged_since_last_checkpoint(file_path):
            # No changes — just release the session if we have one
            if session_id:
                self._client.release_edit_session(part_number, session_id)
            return

        doc.save()
        dag = self._extract_dag(doc)

        self._checkpoint_queue.put(CheckpointJob(
            part_number=part_number,
            file_path=file_path,
            context=context,
            session_id=session_id,
            dag=dag,
        ))

    def _process_checkpoint_queue(self):
        """Background thread processing outbound checkpoints."""
        while True:
            job = self._checkpoint_queue.get()
            try:
                result = self._client.push_checkpoint(
                    part_number=job.part_number,
                    file_path=job.file_path,
                    context=job.context,
                    session_id=job.session_id,
                    dag=job.dag,
                )
                self.checkpoint_pushed.emit(
                    job.part_number, job.context, result.subscribers_notified
                )
            except Exception as e:
                self.sync_error.emit(job.part_number, str(e))
                # If checkpoint failed but we have a session, release it directly
                if job.session_id:
                    try:
                        self._client.release_edit_session(
                            job.part_number, job.session_id
                        )
                    except Exception:
                        pass  # Session will expire via heartbeat timeout

Handling Handoff Requests

When the server sends edit.handoff_requested SSE:

    def _handle_handoff_request(self, event):
        """Another user wants the lock we're holding."""
        # Find our local session for this item
        for key, sid in self._context_hook._active_sessions.items():
            if sid == event.session_id:
                self.handoff_requested.emit(
                    event.part_number, event.requester, event.message, sid
                )
                break

The Activity pane renders the handoff request with action buttons:

🔔 alice requests access to Sketch001 on PRT-100
   "Would like to edit the mounting holes"
   [Release Now] [Defer] [Dismiss]

Release Now: pushes a checkpoint if dirty, then releases the session. The user is exited from the editing context.

Defer: sends a response SSE (future — for now, just dismisses the notification).


Phase C: Subscription UI and Background Sync

New Commands

Command Menu Label Function
Silo_Subscribe Subscribe... Browse/create subscriptions
Silo_Subscriptions Manage Subscriptions List, pause, policy, unsubscribe
Silo_SyncStatus Sync Status Summary + manual sync

Subscribe Dialog

┌── Subscribe ──────────────────────────────────────┐
│                                                    │
│  Type: (•) Items  ( ) Projects  ( ) Categories     │
│                                                    │
│  ┌────────────────────────────────────────────────┐│
│  │ 🔍 Search...                                   ││
│  ├────────────────────────────────────────────────┤│
│  │ ☑ PRT-100  Mounting Bracket       🟢 (alice)  ││
│  │ ☑ PRT-101  Side Plate                         ││
│  │ ☐ ASM-200  Motor Assembly         🟡 (bob)    ││
│  └────────────────────────────────────────────────┘│
│                                                    │
│  Revision Policy: [Latest Released ▾]              │
│                                                    │
│  Note: "Latest" includes working checkpoints       │
│  from other users' editing sessions.               │
│                                                    │
│                      [Subscribe]  [Cancel]         │
└────────────────────────────────────────────────────┘

Presence indicators (🟢/🟡) show active edit sessions inline in the item list. This leverages the same active_remote_sessions map used for interference detection.

Background Download Queue (Inbound)

SSE event handling:

Event Action
subscription.revision Queue download (formal revision)
subscription.checkpoint Queue download (if not from self)
subscription.sync_required get_sync_diff(), queue all
subscription.update get_sync_diff(), queue results

Per-file download flow:

  1. Check open files → defer if open
  2. Check local hash → flag conflict if mismatch
  3. client.download_file() → compute hash
  4. client.ack_sync() → update sync state
  5. Emit signal for Activity pane

Skip own checkpoints (checkpoint_user == current_user).

Reconnect Reconciliation

On every SSE connection:

  1. get_sync_diff(workstation_id) — catch up on missed updates
  2. get_edit_sessions() for any items we have subscriptions to — rebuild presence map
  3. Queue downloads, process removals

File Conflict Handling

Scenario Resolution
Remote update, local unmodified Overwrite silently
Remote update, local modified Notify, [Overwrite] [Skip]
Remote update, file open Defer until close
Checkpoint from self Skip
Subscription removed Remove from tracking, keep file

Phase D: Activity Pane and Presence Polish

Activity Pane Events

Outbound checkpoint:

⬆ Checkpoint: PRT-100 (body_close) — 3 subscribers notified

Inbound revision:

⬇ Synced: PRT-100 rev 3 → rev 5
  [Open] [View in Silo →]

Inbound checkpoint:

⬇ Updated: PRT-100 — alice finished editing Body
  Working checkpoint (not a formal revision)
  [Open] [View in Silo →]

Hard interference blocked:

🔒 Blocked: cannot edit Sketch001 on PRT-100
   alice is editing (since 14:00)  [Request Handoff]

Soft interference warning:

⚠ alice is editing Sketch003 on PRT-100
  Shares downstream: Pad003, Fillet005

Handoff request:

🔔 alice requests Sketch001 on PRT-100
  [Release Now] [Defer] [Dismiss]

Feature Tree Presence (Future Polish)

When presence_changed fires, update visual indicators on the feature tree:

  • 🟢 Green dot: another user is viewing this item (has it open)
  • 🟡 Yellow dot: another user is editing within this item (has an active edit session)
  • 🔴 Red dot: another user is editing this specific object (hard interference if you try)

This is lower priority than the functional locking but significantly improves the UX by surfacing conflicts before the user clicks into edit mode.


Settings

Setting Key Default Widget
Edit locking enabled edit_locking_enabled true Checkbox
Push on context close push_on_context_close true Checkbox
Auto-sync enabled subscription_auto_sync true Checkbox
Max concurrent downloads subscription_max_downloads 2 Spinbox (1-8)
Sync on connect subscription_sync_on_connect true Checkbox
Notify on sync subscription_notify true Checkbox

"Edit locking enabled" disables the acquire/release flow entirely. Useful for solo users who don't want server round-trips on every context transition. When disabled, the ContextSyncHook still pushes checkpoints on exit but does not acquire locks on entry.


Signals

class SyncManager(QObject):
    # Outbound
    checkpoint_pushed = Signal(str, str, int)       # pn, context, sub_count

    # Inbound
    file_synced = Signal(str, str, int, bool)       # pn, path, rev, is_checkpoint
    file_deferred = Signal(str, str, int)            # pn, path, target_rev
    file_conflict = Signal(str, str, str)            # pn, path, conflict_type
    bulk_sync_complete = Signal(int, str)             # count, sub_target

    # Edit sessions
    presence_changed = Signal(str)                    # part_number
    soft_interference = Signal(str, str, str, list)   # pn, user, object, shared_nodes
    hard_interference_blocked = Signal(str, str, str) # pn, holder_user, object
    handoff_requested = Signal(str, str, str, str)    # pn, requester, message, session_id

    # Errors
    sync_error = Signal(str, str)                     # pn, error_message

Acceptance Criteria

Phase A: Edit Session Enforcement

  • Downward context transitions on Silo-tracked docs trigger acquire_edit_session
  • Hard interference (409) blocks entry, shows holder info dialog
  • Hard interference dialog offers "Request Handoff" action
  • Soft interference allows entry, shows warning in Activity pane
  • No-interference allows entry silently
  • Session ID stored for later release
  • edit_locking_enabled=false skips acquisition entirely
  • Offline/disconnected allows entry with warning (optimistic fallback)
  • SSE edit.session_acquired/released events update presence map
  • Handoff request received → notification with [Release] [Defer] [Dismiss]
  • "Release Now" on handoff pushes checkpoint if dirty, exits context

Phase B: Checkpoint Push on Exit

  • Upward transitions on Silo-tracked docs trigger push_checkpoint
  • session_id included in checkpoint → atomic release
  • Unchanged files skip checkpoint, release session directly
  • Document saved before checkpoint upload
  • DAG extracted and included
  • push_on_context_close=false disables push but session still released
  • Checkpoint failure still releases session (via direct release or heartbeat timeout)
  • sketch_close releases sketch session, keeps body session
  • body_close releases body session

Phase C: Subscriptions and Background Sync

  • Subscribe dialog with presence indicators in item list
  • Subscription manager with pause/policy/unsubscribe
  • Sync status shows pending/checkpoint/deferred counts
  • subscription.checkpoint events trigger download (skip self)
  • subscription.revision events trigger download
  • Open files deferred, synced on close
  • Reconnect runs full sync/diff + rebuilds presence map
  • Manual Sync Now works

Phase D: Activity Pane and Presence

  • Outbound checkpoint events rendered
  • Inbound revisions and checkpoints rendered with context
  • Hard interference blocks rendered with handoff action
  • Soft interference warnings rendered with shared node list
  • Handoff requests rendered with action buttons
  • Bulk syncs grouped
# Context-Aware Part Subscription System — Workbench UI and Sync **Repository:** `silo-mod` **Type:** Feature **Depends on:** `silo-client` subscription + edit session + checkpoint methods, `silo` server endpoints --- ## Summary Add subscription management, automatic background sync, edit session locking, and editing-context-driven checkpoint pushing to the Silo workbench. The system hooks into Kindred Create's `EditingContextResolver` at both ends of the editing lifecycle: - **On context entry** (downward transition): acquire an edit session lock from the server. If hard interference → block entry. If soft → allow with visual warning. - **On context exit** (upward transition): push checkpoint + release lock atomically. Server notifies subscribers. The result: real-time presence awareness, conflict prevention before it happens, and automatic file distribution — all driven by the signals the `EditingContextResolver` already emits. --- ## Architecture ``` EditingContextResolver (C++, src/Gui/EditingContext.h) │ ├─ contextChanged signal: downward transition detected │ │ │ ▼ │ ContextSyncHook._on_entering_context() │ │ │ ├─ client.acquire_edit_session(pn, ws_id, level, object_id, cone) │ │ │ │ │ ├─ 200 interference: "none" → allow entry │ │ ├─ 200 interference: "soft" → allow entry + show warning │ │ └─ 409 hard interference → BLOCK ENTRY, show holder info │ │ │ └─ store session_id for later release │ ├─ contextChanged signal: upward transition detected │ │ │ ▼ │ ContextSyncHook._on_exiting_context() │ │ │ ├─ doc.save() │ ├─ extract DAG │ └─ client.push_checkpoint(pn, path, context, session_id, dag) │ │ │ └─ Server atomically: │ ├─ stores checkpoint │ ├─ syncs DAG │ ├─ releases edit session │ └─ emits subscription.checkpoint → subscribers │ └─ SSE events from server │ ▼ SyncManager (background thread) │ ├─ edit.session_acquired → update presence indicators ├─ edit.session_released → clear presence indicators ├─ edit.handoff_requested → show handoff dialog ├─ subscription.checkpoint → download file from other user └─ subscription.revision → download formal revision ``` --- ## Phase A: Edit Session Enforcement ### Context Entry Gate The `ContextSyncHook` intercepts **downward** context transitions (shallower → deeper) on Silo-tracked documents and gates them through the server's edit session system. ```python CONTEXT_DEPTH = { None: 0, # document level "Assembly": 1, "PartDesign": 2, "Sketcher": 3, } # Map Create context types to edit session levels CONTEXT_TO_LEVEL = { "Sketcher": LEVEL_SKETCH, "PartDesign": LEVEL_PARTDESIGN, "Assembly": LEVEL_ASSEMBLY, } class ContextSyncHook: def __init__(self, client, sync_manager): self._client = client self._sync_manager = sync_manager self._active_sessions = {} # context_key → session_id resolver = get_editing_context_resolver() resolver.contextChanged.connect(self._on_context_changed) def _on_context_changed(self, old_context, new_context): doc = FreeCAD.ActiveDocument if not doc or not self._is_silo_tracked(doc): return old_depth = CONTEXT_DEPTH.get(old_context.type, 0) new_depth = CONTEXT_DEPTH.get(new_context.type, 0) if new_depth > old_depth: self._on_entering_context(doc, new_context) elif new_depth < old_depth: self._on_exiting_context(doc, old_context, new_context) ``` ### Acquiring Locks on Entry ```python def _on_entering_context(self, doc, context): if not self._settings.get("edit_locking_enabled", True): return part_number = doc.SiloPartNumber level = CONTEXT_TO_LEVEL.get(context.type) if not level: return object_id = self._get_object_id(context) cone = self._compute_dependency_cone(doc, object_id) try: result = self._client.acquire_edit_session( part_number=part_number, workstation_id=self._sync_manager.workstation_id, context_level=level, object_id=object_id, dependency_cone=cone, ) except HardInterferenceError as e: # BLOCK ENTRY — cancel the context transition self._block_context_entry(doc, context, e.holder, e.message) return except ConnectionError: # Offline — allow entry with warning (optimistic) self._warn_offline_editing(doc, context) return # Store session for later release key = f"{part_number}:{level}:{object_id}" self._active_sessions[key] = result.session_id if result.interference == "soft": self._show_soft_interference_warning(doc, context, result.conflicts) ``` ### Blocking Hard Interference When hard interference is detected, the user is prevented from entering the editing context. This requires integration with the `EditingContextResolver` to cancel or reject the transition: ```python def _block_context_entry(self, doc, context, holder, message): """ Prevent the user from entering this editing context. Show a dialog with the lock holder's info and options. """ from PySide6.QtWidgets import QMessageBox dialog = QMessageBox(FreeCADGui.getMainWindow()) dialog.setIcon(QMessageBox.Warning) dialog.setWindowTitle("Editing Blocked") dialog.setText( f"{holder.user} is currently editing {holder.object_id} " f"on {holder.workstation}." ) dialog.setInformativeText( f"Started: {holder.acquired_at.strftime('%H:%M')}\n\n" "You cannot edit this object until they finish." ) request_btn = dialog.addButton("Request Handoff", QMessageBox.ActionRole) dialog.addButton("OK", QMessageBox.AcceptRole) dialog.exec() if dialog.clickedButton() == request_btn: self._client.request_handoff( doc.SiloPartNumber, # Need the holder's session_id — included in the error holder.session_id, message=f"{self._current_user()} would like to edit {holder.object_id}", ) # Cancel the context entry — EditingContextResolver resets to previous state self._cancel_context_entry(context) ``` The `_cancel_context_entry` method calls the `EditingContextResolver` to reject the transition. Implementation detail depends on whether the resolver supports a `cancelTransition()` API or whether we need to immediately call `resetEdit()` to back out. ### Soft Interference Warning Soft interference allows entry but shows a non-blocking indicator: ```python def _show_soft_interference_warning(self, doc, context, conflicts): """ Show a non-modal warning in the Activity pane and feature tree. User can proceed — this is informational. """ for conflict in conflicts: msg = ( f"⚠ {conflict.user} is editing {conflict.object_id} — " f"shares downstream features: {', '.join(conflict.shared_nodes[:3])}" ) self._sync_manager.soft_interference.emit( doc.SiloPartNumber, conflict.user, conflict.object_id, conflict.shared_nodes, ) # TODO: highlight affected nodes in the feature tree (Phase D polish) ``` ### Presence Indicators via SSE The sync manager listens for `edit.session_acquired` and `edit.session_released` events to maintain a live map of who's editing what. This is displayed **before** the user attempts to enter a context: ```python # In SyncManager SSE handler: def _handle_edit_session_event(self, event): if event.type == SSE_EDIT_SESSION_ACQUIRED: self._active_remote_sessions[event.session_id] = { "user": event.user, "item": event.part_number, "level": event.context_level, "object": event.object_id, } self.presence_changed.emit(event.part_number) elif event.type == SSE_EDIT_SESSION_RELEASED: self._active_remote_sessions.pop(event.session_id, None) self.presence_changed.emit(event.part_number) ``` The Start Panel, feature tree, and `Silo_Info` dialog can query `sync_manager.active_remote_sessions` to show who's editing what before the user even tries to enter edit mode. --- ## Phase B: Checkpoint Push on Context Exit ### Releasing Locks on Exit ```python def _on_exiting_context(self, doc, old_context, new_context): if not self._settings.get("push_on_context_close", True): return part_number = doc.SiloPartNumber context = self._classify_transition(old_context, new_context) if not context: return # Find the session to release level = CONTEXT_TO_LEVEL.get(old_context.type) object_id = self._get_object_id(old_context) key = f"{part_number}:{level}:{object_id}" session_id = self._active_sessions.pop(key, None) # Queue checkpoint + release in background self._sync_manager.queue_checkpoint( doc, context, session_id=session_id ) def _classify_transition(self, old_ctx, new_ctx) -> Optional[str]: old_type = old_ctx.type new_type = new_ctx.type if old_type == "Sketcher" and new_type == "PartDesign": return CONTEXT_SKETCH_CLOSE elif old_type == "Sketcher": return CONTEXT_BODY_CLOSE elif old_type == "PartDesign": return CONTEXT_BODY_CLOSE elif old_type == "Assembly": return CONTEXT_ASSEMBLY_CLOSE return None ``` ### Checkpoint Queue Processing ```python class SyncManager(QObject): def queue_checkpoint(self, doc, context, session_id=None): part_number = doc.SiloPartNumber file_path = Path(doc.FileName) if self._file_unchanged_since_last_checkpoint(file_path): # No changes — just release the session if we have one if session_id: self._client.release_edit_session(part_number, session_id) return doc.save() dag = self._extract_dag(doc) self._checkpoint_queue.put(CheckpointJob( part_number=part_number, file_path=file_path, context=context, session_id=session_id, dag=dag, )) def _process_checkpoint_queue(self): """Background thread processing outbound checkpoints.""" while True: job = self._checkpoint_queue.get() try: result = self._client.push_checkpoint( part_number=job.part_number, file_path=job.file_path, context=job.context, session_id=job.session_id, dag=job.dag, ) self.checkpoint_pushed.emit( job.part_number, job.context, result.subscribers_notified ) except Exception as e: self.sync_error.emit(job.part_number, str(e)) # If checkpoint failed but we have a session, release it directly if job.session_id: try: self._client.release_edit_session( job.part_number, job.session_id ) except Exception: pass # Session will expire via heartbeat timeout ``` ### Handling Handoff Requests When the server sends `edit.handoff_requested` SSE: ```python def _handle_handoff_request(self, event): """Another user wants the lock we're holding.""" # Find our local session for this item for key, sid in self._context_hook._active_sessions.items(): if sid == event.session_id: self.handoff_requested.emit( event.part_number, event.requester, event.message, sid ) break ``` The Activity pane renders the handoff request with action buttons: ``` 🔔 alice requests access to Sketch001 on PRT-100 "Would like to edit the mounting holes" [Release Now] [Defer] [Dismiss] ``` **Release Now**: pushes a checkpoint if dirty, then releases the session. The user is exited from the editing context. **Defer**: sends a response SSE (future — for now, just dismisses the notification). --- ## Phase C: Subscription UI and Background Sync ### New Commands | Command | Menu Label | Function | |---------|------------|----------| | `Silo_Subscribe` | Subscribe... | Browse/create subscriptions | | `Silo_Subscriptions` | Manage Subscriptions | List, pause, policy, unsubscribe | | `Silo_SyncStatus` | Sync Status | Summary + manual sync | ### Subscribe Dialog ``` ┌── Subscribe ──────────────────────────────────────┐ │ │ │ Type: (•) Items ( ) Projects ( ) Categories │ │ │ │ ┌────────────────────────────────────────────────┐│ │ │ 🔍 Search... ││ │ ├────────────────────────────────────────────────┤│ │ │ ☑ PRT-100 Mounting Bracket 🟢 (alice) ││ │ │ ☑ PRT-101 Side Plate ││ │ │ ☐ ASM-200 Motor Assembly 🟡 (bob) ││ │ └────────────────────────────────────────────────┘│ │ │ │ Revision Policy: [Latest Released ▾] │ │ │ │ Note: "Latest" includes working checkpoints │ │ from other users' editing sessions. │ │ │ │ [Subscribe] [Cancel] │ └────────────────────────────────────────────────────┘ ``` Presence indicators (🟢/🟡) show active edit sessions inline in the item list. This leverages the same `active_remote_sessions` map used for interference detection. ### Background Download Queue (Inbound) SSE event handling: | Event | Action | |-------|--------| | `subscription.revision` | Queue download (formal revision) | | `subscription.checkpoint` | Queue download (if not from self) | | `subscription.sync_required` | `get_sync_diff()`, queue all | | `subscription.update` | `get_sync_diff()`, queue results | Per-file download flow: 1. Check open files → defer if open 2. Check local hash → flag conflict if mismatch 3. `client.download_file()` → compute hash 4. `client.ack_sync()` → update sync state 5. Emit signal for Activity pane Skip own checkpoints (`checkpoint_user == current_user`). ### Reconnect Reconciliation On every SSE connection: 1. `get_sync_diff(workstation_id)` — catch up on missed updates 2. `get_edit_sessions()` for any items we have subscriptions to — rebuild presence map 3. Queue downloads, process removals ### File Conflict Handling | Scenario | Resolution | |----------|------------| | Remote update, local unmodified | Overwrite silently | | Remote update, local modified | Notify, [Overwrite] [Skip] | | Remote update, file open | Defer until close | | Checkpoint from self | Skip | | Subscription removed | Remove from tracking, keep file | --- ## Phase D: Activity Pane and Presence Polish ### Activity Pane Events **Outbound checkpoint:** ``` ⬆ Checkpoint: PRT-100 (body_close) — 3 subscribers notified ``` **Inbound revision:** ``` ⬇ Synced: PRT-100 rev 3 → rev 5 [Open] [View in Silo →] ``` **Inbound checkpoint:** ``` ⬇ Updated: PRT-100 — alice finished editing Body Working checkpoint (not a formal revision) [Open] [View in Silo →] ``` **Hard interference blocked:** ``` 🔒 Blocked: cannot edit Sketch001 on PRT-100 alice is editing (since 14:00) [Request Handoff] ``` **Soft interference warning:** ``` ⚠ alice is editing Sketch003 on PRT-100 Shares downstream: Pad003, Fillet005 ``` **Handoff request:** ``` 🔔 alice requests Sketch001 on PRT-100 [Release Now] [Defer] [Dismiss] ``` ### Feature Tree Presence (Future Polish) When `presence_changed` fires, update visual indicators on the feature tree: - 🟢 Green dot: another user is viewing this item (has it open) - 🟡 Yellow dot: another user is editing within this item (has an active edit session) - 🔴 Red dot: another user is editing this specific object (hard interference if you try) This is lower priority than the functional locking but significantly improves the UX by surfacing conflicts *before* the user clicks into edit mode. --- ## Settings | Setting | Key | Default | Widget | |---------|-----|---------|--------| | Edit locking enabled | `edit_locking_enabled` | `true` | Checkbox | | Push on context close | `push_on_context_close` | `true` | Checkbox | | Auto-sync enabled | `subscription_auto_sync` | `true` | Checkbox | | Max concurrent downloads | `subscription_max_downloads` | `2` | Spinbox (1-8) | | Sync on connect | `subscription_sync_on_connect` | `true` | Checkbox | | Notify on sync | `subscription_notify` | `true` | Checkbox | **"Edit locking enabled"** disables the acquire/release flow entirely. Useful for solo users who don't want server round-trips on every context transition. When disabled, the `ContextSyncHook` still pushes checkpoints on exit but does not acquire locks on entry. --- ## Signals ```python class SyncManager(QObject): # Outbound checkpoint_pushed = Signal(str, str, int) # pn, context, sub_count # Inbound file_synced = Signal(str, str, int, bool) # pn, path, rev, is_checkpoint file_deferred = Signal(str, str, int) # pn, path, target_rev file_conflict = Signal(str, str, str) # pn, path, conflict_type bulk_sync_complete = Signal(int, str) # count, sub_target # Edit sessions presence_changed = Signal(str) # part_number soft_interference = Signal(str, str, str, list) # pn, user, object, shared_nodes hard_interference_blocked = Signal(str, str, str) # pn, holder_user, object handoff_requested = Signal(str, str, str, str) # pn, requester, message, session_id # Errors sync_error = Signal(str, str) # pn, error_message ``` --- ## Acceptance Criteria ### Phase A: Edit Session Enforcement - [ ] Downward context transitions on Silo-tracked docs trigger `acquire_edit_session` - [ ] Hard interference (409) blocks entry, shows holder info dialog - [ ] Hard interference dialog offers "Request Handoff" action - [ ] Soft interference allows entry, shows warning in Activity pane - [ ] No-interference allows entry silently - [ ] Session ID stored for later release - [ ] `edit_locking_enabled=false` skips acquisition entirely - [ ] Offline/disconnected allows entry with warning (optimistic fallback) - [ ] SSE `edit.session_acquired`/`released` events update presence map - [ ] Handoff request received → notification with [Release] [Defer] [Dismiss] - [ ] "Release Now" on handoff pushes checkpoint if dirty, exits context ### Phase B: Checkpoint Push on Exit - [ ] Upward transitions on Silo-tracked docs trigger `push_checkpoint` - [ ] `session_id` included in checkpoint → atomic release - [ ] Unchanged files skip checkpoint, release session directly - [ ] Document saved before checkpoint upload - [ ] DAG extracted and included - [ ] `push_on_context_close=false` disables push but session still released - [ ] Checkpoint failure still releases session (via direct release or heartbeat timeout) - [ ] `sketch_close` releases sketch session, keeps body session - [ ] `body_close` releases body session ### Phase C: Subscriptions and Background Sync - [ ] Subscribe dialog with presence indicators in item list - [ ] Subscription manager with pause/policy/unsubscribe - [ ] Sync status shows pending/checkpoint/deferred counts - [ ] `subscription.checkpoint` events trigger download (skip self) - [ ] `subscription.revision` events trigger download - [ ] Open files deferred, synced on close - [ ] Reconnect runs full `sync/diff` + rebuilds presence map - [ ] Manual Sync Now works ### Phase D: Activity Pane and Presence - [ ] Outbound checkpoint events rendered - [ ] Inbound revisions and checkpoints rendered with context - [ ] Hard interference blocks rendered with handoff action - [ ] Soft interference warnings rendered with shared node list - [ ] Handoff requests rendered with action buttons - [ ] Bulk syncs grouped
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/silo-mod#27