[EPIC] feat: Context-Aware Part Subscription System - Silo-client #2

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

Context-Aware Part Subscription System — Client Library

Repository: silo-client
Type: Feature
Depends on: silo server endpoints
Blocks: silo-mod workbench UI and sync


Summary

Add workstation identity, subscription CRUD, checkpoint upload, edit session locking, sync diffing, and file download support to silo-client. This is the transport and data layer with no FreeCAD or Qt dependencies.

The key additions beyond basic subscription support are the checkpoint client and the edit session client. Together they implement the full context-transition lifecycle: acquire lock on context entry → push checkpoint + release lock on context exit → subscribers receive update.


1. Workstation Identity

silo_client/workstation.py

import hashlib
import socket
import uuid

def _get_primary_mac() -> str:
    mac = uuid.getnode()
    return f"{mac:012x}"

def generate_fingerprint(instance_url: str) -> str:
    """sha256(hostname + mac + instance_url). Stable per machine per instance."""
    raw = f"{socket.gethostname()}:{_get_primary_mac()}:{instance_url}"
    return hashlib.sha256(raw.encode()).hexdigest()

def get_or_register_workstation(client: "SiloClient") -> str:
    """Returns workstation_id. Registers on first call, caches in settings."""
    cached = client.settings.get("workstation_id")
    if cached:
        return cached
    fingerprint = generate_fingerprint(client.settings.api_url)
    ws = client.register_workstation(
        name=socket.gethostname(),
        fingerprint=fingerprint,
    )
    client.settings.set("workstation_id", ws["id"])
    return ws["id"]

2. Data Models

silo_client/models.py

from dataclasses import dataclass
from datetime import datetime
from typing import Optional

@dataclass
class Workstation:
    id: str
    name: str
    fingerprint: str
    last_seen: Optional[datetime]
    created_at: datetime

@dataclass
class Subscription:
    id: str
    workstation_id: str
    type: str               # "item", "project", "category"
    target: str
    revision_policy: str    # "latest_released", "latest", "pinned"
    pinned_revision: Optional[int]
    paused: bool
    created_at: datetime
    last_sync_at: Optional[datetime]

@dataclass
class SyncDiffEntry:
    item_id: str
    part_number: str
    category: str
    description: str
    target_revision: Optional[int]
    current_revision: Optional[int]
    download_url: Optional[str]
    subscription_id: Optional[str]
    action: str                         # "download", "update", "remove"
    is_checkpoint: bool = False
    checkpoint_id: Optional[str] = None
    checkpoint_context: Optional[str] = None
    checkpoint_user: Optional[str] = None

@dataclass
class SyncDiff:
    downloads: list[SyncDiffEntry]
    updates: list[SyncDiffEntry]
    removals: list[SyncDiffEntry]
    next_cursor: Optional[str]

@dataclass
class Checkpoint:
    id: str
    item_id: str
    user_id: str
    context: str
    file_hash: str
    file_size: Optional[int]
    dag_synced: bool
    created_at: datetime
    expires_at: datetime
    download_url: Optional[str] = None

@dataclass
class CheckpointResult:
    checkpoint_id: str
    context: str
    subscribers_notified: int
    dag_synced: bool
    session_released: bool

# --- Edit Session Models ---

@dataclass
class EditSessionConflict:
    session_id: str
    user: str
    context_level: str
    object_id: str
    shared_nodes: list[str]
    message: str

@dataclass
class EditSessionResult:
    session_id: str
    interference: str               # "none", "soft"
    conflicts: list[EditSessionConflict]

@dataclass
class EditSessionHolder:
    user: str
    workstation: str
    context_level: str
    object_id: str
    acquired_at: datetime

@dataclass
class HardInterferenceError(Exception):
    """Raised when edit session acquisition is blocked by hard interference."""
    holder: EditSessionHolder
    message: str

@dataclass
class EditSession:
    session_id: str
    user: str
    workstation: str
    context_level: str
    object_id: str
    acquired_at: datetime

3. SiloClient API Methods

Workstations

