feat(04-02): implement _handle_new_project with UserContextManager
- 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>
This commit is contained in:
@@ -16,7 +16,8 @@ from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager
|
||||
from gsd_openclaw.command_executor import CommandExecutor, CommandResult
|
||||
from gsd_openclaw.approval_handler import ApprovalHandler
|
||||
from gsd_openclaw.progress_monitor import ProgressMonitor
|
||||
from gsd_openclaw.response_formatter import FeishuResponseFormatter
|
||||
from gsd_openclaw.response_formatter import FeishuResponseFormatter, CardButton
|
||||
from gsd_openclaw.user_context import UserContextManager
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
@@ -40,6 +41,9 @@ class Orchestrator:
|
||||
# Maximum characters for card content (Feishu limit)
|
||||
MAX_OUTPUT_LENGTH = 2000
|
||||
|
||||
# Commands that require long-running sessions (no timeout)
|
||||
LONG_COMMANDS = {'execute-phase', 'plan-phase', 'discuss-phase', 'new-project'}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmux_manager: Optional[TmuxSessionManager] = None,
|
||||
@@ -58,6 +62,79 @@ class Orchestrator:
|
||||
self.executor = CommandExecutor()
|
||||
self.approval_handler = ApprovalHandler(self.formatter)
|
||||
self.progress_monitor = ProgressMonitor(self.formatter)
|
||||
self.user_context_managers: Dict[str, UserContextManager] = {}
|
||||
|
||||
def _get_user_context_manager(self, user_id: str) -> UserContextManager:
|
||||
"""Get or create UserContextManager for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user identifier.
|
||||
|
||||
Returns:
|
||||
UserContextManager instance for the user.
|
||||
"""
|
||||
if user_id not in self.user_context_managers:
|
||||
self.user_context_managers[user_id] = UserContextManager(user_id)
|
||||
return self.user_context_managers[user_id]
|
||||
|
||||
def _handle_new_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
|
||||
"""Handle /gsd:new-project command.
|
||||
|
||||
Pre-creates a tmux session before executing the command,
|
||||
then updates user context to track the new project.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user sending the command.
|
||||
project_name: Name of the project to create.
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict as response.
|
||||
"""
|
||||
# Validate project name
|
||||
if not project_name or not project_name.strip():
|
||||
return self.formatter.format_error(
|
||||
title="Project name required",
|
||||
content="Please provide a project name: /gsd:new-project <name>"
|
||||
)
|
||||
|
||||
project_name = project_name.strip()
|
||||
|
||||
try:
|
||||
# Create session first
|
||||
session = self.tmux_manager.create_session(project_name)
|
||||
|
||||
# Build and execute the new-project command
|
||||
full_command = f"/gsd:new-project {project_name}"
|
||||
|
||||
# Execute with no timeout for long-running commands
|
||||
result = self.executor.execute(session, full_command, timeout=None)
|
||||
|
||||
# Update user context
|
||||
user_ctx = self._get_user_context_manager(user_id)
|
||||
user_ctx.set_current_project(project_name)
|
||||
|
||||
# Truncate output if needed
|
||||
output = result.output
|
||||
if len(output) > self.MAX_OUTPUT_LENGTH:
|
||||
output = output[-self.MAX_OUTPUT_LENGTH:]
|
||||
|
||||
# Return appropriate response
|
||||
if result.has_error:
|
||||
return self.formatter.format_error(
|
||||
title="Project creation failed",
|
||||
content=output
|
||||
)
|
||||
else:
|
||||
return self.formatter.format_success(
|
||||
title="Project created",
|
||||
content=f"Project '{project_name}' created successfully.\n\n{output}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="Project creation failed",
|
||||
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
|
||||
)
|
||||
|
||||
def parse_command(self, message: str) -> Optional[Tuple[str, str]]:
|
||||
"""Parse a message for /gsd:* command pattern.
|
||||
@@ -112,6 +189,10 @@ class Orchestrator:
|
||||
|
||||
command, args = parsed
|
||||
|
||||
# Route special commands
|
||||
if command == 'new-project':
|
||||
return self._handle_new_project(user_id, args)
|
||||
|
||||
# Get project name from context
|
||||
project_name = context.get("project", "default")
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
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={}
|
||||
)
|
||||
Reference in New Issue
Block a user