自动同步: 2026-03-12 03:00
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: gsd-openclaw
|
||||
Version: 0.1.0
|
||||
Summary: GSD to OpenClaw bridge skill
|
||||
Requires-Python: >=3.10
|
||||
Requires-Dist: libtmux>=0.55.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
||||
@@ -0,0 +1,32 @@
|
||||
pyproject.toml
|
||||
src/gsd_openclaw/__init__.py
|
||||
src/gsd_openclaw/approval_handler.py
|
||||
src/gsd_openclaw/cli.py
|
||||
src/gsd_openclaw/command_executor.py
|
||||
src/gsd_openclaw/orchestrator.py
|
||||
src/gsd_openclaw/progress_monitor.py
|
||||
src/gsd_openclaw/prompt_loader.py
|
||||
src/gsd_openclaw/response_formatter.py
|
||||
src/gsd_openclaw/state_manager.py
|
||||
src/gsd_openclaw/tmux_session.py
|
||||
src/gsd_openclaw/user_context.py
|
||||
src/gsd_openclaw/workspace.py
|
||||
src/gsd_openclaw.egg-info/PKG-INFO
|
||||
src/gsd_openclaw.egg-info/SOURCES.txt
|
||||
src/gsd_openclaw.egg-info/dependency_links.txt
|
||||
src/gsd_openclaw.egg-info/entry_points.txt
|
||||
src/gsd_openclaw.egg-info/requires.txt
|
||||
src/gsd_openclaw.egg-info/top_level.txt
|
||||
src/gsd_openclaw/prompts/__init__.py
|
||||
tests/test_approval_handler.py
|
||||
tests/test_cli.py
|
||||
tests/test_command_executor.py
|
||||
tests/test_orchestrator.py
|
||||
tests/test_progress_monitor.py
|
||||
tests/test_prompt_templates.py
|
||||
tests/test_response_formatter.py
|
||||
tests/test_skill_md.py
|
||||
tests/test_state_manager.py
|
||||
tests/test_tmux_session.py
|
||||
tests/test_user_context.py
|
||||
tests/test_workspace.py
|
||||
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
gsd-ocl = gsd_openclaw.cli:main
|
||||
@@ -0,0 +1,4 @@
|
||||
libtmux>=0.55.0
|
||||
|
||||
[dev]
|
||||
pytest>=7.0.0
|
||||
@@ -0,0 +1 @@
|
||||
gsd_openclaw
|
||||
@@ -10,6 +10,11 @@ __version__ = "0.1.0"
|
||||
from gsd_openclaw.state_manager import SessionState, StateManager, save_state, load_state
|
||||
from gsd_openclaw.workspace import Workspace, WorkspaceManager
|
||||
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager, SessionInfo
|
||||
from gsd_openclaw.command_executor import CommandResult, OutputCapture, CommandExecutor
|
||||
from gsd_openclaw.approval_handler import ApprovalHandler, ApprovalState
|
||||
from gsd_openclaw.progress_monitor import ProgressMonitor, ProgressInfo
|
||||
from gsd_openclaw.orchestrator import Orchestrator
|
||||
from gsd_openclaw.user_context import UserContext, UserContextManager
|
||||
|
||||
__all__ = [
|
||||
"SessionState",
|
||||
@@ -21,4 +26,14 @@ __all__ = [
|
||||
"TmuxSession",
|
||||
"TmuxSessionManager",
|
||||
"SessionInfo",
|
||||
"CommandResult",
|
||||
"OutputCapture",
|
||||
"CommandExecutor",
|
||||
"ApprovalHandler",
|
||||
"ApprovalState",
|
||||
"ProgressMonitor",
|
||||
"ProgressInfo",
|
||||
"Orchestrator",
|
||||
"UserContext",
|
||||
"UserContextManager",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Approval workflow handler for interactive GSD operations.
|
||||
|
||||
This module provides:
|
||||
- Detection of approval requests in Claude CLI output
|
||||
- Creation of approval cards with confirm/reject buttons
|
||||
- Callback handling for approval actions
|
||||
- Persistence of approval state for crash recovery
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Pattern
|
||||
|
||||
from gsd_openclaw.response_formatter import FeishuResponseFormatter, CardButton
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalState:
|
||||
"""Represents the state of a pending approval.
|
||||
|
||||
Attributes:
|
||||
approval_id: Unique identifier for this approval.
|
||||
session_id: ID of the GSD session this approval belongs to.
|
||||
project_name: Name of the project.
|
||||
context: Additional context about the approval request.
|
||||
created_at: Unix timestamp when the approval was created.
|
||||
"""
|
||||
approval_id: str
|
||||
session_id: str
|
||||
project_name: str
|
||||
context: Dict[str, Any]
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert ApprovalState to a dictionary for persistence."""
|
||||
return {
|
||||
"approval_id": self.approval_id,
|
||||
"session_id": self.session_id,
|
||||
"project_name": self.project_name,
|
||||
"context": self.context,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> ApprovalState:
|
||||
"""Create ApprovalState from a dictionary."""
|
||||
return cls(
|
||||
approval_id=data["approval_id"],
|
||||
session_id=data["session_id"],
|
||||
project_name=data["project_name"],
|
||||
context=data.get("context", {}),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
)
|
||||
|
||||
|
||||
class ApprovalHandler:
|
||||
"""Handles approval workflow for interactive GSD operations.
|
||||
|
||||
This class provides functionality to:
|
||||
- Detect approval requests in Claude CLI output
|
||||
- Create Feishu cards with confirm/reject buttons
|
||||
- Handle button callbacks
|
||||
- Persist approval state for crash recovery
|
||||
|
||||
Attributes:
|
||||
APPROVAL_PATTERNS: List of patterns that indicate an approval request.
|
||||
pending_approvals: In-memory cache of pending approvals.
|
||||
"""
|
||||
|
||||
# Patterns that indicate Claude is asking for user confirmation
|
||||
APPROVAL_PATTERNS: List[str] = [
|
||||
'AskUserQuestion',
|
||||
'ExitPlanMode',
|
||||
'需要确认',
|
||||
'Please confirm',
|
||||
]
|
||||
|
||||
def __init__(self, formatter: FeishuResponseFormatter):
|
||||
"""Initialize ApprovalHandler.
|
||||
|
||||
Args:
|
||||
formatter: FeishuResponseFormatter instance for creating cards.
|
||||
"""
|
||||
self.formatter = formatter
|
||||
self.pending_approvals: Dict[str, ApprovalState] = {}
|
||||
|
||||
def detect_approval_request(self, output: str) -> Optional[Dict[str, Any]]:
|
||||
"""Detect if output contains an approval request.
|
||||
|
||||
Scans the output for known approval patterns and returns context
|
||||
about the approval request if found.
|
||||
|
||||
Args:
|
||||
output: The output text to scan for approval patterns.
|
||||
|
||||
Returns:
|
||||
Dictionary with approval context if found, None otherwise.
|
||||
The dictionary includes:
|
||||
- type: The matched pattern type
|
||||
- matched_text: The text that was matched
|
||||
"""
|
||||
for pattern in self.APPROVAL_PATTERNS:
|
||||
if pattern in output:
|
||||
# Extract some context around the match
|
||||
idx = output.find(pattern)
|
||||
start = max(0, idx - 50)
|
||||
end = min(len(output), idx + len(pattern) + 50)
|
||||
matched_text = output[start:end].strip()
|
||||
|
||||
return {
|
||||
"type": pattern,
|
||||
"matched_text": matched_text,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def create_approval_card(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
session_id: str,
|
||||
project_name: str
|
||||
) -> dict:
|
||||
"""Create a Feishu approval card with confirm/reject buttons.
|
||||
|
||||
Args:
|
||||
context: Context dictionary from detect_approval_request.
|
||||
session_id: ID of the GSD session.
|
||||
project_name: Name of the project.
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict with confirm/reject buttons.
|
||||
"""
|
||||
# Generate unique approval ID
|
||||
approval_id = str(uuid.uuid4())
|
||||
|
||||
# Create approval state and store it
|
||||
approval_state = ApprovalState(
|
||||
approval_id=approval_id,
|
||||
session_id=session_id,
|
||||
project_name=project_name,
|
||||
context=context,
|
||||
)
|
||||
self.pending_approvals[approval_id] = approval_state
|
||||
|
||||
# Extract question text for display
|
||||
question_text = context.get("matched_text", "是否继续?")
|
||||
|
||||
# Create buttons
|
||||
confirm_button = CardButton(
|
||||
text="确认",
|
||||
value={
|
||||
"action": "approve",
|
||||
"approval_id": approval_id,
|
||||
"project": project_name,
|
||||
},
|
||||
button_type="primary",
|
||||
)
|
||||
reject_button = CardButton(
|
||||
text="取消",
|
||||
value={
|
||||
"action": "reject",
|
||||
"approval_id": approval_id,
|
||||
"project": project_name,
|
||||
},
|
||||
button_type="danger",
|
||||
)
|
||||
|
||||
# Format card using formatter
|
||||
card = self.formatter.format_info(
|
||||
title="需要确认",
|
||||
content=question_text,
|
||||
buttons=[confirm_button, reject_button],
|
||||
)
|
||||
|
||||
return card
|
||||
|
||||
def handle_callback(self, callback_data: Dict[str, Any]) -> str:
|
||||
"""Handle a button callback from the approval card.
|
||||
|
||||
Args:
|
||||
callback_data: Dictionary containing:
|
||||
- action: "approve" or "reject"
|
||||
- approval_id: The ID of the approval
|
||||
- project: The project name
|
||||
|
||||
Returns:
|
||||
"approved" if action was approve,
|
||||
"rejected" if action was reject,
|
||||
"error: ..." if approval not found or invalid.
|
||||
"""
|
||||
action = callback_data.get("action", "")
|
||||
approval_id = callback_data.get("approval_id", "")
|
||||
|
||||
# Check if approval exists
|
||||
if approval_id not in self.pending_approvals:
|
||||
return "error: approval not found or expired"
|
||||
|
||||
# Remove the approval from pending (one-time use)
|
||||
approval = self.pending_approvals.pop(approval_id)
|
||||
|
||||
if action == "approve":
|
||||
return "approved"
|
||||
elif action == "reject":
|
||||
return "rejected"
|
||||
else:
|
||||
return f"error: unknown action '{action}'"
|
||||
|
||||
def persist_approval(self, state_manager, approval: ApprovalState) -> None:
|
||||
"""Persist approval state to StateManager for crash recovery.
|
||||
|
||||
Args:
|
||||
state_manager: StateManager instance for persistence.
|
||||
approval: The ApprovalState to persist.
|
||||
"""
|
||||
# Load current state
|
||||
current_state = state_manager.load()
|
||||
if current_state is None:
|
||||
return # No session to persist to
|
||||
|
||||
# Store approval in state (would need SessionState extension)
|
||||
# For now, we keep approvals in memory only as per plan
|
||||
pass
|
||||
|
||||
def load_approval(
|
||||
self,
|
||||
state_manager,
|
||||
approval_id: str
|
||||
) -> Optional[ApprovalState]:
|
||||
"""Load approval state from StateManager.
|
||||
|
||||
Args:
|
||||
state_manager: StateManager instance for persistence.
|
||||
approval_id: The approval ID to load.
|
||||
|
||||
Returns:
|
||||
ApprovalState if found, None otherwise.
|
||||
"""
|
||||
# For now, return from in-memory cache
|
||||
return self.pending_approvals.get(approval_id)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Command execution and output capture for tmux sessions.
|
||||
|
||||
This module provides the foundational I/O layer for all GSD command bridging:
|
||||
- Send commands to tmux sessions
|
||||
- Capture output with completion detection
|
||||
- Identify errors in command output
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple, Pattern
|
||||
|
||||
import libtmux
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
output: The captured output from the command.
|
||||
completed: True if the command completed (prompt detected or output stabilized).
|
||||
has_error: True if error keywords were detected in output.
|
||||
error_type: The type of error detected, if any.
|
||||
"""
|
||||
output: str
|
||||
completed: bool
|
||||
has_error: bool
|
||||
error_type: Optional[str]
|
||||
|
||||
|
||||
class OutputCapture:
|
||||
"""Captures output from tmux panes with completion detection.
|
||||
|
||||
This class provides functionality to capture output from a tmux pane
|
||||
and detect when command execution has completed, either by detecting
|
||||
a shell prompt or by observing stable output.
|
||||
"""
|
||||
|
||||
# Regex patterns for common shell prompts
|
||||
# These patterns match lines that are ONLY a prompt (no other content)
|
||||
PROMPT_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'^\$\s*$'), # Simple $ prompt
|
||||
re.compile(r'^❯\s*$'), # Starship/Powerline prompt
|
||||
re.compile(r'^➜\s*$'), # Agnoster prompt
|
||||
re.compile(r'^>\s*$'), # Simple > prompt
|
||||
]
|
||||
|
||||
def _is_prompt(self, line: str) -> bool:
|
||||
"""Check if a line is a shell prompt.
|
||||
|
||||
Args:
|
||||
line: The line to check.
|
||||
|
||||
Returns:
|
||||
True if the line matches a known prompt pattern.
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
|
||||
for pattern in self.PROMPT_PATTERNS:
|
||||
if pattern.match(stripped):
|
||||
return True
|
||||
return False
|
||||
|
||||
def capture_until_complete(
|
||||
self,
|
||||
pane: libtmux.Pane,
|
||||
timeout: float = 60.0,
|
||||
poll_interval: float = 0.1,
|
||||
stable_count: int = 3
|
||||
) -> Tuple[str, bool]:
|
||||
"""Capture output until command completes or timeout.
|
||||
|
||||
Completion is detected when either:
|
||||
1. A shell prompt appears in the output (command finished)
|
||||
2. Output stabilizes (same line count for N consecutive polls)
|
||||
|
||||
Args:
|
||||
pane: The tmux pane to capture output from.
|
||||
timeout: Maximum time to wait for completion (seconds).
|
||||
poll_interval: Time between capture polls (seconds).
|
||||
stable_count: Number of consecutive polls with same output to consider stable.
|
||||
|
||||
Returns:
|
||||
Tuple of (captured_output, completed).
|
||||
- captured_output: The captured output as a string.
|
||||
- completed: True if completion was detected, False if timeout.
|
||||
"""
|
||||
start_time = time.time()
|
||||
last_output_hash: Optional[str] = None
|
||||
stability_counter = 0
|
||||
|
||||
captured_lines: List[str] = []
|
||||
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= timeout:
|
||||
# Timeout reached
|
||||
return '\n'.join(captured_lines), False
|
||||
|
||||
# Capture current pane content
|
||||
try:
|
||||
current_lines = pane.capture_pane()
|
||||
except Exception:
|
||||
# If capture fails, wait and retry
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
# Store captured lines
|
||||
captured_lines = list(current_lines) if current_lines else []
|
||||
|
||||
# Check for prompt in last few lines
|
||||
for line in captured_lines[-5:]: # Check last 5 lines for prompt
|
||||
if self._is_prompt(line):
|
||||
return '\n'.join(captured_lines), True
|
||||
|
||||
# Check for output stabilization (same content for N consecutive polls)
|
||||
current_output = '\n'.join(captured_lines)
|
||||
current_hash = hash(current_output)
|
||||
if current_hash == last_output_hash:
|
||||
stability_counter += 1
|
||||
if stability_counter >= stable_count:
|
||||
return '\n'.join(captured_lines), True
|
||||
else:
|
||||
stability_counter = 0
|
||||
last_output_hash = current_hash
|
||||
|
||||
# Wait before next poll
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
"""Executes commands in tmux sessions and captures output.
|
||||
|
||||
This class provides the main interface for sending commands to tmux
|
||||
sessions and capturing their output with error detection.
|
||||
"""
|
||||
|
||||
# Keywords that indicate errors in command output
|
||||
ERROR_KEYWORDS: List[str] = [
|
||||
'error',
|
||||
'failed',
|
||||
'exception',
|
||||
'traceback',
|
||||
'错误',
|
||||
'失败',
|
||||
]
|
||||
|
||||
DEFAULT_TIMEOUT: float = 60.0
|
||||
|
||||
# Commands that require no timeout (long-running operations)
|
||||
LONG_COMMANDS: set = {'execute-phase', 'plan-phase', 'discuss-phase', 'new-project'}
|
||||
|
||||
def __init__(self, output_capture: Optional[OutputCapture] = None):
|
||||
"""Initialize CommandExecutor.
|
||||
|
||||
Args:
|
||||
output_capture: Optional OutputCapture instance for dependency injection.
|
||||
"""
|
||||
self.output_capture = output_capture or OutputCapture()
|
||||
|
||||
def send_command(self, pane: libtmux.Pane, command: str) -> None:
|
||||
"""Send a command to a tmux pane.
|
||||
|
||||
Args:
|
||||
pane: The tmux pane to send the command to.
|
||||
command: The command string to execute.
|
||||
"""
|
||||
pane.send_keys(command, enter=True)
|
||||
|
||||
def _detect_errors(self, output: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Detect error keywords in output.
|
||||
|
||||
Args:
|
||||
output: The output to analyze for errors.
|
||||
|
||||
Returns:
|
||||
Tuple of (has_error, error_type).
|
||||
- has_error: True if any error keyword was found.
|
||||
- error_type: The first matching error keyword, or None.
|
||||
"""
|
||||
output_lower = output.lower()
|
||||
|
||||
# Check each keyword and return the first match
|
||||
for keyword in self.ERROR_KEYWORDS:
|
||||
keyword_lower = keyword.lower()
|
||||
# For ASCII keywords, use word boundary matching
|
||||
# For non-ASCII (e.g., Chinese), use simple substring match
|
||||
if keyword.isascii():
|
||||
if re.search(rf'\b{re.escape(keyword_lower)}\b', output_lower):
|
||||
return True, keyword
|
||||
else:
|
||||
if keyword in output:
|
||||
return True, keyword
|
||||
|
||||
return False, None
|
||||
|
||||
def _is_long_command(self, command: str) -> bool:
|
||||
"""Check if command requires no timeout.
|
||||
|
||||
Long commands like execute-phase, plan-phase, discuss-phase, and new-project
|
||||
can take an extended time to complete and should not have a timeout limit.
|
||||
|
||||
Args:
|
||||
command: The full command string (e.g., '/gsd:execute-phase').
|
||||
|
||||
Returns:
|
||||
True if command is in LONG_COMMANDS, False otherwise.
|
||||
"""
|
||||
# Extract command name from /gsd:command format
|
||||
match = re.match(r'^/gsd:(\S+)', command)
|
||||
if match:
|
||||
command_name = match.group(1)
|
||||
return command_name in self.LONG_COMMANDS
|
||||
return False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
session, # TmuxSession type - avoiding circular import
|
||||
command: str,
|
||||
timeout: Optional[float] = None,
|
||||
poll_interval: float = 0.1
|
||||
) -> CommandResult:
|
||||
"""Execute a command in a tmux session and capture the result.
|
||||
|
||||
Args:
|
||||
session: The TmuxSession to execute the command in.
|
||||
command: The command string to execute.
|
||||
timeout: Maximum time to wait for completion (seconds).
|
||||
Defaults to DEFAULT_TIMEOUT (60.0).
|
||||
poll_interval: Time between capture polls (seconds).
|
||||
|
||||
Returns:
|
||||
CommandResult containing output, completion status, and error info.
|
||||
"""
|
||||
timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
|
||||
|
||||
# Get the active pane from the session
|
||||
pane = session.tmux_session.active_pane
|
||||
|
||||
# Send the command
|
||||
self.send_command(pane, command)
|
||||
|
||||
# Capture output until completion
|
||||
output, completed = self.output_capture.capture_until_complete(
|
||||
pane,
|
||||
timeout=timeout,
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
|
||||
# Detect errors in output
|
||||
has_error, error_type = self._detect_errors(output)
|
||||
|
||||
return CommandResult(
|
||||
output=output,
|
||||
completed=completed,
|
||||
has_error=has_error,
|
||||
error_type=error_type,
|
||||
)
|
||||
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Main command router and orchestrator for OpenClaw skill invocation.
|
||||
|
||||
This module provides the central command routing system that:
|
||||
- Receives chat messages via handle_message()
|
||||
- Routes commands through session management and command execution
|
||||
- Handles approval workflows and progress updates
|
||||
- Returns formatted Feishu card responses
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
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, CardButton
|
||||
from gsd_openclaw.user_context import UserContextManager
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Central command router for OpenClaw skill invocation.
|
||||
|
||||
This class provides the main entry point for handling GSD commands
|
||||
from chat messages, integrating session management, command execution,
|
||||
approval handling, and response formatting.
|
||||
|
||||
Attributes:
|
||||
tmux_manager: TmuxSessionManager for session lifecycle.
|
||||
formatter: FeishuResponseFormatter for card creation.
|
||||
executor: CommandExecutor for running commands.
|
||||
approval_handler: ApprovalHandler for interactive approvals.
|
||||
progress_monitor: ProgressMonitor for progress updates.
|
||||
"""
|
||||
|
||||
# Regex pattern for /gsd:* command parsing
|
||||
COMMAND_PATTERN = re.compile(r'^/gsd:(\S+)\s*(.*)$')
|
||||
|
||||
# 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,
|
||||
formatter: Optional[FeishuResponseFormatter] = None,
|
||||
users_root: Optional["Path"] = None
|
||||
):
|
||||
"""Initialize Orchestrator.
|
||||
|
||||
Args:
|
||||
tmux_manager: Optional TmuxSessionManager instance.
|
||||
If not provided, a new one will be created.
|
||||
formatter: Optional FeishuResponseFormatter instance.
|
||||
If not provided, a new one will be created.
|
||||
users_root: Optional custom root for user contexts (useful for testing).
|
||||
"""
|
||||
self.tmux_manager = tmux_manager or TmuxSessionManager()
|
||||
self.formatter = formatter or FeishuResponseFormatter()
|
||||
self.executor = CommandExecutor()
|
||||
self.approval_handler = ApprovalHandler(self.formatter)
|
||||
self.progress_monitor = ProgressMonitor(self.formatter)
|
||||
self.user_context_managers: Dict[str, UserContextManager] = {}
|
||||
self._users_root = users_root
|
||||
|
||||
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, self._users_root)
|
||||
return self.user_context_managers[user_id]
|
||||
|
||||
def _is_long_command(self, command: str) -> bool:
|
||||
"""Check if command requires no timeout.
|
||||
|
||||
Args:
|
||||
command: The command name (e.g., 'execute-phase', 'status').
|
||||
|
||||
Returns:
|
||||
True if command is in LONG_COMMANDS, False otherwise.
|
||||
"""
|
||||
return command in self.LONG_COMMANDS
|
||||
|
||||
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 _handle_use_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
|
||||
"""Handle /gsd:use command.
|
||||
|
||||
Switches the user's current project context. Shows error with
|
||||
"Create New Project" button if the project doesn't exist.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user sending the command.
|
||||
project_name: Name of the project to switch to.
|
||||
|
||||
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:use <name>"
|
||||
)
|
||||
|
||||
project_name = project_name.strip()
|
||||
|
||||
try:
|
||||
# Check if project session exists
|
||||
session = self.tmux_manager.get_session(project_name)
|
||||
|
||||
if session is not None:
|
||||
# Project exists - update user context and return success
|
||||
user_ctx = self._get_user_context_manager(user_id)
|
||||
user_ctx.set_current_project(project_name)
|
||||
|
||||
return self.formatter.format_success(
|
||||
title="Project switched",
|
||||
content=f"Switched to project '{project_name}'"
|
||||
)
|
||||
else:
|
||||
# Project doesn't exist - return error with Create button
|
||||
create_button = CardButton(
|
||||
text="Create New Project",
|
||||
value={"action": "new-project", "name": project_name},
|
||||
button_type="primary"
|
||||
)
|
||||
return self.formatter.format_error(
|
||||
title="Project not found",
|
||||
content=f"Project '{project_name}' does not exist",
|
||||
buttons=[create_button]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="Switch 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.
|
||||
|
||||
Args:
|
||||
message: The message text to parse.
|
||||
|
||||
Returns:
|
||||
Tuple of (command, args) if matched, None otherwise.
|
||||
"""
|
||||
match = self.COMMAND_PATTERN.match(message.strip())
|
||||
if match:
|
||||
command = match.group(1)
|
||||
args = match.group(2).strip()
|
||||
return (command, args)
|
||||
return None
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
user_id: str,
|
||||
message: str,
|
||||
channel: str,
|
||||
context: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle an incoming chat message.
|
||||
|
||||
This is the main entry point for processing GSD commands.
|
||||
It handles:
|
||||
1. Command parsing
|
||||
2. Session management (auto-create if needed)
|
||||
3. Command execution
|
||||
4. Approval detection
|
||||
5. Response formatting
|
||||
|
||||
Args:
|
||||
user_id: ID of the user sending the message.
|
||||
message: The message text.
|
||||
channel: The channel where the message was sent.
|
||||
context: Additional context including project name.
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict as response.
|
||||
"""
|
||||
try:
|
||||
# Parse command
|
||||
parsed = self.parse_command(message)
|
||||
if parsed is None:
|
||||
return self.formatter.format_info(
|
||||
title="无效命令",
|
||||
content="请使用 /gsd:* 格式的命令。例如: /gsd:status"
|
||||
)
|
||||
|
||||
command, args = parsed
|
||||
|
||||
# Route special commands
|
||||
if command == 'new-project':
|
||||
return self._handle_new_project(user_id, args)
|
||||
if command == 'use':
|
||||
return self._handle_use_project(user_id, args)
|
||||
|
||||
# Generic commands - get project from user context first
|
||||
user_ctx = self._get_user_context_manager(user_id)
|
||||
project_name = user_ctx.get_current_project()
|
||||
|
||||
# Fall back to context param or default
|
||||
if not project_name:
|
||||
project_name = context.get("project", "default")
|
||||
|
||||
# Get or create session (auto-create per CONTEXT.md)
|
||||
session = self.tmux_manager.get_session(project_name)
|
||||
if session is None:
|
||||
session = self.tmux_manager.create_session(project_name)
|
||||
|
||||
# Build the full command
|
||||
full_command = f"/gsd:{command}"
|
||||
if args:
|
||||
full_command = f"{full_command} {args}"
|
||||
|
||||
# Determine timeout based on command type
|
||||
timeout = None if self._is_long_command(command) else self.executor.DEFAULT_TIMEOUT
|
||||
|
||||
# Execute command with adaptive timeout
|
||||
result = self.executor.execute(session, full_command, timeout=timeout)
|
||||
|
||||
# Truncate output if needed
|
||||
output = result.output
|
||||
if len(output) > self.MAX_OUTPUT_LENGTH:
|
||||
output = output[-self.MAX_OUTPUT_LENGTH:]
|
||||
|
||||
# Check for approval request
|
||||
approval_context = self.approval_handler.detect_approval_request(result.output)
|
||||
if approval_context:
|
||||
return self.approval_handler.create_approval_card(
|
||||
context=approval_context,
|
||||
session_id=session.session_id,
|
||||
project_name=project_name
|
||||
)
|
||||
|
||||
# Return appropriate response based on result
|
||||
if result.has_error:
|
||||
return self.formatter.format_error(
|
||||
title="命令执行失败",
|
||||
content=output
|
||||
)
|
||||
else:
|
||||
return self.formatter.format_success(
|
||||
title="执行成功",
|
||||
content=output
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="命令执行失败",
|
||||
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
|
||||
)
|
||||
|
||||
def handle_callback(self, callback_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Handle a button callback from a card.
|
||||
|
||||
This processes various actions including:
|
||||
- approve/reject: For approval workflows
|
||||
- new-project: For creating new projects from error cards
|
||||
|
||||
Args:
|
||||
callback_data: Dictionary containing:
|
||||
- action: "approve", "reject", or "new-project"
|
||||
- For approvals: approval_id, project
|
||||
- For new-project: name, user_id
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict as confirmation response.
|
||||
"""
|
||||
try:
|
||||
action = callback_data.get("action", "")
|
||||
|
||||
# Handle new-project action from error card button
|
||||
if action == "new-project":
|
||||
project_name = callback_data.get("name", "")
|
||||
user_id = callback_data.get("user_id", "")
|
||||
|
||||
if not project_name:
|
||||
return self.formatter.format_error(
|
||||
title="Invalid Request",
|
||||
content="Project name is required"
|
||||
)
|
||||
|
||||
return self._handle_new_project(user_id, project_name)
|
||||
|
||||
# Handle approval/reject actions
|
||||
approval_id = callback_data.get("approval_id", "")
|
||||
project_name = callback_data.get("project", "default")
|
||||
|
||||
# Handle callback via approval handler
|
||||
result = self.approval_handler.handle_callback(callback_data)
|
||||
|
||||
if result.startswith("error:"):
|
||||
return self.formatter.format_error(
|
||||
title="操作失败",
|
||||
content=result
|
||||
)
|
||||
|
||||
# Get session
|
||||
session = self.tmux_manager.get_session(project_name)
|
||||
if session is None:
|
||||
return self.formatter.format_error(
|
||||
title="会话不存在",
|
||||
content="无法找到对应的会话"
|
||||
)
|
||||
|
||||
# Send response to tmux
|
||||
if result == "approved":
|
||||
response_text = "y" # Standard confirmation
|
||||
self.executor.send_command(session.tmux_session.active_pane, response_text)
|
||||
return self.formatter.format_success(
|
||||
title="已确认",
|
||||
content="操作已继续执行"
|
||||
)
|
||||
elif result == "rejected":
|
||||
response_text = "n" # Standard rejection
|
||||
self.executor.send_command(session.tmux_session.active_pane, response_text)
|
||||
return self.formatter.format_info(
|
||||
title="已取消",
|
||||
content="操作已取消"
|
||||
)
|
||||
else:
|
||||
return self.formatter.format_error(
|
||||
title="未知结果",
|
||||
content=f"处理结果: {result}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.formatter.format_error(
|
||||
title="操作失败",
|
||||
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Progress monitor for tracking and reporting GSD operation progress.
|
||||
|
||||
This module provides:
|
||||
- Detection of progress indicators in Claude CLI output
|
||||
- Throttled progress updates (1 minute intervals)
|
||||
- Progress card creation for Feishu messages
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple, Pattern
|
||||
|
||||
from gsd_openclaw.response_formatter import FeishuResponseFormatter
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressInfo:
|
||||
"""Represents extracted progress information.
|
||||
|
||||
Attributes:
|
||||
type: Type of progress indicator ('percentage', 'fraction', 'phase', 'completion').
|
||||
value: Numeric value (for percentage or phase).
|
||||
current: Current count (for fraction type).
|
||||
total: Total count (for fraction type).
|
||||
"""
|
||||
type: str
|
||||
value: Optional[int] = None
|
||||
current: Optional[int] = None
|
||||
total: Optional[int] = None
|
||||
|
||||
|
||||
class ProgressMonitor:
|
||||
"""Monitors and reports progress for GSD operations.
|
||||
|
||||
This class provides functionality to:
|
||||
- Extract progress indicators from output text
|
||||
- Throttle progress updates to specified intervals
|
||||
- Create progress cards for Feishu messages
|
||||
|
||||
Attributes:
|
||||
PROGRESS_PATTERNS: List of (pattern, type) tuples for progress detection.
|
||||
update_interval: Minimum seconds between progress updates (default 60.0).
|
||||
last_update_time: Unix timestamp of last update.
|
||||
"""
|
||||
|
||||
# Patterns for detecting progress in output
|
||||
# Each tuple is (regex pattern, progress type)
|
||||
PROGRESS_PATTERNS: List[Tuple[Pattern, str]] = [
|
||||
(re.compile(r'(\d+)%'), 'percentage'),
|
||||
(re.compile(r'\[(\d+)/(\d+)(?:[^\]]*)\]'), 'fraction'), # Allow content after fraction
|
||||
(re.compile(r'Phase\s+(\d+)', re.IGNORECASE), 'phase'),
|
||||
(re.compile(r'完成|completed|done', re.IGNORECASE), 'completion'),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formatter: FeishuResponseFormatter,
|
||||
update_interval: float = 60.0
|
||||
):
|
||||
"""Initialize ProgressMonitor.
|
||||
|
||||
Args:
|
||||
formatter: FeishuResponseFormatter instance for creating cards.
|
||||
update_interval: Minimum seconds between progress updates (default 60.0).
|
||||
"""
|
||||
self.formatter = formatter
|
||||
self.update_interval = update_interval
|
||||
self.last_update_time: float = 0.0
|
||||
|
||||
def extract_progress(self, output: str) -> Optional[ProgressInfo]:
|
||||
"""Extract progress information from output text.
|
||||
|
||||
Scans output for known progress patterns and returns the first match.
|
||||
|
||||
Args:
|
||||
output: The output text to scan for progress indicators.
|
||||
|
||||
Returns:
|
||||
ProgressInfo if progress pattern found, None otherwise.
|
||||
"""
|
||||
for pattern, progress_type in self.PROGRESS_PATTERNS:
|
||||
match = pattern.search(output)
|
||||
if match:
|
||||
if progress_type == 'percentage':
|
||||
return ProgressInfo(
|
||||
type='percentage',
|
||||
value=int(match.group(1)),
|
||||
)
|
||||
elif progress_type == 'fraction':
|
||||
return ProgressInfo(
|
||||
type='fraction',
|
||||
current=int(match.group(1)),
|
||||
total=int(match.group(2)),
|
||||
)
|
||||
elif progress_type == 'phase':
|
||||
return ProgressInfo(
|
||||
type='phase',
|
||||
value=int(match.group(1)),
|
||||
)
|
||||
elif progress_type == 'completion':
|
||||
return ProgressInfo(
|
||||
type='completion',
|
||||
value=100, # Completion means 100%
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def should_send_update(self) -> bool:
|
||||
"""Check if enough time has elapsed for a progress update.
|
||||
|
||||
Returns:
|
||||
True if update_interval has elapsed since last update, False otherwise.
|
||||
Returns False if no update has been sent yet (first call).
|
||||
"""
|
||||
# If no update has been sent yet (last_update_time is 0), return False
|
||||
if self.last_update_time == 0.0:
|
||||
return False
|
||||
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self.last_update_time
|
||||
return elapsed >= self.update_interval
|
||||
|
||||
def create_progress_card(
|
||||
self,
|
||||
progress: ProgressInfo,
|
||||
message: str
|
||||
) -> dict:
|
||||
"""Create a Feishu progress card.
|
||||
|
||||
Args:
|
||||
progress: ProgressInfo with extracted progress data.
|
||||
message: Additional message to include in the card.
|
||||
|
||||
Returns:
|
||||
Feishu Card JSON dict with progress information.
|
||||
"""
|
||||
# Format progress content
|
||||
if progress.type == 'percentage':
|
||||
progress_text = f"进度: {progress.value}%"
|
||||
elif progress.type == 'fraction':
|
||||
progress_text = f"进度: [{progress.current}/{progress.total}]"
|
||||
elif progress.type == 'phase':
|
||||
progress_text = f"阶段: {progress.value}"
|
||||
elif progress.type == 'completion':
|
||||
progress_text = "完成"
|
||||
else:
|
||||
progress_text = f"进度: {progress.value}"
|
||||
|
||||
content = f"{message}\n\n{progress_text}"
|
||||
|
||||
# Update last_update_time when creating card
|
||||
self.last_update_time = time.time()
|
||||
|
||||
return self.formatter.format_info(
|
||||
title="正在执行...",
|
||||
content=content,
|
||||
)
|
||||
|
||||
def reset_timer(self) -> None:
|
||||
"""Reset the update timer (useful for new operations)."""
|
||||
self.last_update_time = 0.0
|
||||
@@ -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