def list_workstations(self) -> list[Workstation]
def register_workstation(self, name: str, fingerprint: str) -> Workstation
def delete_workstation(self, workstation_id: str) -> None

Subscriptions

def list_subscriptions(self, workstation_id: str) -> list[Subscription]
def create_subscription(self, workstation_id: str, type: str, target: str,
                         revision_policy: str = "latest_released",
                         pinned_revision: Optional[int] = None) -> Subscription
def update_subscription(self, subscription_id: str, **kwargs) -> Subscription
def delete_subscription(self, subscription_id: str) -> None
def resolve_subscription(self, subscription_id: str) -> list[dict]
def resolve_all_subscriptions(self, workstation_id: str) -> list[dict]

Sync

def report_sync_state(self, workstation_id: str, items: list[dict]) -> None
def get_sync_diff(self, workstation_id: str) -> SyncDiff
def ack_sync(self, workstation_id: str, item_id: str, revision: int,
             file_hash: str, is_checkpoint: bool = False) -> None

Edit Sessions

def acquire_edit_session(
    self,
    part_number: str,
    workstation_id: str,
    context_level: str,
    object_id: str,
    dependency_cone: Optional[list[str]] = None,
) -> EditSessionResult:
    """
    POST /api/items/{pn}/edit-session
    
    Attempts to acquire an edit session lock.
    
    Returns EditSessionResult on success (interference: "none" or "soft").
    Raises HardInterferenceError on 409 (same object already locked).
    
    The caller (workbench) uses the result to decide whether to:
    - Allow entry with no warning (none)
    - Allow entry with a visual warning (soft)  
    - Block entry and show holder info (hard → exception)
    """

def release_edit_session(
    self,
    part_number: str,
    session_id: str,
) -> None:
    """
    DELETE /api/items/{pn}/edit-session/{sessionId}
    
    Explicit release. Normally called via push_checkpoint(session_id=...),
    but available for cases where no checkpoint is needed (e.g., user
    cancelled editing without making changes).
    """

def get_edit_sessions(
    self,
    part_number: str,
) -> list[EditSession]:
    """
    GET /api/items/{pn}/edit-sessions
    
    Returns all active edit sessions for an item. Used for presence
    indicators before entering edit mode.
    """

def request_handoff(
    self,
    part_number: str,
    session_id: str,
    message: Optional[str] = None,
) -> None:
    """
    POST /api/items/{pn}/edit-session/{sessionId}/request-handoff
    
    Sends notification to the session holder requesting they release.
    """

Checkpoints

def push_checkpoint(
    self,
    part_number: str,
    file_path: Path,
    context: str,
    session_id: Optional[str] = None,
    dag: Optional[dict] = None,
) -> CheckpointResult:
    """
    Upload checkpoint + optionally release edit session in one flow.
    
    1. Get presigned upload URL
    2. Upload file, compute SHA-256 during stream
    3. POST /api/items/{pn}/checkpoint with hash, context, session_id, dag
    
    When session_id is provided, the server atomically:
    - Stores the checkpoint
    - Syncs the DAG (if provided)
    - Releases the edit session
    - Notifies subscribers (if context warrants it)
    
    One client call → one server round-trip → four operations.
    """

def get_latest_checkpoint(self, part_number: str) -> Optional[Checkpoint]
def list_checkpoints(self, part_number: str) -> list[Checkpoint]
def delete_checkpoint(self, part_number: str, checkpoint_id: str) -> None

File Download

def download_file(self, download_url: str, dest_path: Path) -> str:
    """Stream download, return SHA-256. Creates parent dirs."""

4. Local Path Helper

def subscription_file_path(
    projects_dir: Path, category: str, part_number: str,
    description: str, extension: str = ".FCStd",
) -> Path:
    """Canonical path: {projects_dir}/{category}/{pn}_{desc}{ext}"""

5. SSE Event Constants

# silo_client/events.py

