9e8fc81469
- Add UserContextManager for per-user project tracking - Add _handle_new_project method that pre-creates sessions - Update user context after new-project creation - Route new-project command in handle_message - Add LONG_COMMANDS class attribute for timeout control Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
8.3 KiB
Python
250 lines
8.3 KiB
Python
"""
|
|
Per-user context management for gsd-openclaw.
|
|
|
|
Provides UserContextManager for tracking user project context across chat sessions,
|
|
supporting the /gsd:use command requirement.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
@dataclass
|
|
class UserContext:
|
|
"""Represents the persistent context for a user.
|
|
|
|
Tracks the user's current project, recent projects, and preferences
|
|
for persistence across chat sessions.
|
|
|
|
Attributes:
|
|
schema_version: Version of the context schema (default "1.0")
|
|
user_id: Unique identifier for the user
|
|
current_project: The user's currently active project
|
|
recent_projects: List of recently used projects (max 5)
|
|
last_active_at: ISO format timestamp of last activity
|
|
preferences: Optional user preferences dictionary
|
|
"""
|
|
schema_version: str = "1.0"
|
|
user_id: str = ""
|
|
current_project: str = ""
|
|
recent_projects: List[str] = field(default_factory=list)
|
|
last_active_at: str = ""
|
|
preferences: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert UserContext to a dictionary.
|
|
|
|
Returns:
|
|
Dictionary representation of the user context.
|
|
"""
|
|
return dataclasses.asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> UserContext:
|
|
"""Create UserContext from a dictionary.
|
|
|
|
Unknown fields in the dictionary are ignored.
|
|
|
|
Args:
|
|
data: Dictionary containing user context data.
|
|
|
|
Returns:
|
|
UserContext instance.
|
|
"""
|
|
# Get field names from the dataclass
|
|
field_names = {f.name for f in dataclasses.fields(cls)}
|
|
# Filter to only known fields
|
|
filtered_data = {k: v for k, v in data.items() if k in field_names}
|
|
return cls(**filtered_data)
|
|
|
|
|
|
class UserContextManager:
|
|
"""Manager class for per-user context persistence.
|
|
|
|
Provides a clean interface for loading and saving user context,
|
|
with atomic writes and multi-user isolation.
|
|
|
|
Attributes:
|
|
USERS_ROOT: Default root directory for all user contexts.
|
|
user_id: Unique identifier for the user.
|
|
users_root: Root directory for user contexts (configurable for testing).
|
|
context_path: Full path to the user's context.json file.
|
|
"""
|
|
|
|
USERS_ROOT = Path.home() / "gsd-workspaces" / ".users"
|
|
|
|
def __init__(self, user_id: str, users_root: Optional[Path] = None):
|
|
"""Initialize UserContextManager for a user.
|
|
|
|
Args:
|
|
user_id: Unique identifier for the user.
|
|
users_root: Optional custom root for user contexts (useful for testing).
|
|
Defaults to ~/gsd-workspaces/.users.
|
|
"""
|
|
self.user_id = user_id
|
|
self.users_root = Path(users_root) if users_root else self.USERS_ROOT
|
|
self._context_path: Optional[Path] = None
|
|
|
|
@property
|
|
def context_path(self) -> Path:
|
|
"""Get the path to the user's context file.
|
|
|
|
Returns:
|
|
Path to context.json file.
|
|
"""
|
|
return self.users_root / self.user_id / "context.json"
|
|
|
|
def load(self) -> Optional[UserContext]:
|
|
"""Load user context from file with backup fallback.
|
|
|
|
Returns:
|
|
UserContext if loaded successfully, None if no context file exists.
|
|
"""
|
|
context_path = self.context_path
|
|
backup_path = context_path.with_suffix('.json.bak')
|
|
|
|
def _load_from_path(path: Path) -> UserContext:
|
|
"""Internal: Load context from a specific path."""
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return UserContext.from_dict(data)
|
|
|
|
# Try main file first, then backup
|
|
paths_to_try = []
|
|
if context_path.exists():
|
|
paths_to_try.append(context_path)
|
|
if backup_path.exists() and backup_path != context_path:
|
|
paths_to_try.append(backup_path)
|
|
|
|
if not paths_to_try:
|
|
return None
|
|
|
|
last_error = None
|
|
for path in paths_to_try:
|
|
try:
|
|
return _load_from_path(path)
|
|
except json.JSONDecodeError as e:
|
|
last_error = e
|
|
continue # Try next path (backup)
|
|
except (KeyError, TypeError) as e:
|
|
last_error = e
|
|
continue # Try next path (backup)
|
|
|
|
# If we get here, all paths failed - return None instead of raising
|
|
return None
|
|
|
|
def save(self, context: UserContext) -> None:
|
|
"""Save user context to file atomically with backup rotation.
|
|
|
|
This function implements crash-safe context persistence:
|
|
1. Creates parent directories if they don't exist
|
|
2. Rotates existing context file to backup (context.json -> context.json.bak)
|
|
3. Writes to a temporary file first
|
|
4. Atomically renames temp file to final path
|
|
|
|
Args:
|
|
context: The UserContext to save.
|
|
|
|
Raises:
|
|
OSError: If file operations fail (permissions, disk full, etc.)
|
|
"""
|
|
context_path = self.context_path
|
|
backup_path = context_path.with_suffix('.json.bak')
|
|
|
|
# 1. Create parent directories if missing
|
|
context_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 2. Rotate backup: if context.json exists, move it to context.json.bak
|
|
if context_path.exists():
|
|
# Remove old backup if exists
|
|
if backup_path.exists():
|
|
backup_path.unlink()
|
|
# Rename current context to backup
|
|
context_path.rename(backup_path)
|
|
|
|
# 3. Write to temp file in same directory (for atomic rename)
|
|
fd, temp_path = tempfile.mkstemp(dir=context_path.parent, suffix='.tmp')
|
|
try:
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
json.dump(context.to_dict(), f, indent=2, ensure_ascii=False)
|
|
# 4. Atomic replace
|
|
os.replace(temp_path, context_path)
|
|
except Exception:
|
|
# Clean up temp file on failure
|
|
if os.path.exists(temp_path):
|
|
os.unlink(temp_path)
|
|
raise
|
|
|
|
def get_current_project(self) -> Optional[str]:
|
|
"""Get the user's current project.
|
|
|
|
Returns:
|
|
Current project name or None if no context exists.
|
|
"""
|
|
context = self.load()
|
|
if context is None or not context.current_project:
|
|
return None
|
|
return context.current_project
|
|
|
|
def set_current_project(self, project: str) -> None:
|
|
"""Set the user's current project.
|
|
|
|
Creates default context if none exists. Updates current_project,
|
|
adds to recent_projects (max 5), and updates last_active_at.
|
|
|
|
Args:
|
|
project: Name of the project to set as current.
|
|
"""
|
|
context = self.load()
|
|
if context is None:
|
|
context = self.create_default_context()
|
|
|
|
# Update recent projects
|
|
recent = context.recent_projects.copy()
|
|
# Remove project if already in list (will be added at front)
|
|
if project in recent:
|
|
recent.remove(project)
|
|
# Add to front of list
|
|
recent.insert(0, project)
|
|
# Keep only last 5
|
|
context.recent_projects = recent[:5]
|
|
|
|
# Update current project and timestamp
|
|
context.current_project = project
|
|
context.last_active_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
self.save(context)
|
|
|
|
def get_recent_projects(self) -> List[str]:
|
|
"""Get the user's recent projects list.
|
|
|
|
Returns:
|
|
List of recent project names (may be empty).
|
|
"""
|
|
context = self.load()
|
|
if context is None:
|
|
return []
|
|
return context.recent_projects
|
|
|
|
def create_default_context(self) -> UserContext:
|
|
"""Create a new default context for the user.
|
|
|
|
Returns:
|
|
New UserContext with default values.
|
|
"""
|
|
return UserContext(
|
|
schema_version="1.0",
|
|
user_id=self.user_id,
|
|
current_project="",
|
|
recent_projects=[],
|
|
last_active_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
preferences={}
|
|
)
|