# Edit session events
SSE_EDIT_SESSION_ACQUIRED = "edit.session_acquired"
SSE_EDIT_SESSION_RELEASED = "edit.session_released"
SSE_EDIT_INTERFERENCE_RESOLVED = "edit.interference_resolved"
SSE_EDIT_HANDOFF_REQUESTED = "edit.handoff_requested"
SSE_EDIT_FORCE_RELEASED = "edit.force_released"

# Subscription events
SSE_SUBSCRIPTION_REVISION = "subscription.revision"
SSE_SUBSCRIPTION_CHECKPOINT = "subscription.checkpoint"
SSE_SUBSCRIPTION_UPDATE = "subscription.update"
SSE_SUBSCRIPTION_SYNC_REQUIRED = "subscription.sync_required"

# Context constants
CONTEXT_SKETCH_CLOSE = "sketch_close"
CONTEXT_BODY_CLOSE = "body_close"
CONTEXT_ASSEMBLY_CLOSE = "assembly_close"
CONTEXT_MANUAL = "manual"
CONTEXT_AUTOSAVE = "autosave"

NOTIFY_CONTEXTS = {CONTEXT_BODY_CLOSE, CONTEXT_ASSEMBLY_CLOSE, CONTEXT_MANUAL}

# Context levels (for edit sessions)
LEVEL_SKETCH = "sketch"
LEVEL_PARTDESIGN = "partdesign"
LEVEL_ASSEMBLY = "assembly"

6. Acceptance Criteria

Workstation Identity

  • Fingerprint stable across restarts, unique per machine+instance
  • get_or_register_workstation() caches after first call
  • Idempotent registration

Edit Sessions

  • acquire_edit_session() returns EditSessionResult on success
  • acquire_edit_session() raises HardInterferenceError on 409
  • Soft interference result includes conflict details and shared nodes
  • release_edit_session() explicit release works
  • get_edit_sessions() returns typed list of active sessions
  • request_handoff() sends request
  • All edit session SSE event constants exported

Checkpoints

  • push_checkpoint() handles upload + metadata + session release in one call
  • session_id parameter triggers atomic session release
  • SHA-256 computed during streaming upload
  • DAG included when provided
  • All context values accepted

Subscriptions & Sync

  • All CRUD methods work with typed models
  • get_sync_diff() returns checkpoint metadata in entries
  • ack_sync() accepts is_checkpoint flag

General

  • No FreeCAD/Qt dependencies
  • All methods have type hints
  • HardInterferenceError is a proper exception class
# Context-Aware Part Subscription System — Client Library **Repository:** `silo-client` **Type:** Feature **Depends on:** `silo` server endpoints **Blocks:** `silo-mod` workbench UI and sync --- ## Summary Add workstation identity, subscription CRUD, checkpoint upload, **edit session locking**, sync diffing, and file download support to `silo-client`. This is the transport and data layer with no FreeCAD or Qt dependencies. The key additions beyond basic subscription support are the **checkpoint client** and the **edit session client**. Together they implement the full context-transition lifecycle: acquire lock on context entry → push checkpoint + release lock on context exit → subscribers receive update. --- ## 1. Workstation Identity ### `silo_client/workstation.py` ```python import hashlib import socket import uuid def _get_primary_mac() -> str: mac = uuid.getnode() return f"{mac:012x}" def generate_fingerprint(instance_url: str) -> str: """sha256(hostname + mac + instance_url). Stable per machine per instance.""" raw = f"{socket.gethostname()}:{_get_primary_mac()}:{instance_url}" return hashlib.sha256(raw.encode()).hexdigest() def get_or_register_workstation(client: "SiloClient") -> str: """Returns workstation_id. Registers on first call, caches in settings.""" cached = client.settings.get("workstation_id") if cached: return cached fingerprint = generate_fingerprint(client.settings.api_url) ws = client.register_workstation( name=socket.gethostname(), fingerprint=fingerprint, ) client.settings.set("workstation_id", ws["id"]) return ws["id"] ``` --- ## 2. Data Models ### `silo_client/models.py` ```python from dataclasses import dataclass from datetime import datetime from typing import Optional @dataclass class Workstation: id: str name: str fingerprint: str last_seen: Optional[datetime] created_at: datetime @dataclass class Subscription: id: str workstation_id: str type: str # "item", "project", "category" target: str revision_policy: str # "latest_released", "latest", "pinned" pinned_revision: Optional[int] paused: bool created_at: datetime last_sync_at: Optional[datetime] @dataclass class SyncDiffEntry: item_id: str part_number: str category: str description: str target_revision: Optional[int] current_revision: Optional[int] download_url: Optional[str] subscription_id: Optional[str] action: str # "download", "update", "remove" is_checkpoint: bool = False checkpoint_id: Optional[str] = None checkpoint_context: Optional[str] = None checkpoint_user: Optional[str] = None @dataclass class SyncDiff: downloads: list[SyncDiffEntry] updates: list[SyncDiffEntry] removals: list[SyncDiffEntry] next_cursor: Optional[str] @dataclass class Checkpoint: id: str item_id: str user_id: str context: str file_hash: str file_size: Optional[int] dag_synced: bool created_at: datetime expires_at: datetime download_url: Optional[str] = None @dataclass class CheckpointResult: checkpoint_id: str context: str subscribers_notified: int dag_synced: bool session_released: bool # --- Edit Session Models --- @dataclass class EditSessionConflict: session_id: str user: str context_level: str object_id: str shared_nodes: list[str] message: str @dataclass class EditSessionResult: session_id: str interference: str # "none", "soft" conflicts: list[EditSessionConflict] @dataclass class EditSessionHolder: user: str workstation: str context_level: str object_id: str acquired_at: datetime @dataclass class HardInterferenceError(Exception): """Raised when edit session acquisition is blocked by hard interference.""" holder: EditSessionHolder message: str @dataclass class EditSession: session_id: str user: str workstation: str context_level: str object_id: str acquired_at: datetime ``` --- ## 3. SiloClient API Methods ### Workstations ```python def list_workstations(self) -> list[Workstation] def register_workstation(self, name: str, fingerprint: str) -> Workstation def delete_workstation(self, workstation_id: str) -> None ``` ### Subscriptions ```python def list_subscriptions(self, workstation_id: str) -> list[Subscription] def create_subscription(self, workstation_id: str, type: str, target: str, revision_policy: str = "latest_released", pinned_revision: Optional[int] = None) -> Subscription def update_subscription(self, subscription_id: str, **kwargs) -> Subscription def delete_subscription(self, subscription_id: str) -> None def resolve_subscription(self, subscription_id: str) -> list[dict] def resolve_all_subscriptions(self, workstation_id: str) -> list[dict] ``` ### Sync ```python def report_sync_state(self, workstation_id: str, items: list[dict]) -> None def get_sync_diff(self, workstation_id: str) -> SyncDiff def ack_sync(self, workstation_id: str, item_id: str, revision: int, file_hash: str, is_checkpoint: bool = False) -> None ``` ### Edit Sessions ```python def acquire_edit_session( self, part_number: str, workstation_id: str, context_level: str, object_id: str, dependency_cone: Optional[list[str]] = None, ) -> EditSessionResult: """ POST /api/items/{pn}/edit-session Attempts to acquire an edit session lock. Returns EditSessionResult on success (interference: "none" or "soft"). Raises HardInterferenceError on 409 (same object already locked). The caller (workbench) uses the result to decide whether to: - Allow entry with no warning (none) - Allow entry with a visual warning (soft) - Block entry and show holder info (hard → exception) """ def release_edit_session( self, part_number: str, session_id: str, ) -> None: """ DELETE /api/items/{pn}/edit-session/{sessionId} Explicit release. Normally called via push_checkpoint(session_id=...), but available for cases where no checkpoint is needed (e.g., user cancelled editing without making changes). """ def get_edit_sessions( self, part_number: str, ) -> list[EditSession]: """ GET /api/items/{pn}/edit-sessions Returns all active edit sessions for an item. Used for presence indicators before entering edit mode. """ def request_handoff( self, part_number: str, session_id: str, message: Optional[str] = None, ) -> None: """ POST /api/items/{pn}/edit-session/{sessionId}/request-handoff Sends notification to the session holder requesting they release. """ ``` ### Checkpoints ```python def push_checkpoint( self, part_number: str, file_path: Path, context: str, session_id: Optional[str] = None, dag: Optional[dict] = None, ) -> CheckpointResult: """ Upload checkpoint + optionally release edit session in one flow. 1. Get presigned upload URL 2. Upload file, compute SHA-256 during stream 3. POST /api/items/{pn}/checkpoint with hash, context, session_id, dag When session_id is provided, the server atomically: - Stores the checkpoint - Syncs the DAG (if provided) - Releases the edit session - Notifies subscribers (if context warrants it) One client call → one server round-trip → four operations. """ def get_latest_checkpoint(self, part_number: str) -> Optional[Checkpoint] def list_checkpoints(self, part_number: str) -> list[Checkpoint] def delete_checkpoint(self, part_number: str, checkpoint_id: str) -> None ``` ### File Download ```python def download_file(self, download_url: str, dest_path: Path) -> str: """Stream download, return SHA-256. Creates parent dirs.""" ``` --- ## 4. Local Path Helper ```python def subscription_file_path( projects_dir: Path, category: str, part_number: str, description: str, extension: str = ".FCStd", ) -> Path: """Canonical path: {projects_dir}/{category}/{pn}_{desc}{ext}""" ``` --- ## 5. SSE Event Constants ```python # silo_client/events.py # Edit session events SSE_EDIT_SESSION_ACQUIRED = "edit.session_acquired" SSE_EDIT_SESSION_RELEASED = "edit.session_released" SSE_EDIT_INTERFERENCE_RESOLVED = "edit.interference_resolved" SSE_EDIT_HANDOFF_REQUESTED = "edit.handoff_requested" SSE_EDIT_FORCE_RELEASED = "edit.force_released" # Subscription events SSE_SUBSCRIPTION_REVISION = "subscription.revision" SSE_SUBSCRIPTION_CHECKPOINT = "subscription.checkpoint" SSE_SUBSCRIPTION_UPDATE = "subscription.update" SSE_SUBSCRIPTION_SYNC_REQUIRED = "subscription.sync_required" # Context constants CONTEXT_SKETCH_CLOSE = "sketch_close" CONTEXT_BODY_CLOSE = "body_close" CONTEXT_ASSEMBLY_CLOSE = "assembly_close" CONTEXT_MANUAL = "manual" CONTEXT_AUTOSAVE = "autosave" NOTIFY_CONTEXTS = {CONTEXT_BODY_CLOSE, CONTEXT_ASSEMBLY_CLOSE, CONTEXT_MANUAL} # Context levels (for edit sessions) LEVEL_SKETCH = "sketch" LEVEL_PARTDESIGN = "partdesign" LEVEL_ASSEMBLY = "assembly" ``` --- ## 6. Acceptance Criteria ### Workstation Identity - [ ] Fingerprint stable across restarts, unique per machine+instance - [ ] `get_or_register_workstation()` caches after first call - [ ] Idempotent registration ### Edit Sessions - [ ] `acquire_edit_session()` returns `EditSessionResult` on success - [ ] `acquire_edit_session()` raises `HardInterferenceError` on 409 - [ ] Soft interference result includes conflict details and shared nodes - [ ] `release_edit_session()` explicit release works - [ ] `get_edit_sessions()` returns typed list of active sessions - [ ] `request_handoff()` sends request - [ ] All edit session SSE event constants exported ### Checkpoints - [ ] `push_checkpoint()` handles upload + metadata + session release in one call - [ ] `session_id` parameter triggers atomic session release - [ ] SHA-256 computed during streaming upload - [ ] DAG included when provided - [ ] All context values accepted ### Subscriptions & Sync - [ ] All CRUD methods work with typed models - [ ] `get_sync_diff()` returns checkpoint metadata in entries - [ ] `ack_sync()` accepts `is_checkpoint` flag ### General - [ ] No FreeCAD/Qt dependencies - [ ] All methods have type hints - [ ] `HardInterferenceError` is a proper exception class
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/silo-client#2