c17c2768eb
New runtime/code: - src/ask_user_runtime.py - src/team_runtime.py New real tools in src/agent_tools.py: - ask_user_question - team_create - team_delete - team_list - team_get - send_message - team_messages - notebook_edit
449 lines
16 KiB
Python
449 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_WORKTREE_STATE_FILE = 'claw_worktree_runtime.json'
|
|
DEFAULT_WORKTREE_PARENT_SUFFIX = '-claw-worktrees'
|
|
VALID_EXIT_ACTIONS = ('keep', 'remove')
|
|
_WORKTREE_SLUG_RE = re.compile(r'^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$')
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorktreeSessionState:
|
|
name: str
|
|
repo_root: str
|
|
common_git_dir: str
|
|
worktree_path: str
|
|
worktree_branch: str
|
|
original_cwd: str
|
|
original_head_commit: str | None
|
|
created_at: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorktreeStatusReport:
|
|
active: bool
|
|
detail: str
|
|
repo_root: str | None = None
|
|
common_git_dir: str | None = None
|
|
current_cwd: str | None = None
|
|
original_cwd: str | None = None
|
|
worktree_path: str | None = None
|
|
worktree_branch: str | None = None
|
|
session_name: str | None = None
|
|
state_path: str | None = None
|
|
history_count: int = 0
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def as_text(self) -> str:
|
|
lines = [
|
|
f'active={self.active}',
|
|
f'detail={self.detail}',
|
|
f'history_count={self.history_count}',
|
|
]
|
|
for key in (
|
|
'repo_root',
|
|
'common_git_dir',
|
|
'current_cwd',
|
|
'original_cwd',
|
|
'worktree_path',
|
|
'worktree_branch',
|
|
'session_name',
|
|
'state_path',
|
|
):
|
|
value = getattr(self, key)
|
|
if value:
|
|
lines.append(f'{key}={value}')
|
|
for key, value in sorted(self.metadata.items()):
|
|
lines.append(f'metadata.{key}={value}')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
@dataclass
|
|
class WorktreeRuntime:
|
|
cwd: Path
|
|
state_path: Path
|
|
repo_root: Path | None = None
|
|
common_git_dir: Path | None = None
|
|
active_session: WorktreeSessionState | None = None
|
|
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
|
|
@classmethod
|
|
def from_workspace(cls, cwd: Path) -> 'WorktreeRuntime':
|
|
resolved_cwd = cwd.resolve()
|
|
common_git_dir = _find_git_common_dir(resolved_cwd)
|
|
repo_root = _infer_repo_root(resolved_cwd, common_git_dir)
|
|
state_path = (
|
|
common_git_dir / DEFAULT_WORKTREE_STATE_FILE
|
|
if common_git_dir is not None
|
|
else (resolved_cwd / '.port_sessions' / 'worktree_runtime.json')
|
|
)
|
|
payload = _load_payload(state_path)
|
|
active_payload = payload.get('active_session')
|
|
history_payload = payload.get('history')
|
|
return cls(
|
|
cwd=resolved_cwd,
|
|
state_path=state_path,
|
|
repo_root=repo_root,
|
|
common_git_dir=common_git_dir,
|
|
active_session=_session_from_payload(active_payload),
|
|
history=tuple(
|
|
item for item in history_payload if isinstance(item, dict)
|
|
) if isinstance(history_payload, list) else (),
|
|
)
|
|
|
|
def has_state(self) -> bool:
|
|
return self.active_session is not None or bool(self.history)
|
|
|
|
def current_report(self, *, detail: str | None = None) -> WorktreeStatusReport:
|
|
if self.active_session is None:
|
|
return WorktreeStatusReport(
|
|
active=False,
|
|
detail=detail or 'No active managed worktree session.',
|
|
repo_root=str(self.repo_root) if self.repo_root is not None else None,
|
|
common_git_dir=(
|
|
str(self.common_git_dir) if self.common_git_dir is not None else None
|
|
),
|
|
current_cwd=str(self.cwd),
|
|
state_path=str(self.state_path),
|
|
history_count=len(self.history),
|
|
)
|
|
active = self.active_session
|
|
return WorktreeStatusReport(
|
|
active=True,
|
|
detail=detail or f'Active worktree session {active.name}',
|
|
repo_root=active.repo_root,
|
|
common_git_dir=active.common_git_dir,
|
|
current_cwd=str(self.cwd),
|
|
original_cwd=active.original_cwd,
|
|
worktree_path=active.worktree_path,
|
|
worktree_branch=active.worktree_branch,
|
|
session_name=active.name,
|
|
state_path=str(self.state_path),
|
|
history_count=len(self.history),
|
|
)
|
|
|
|
def render_summary(self) -> str:
|
|
lines = ['# Worktree', '']
|
|
report = self.current_report()
|
|
lines.extend(
|
|
[
|
|
f'- Git repo detected: {self.repo_root is not None}',
|
|
f'- Active managed worktree: {report.active}',
|
|
f'- Current working directory: {self.cwd}',
|
|
f'- Worktree history entries: {len(self.history)}',
|
|
]
|
|
)
|
|
if self.repo_root is not None:
|
|
lines.append(f'- Repo root: {self.repo_root}')
|
|
if self.common_git_dir is not None:
|
|
lines.append(f'- Common git dir: {self.common_git_dir}')
|
|
if self.active_session is None:
|
|
lines.append('- Active worktree path: none')
|
|
lines.append('- Original working directory: none')
|
|
else:
|
|
active = self.active_session
|
|
lines.append(f'- Active worktree path: {active.worktree_path}')
|
|
lines.append(f'- Active worktree branch: {active.worktree_branch}')
|
|
lines.append(f'- Original working directory: {active.original_cwd}')
|
|
return '\n'.join(lines)
|
|
|
|
def render_history(self) -> str:
|
|
lines = ['# Worktree History', '']
|
|
if not self.history:
|
|
lines.append('No worktree history recorded.')
|
|
return '\n'.join(lines)
|
|
for entry in self.history:
|
|
action = entry.get('action', 'unknown')
|
|
timestamp = entry.get('timestamp', 'unknown')
|
|
name = entry.get('name') or entry.get('worktree_name') or 'unknown'
|
|
path = entry.get('worktree_path', 'unknown')
|
|
lines.append(f'- {timestamp} ; {action} ; {name} ; {path}')
|
|
return '\n'.join(lines)
|
|
|
|
def enter(self, name: str | None = None) -> WorktreeStatusReport:
|
|
if self.active_session is not None:
|
|
raise RuntimeError('A managed worktree session is already active.')
|
|
if self.repo_root is None or self.common_git_dir is None:
|
|
raise RuntimeError('A git repository is required to create a managed worktree.')
|
|
slug = _normalize_slug(name)
|
|
worktree_parent = self.repo_root.parent / f'{self.repo_root.name}{DEFAULT_WORKTREE_PARENT_SUFFIX}'
|
|
worktree_path = (worktree_parent / slug).resolve()
|
|
branch = f'claw/{slug}'
|
|
if worktree_path.exists():
|
|
raise RuntimeError(f'Worktree path already exists: {worktree_path}')
|
|
if _branch_exists(self.repo_root, branch):
|
|
raise RuntimeError(f'Worktree branch already exists: {branch}')
|
|
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
_run_git(
|
|
self.repo_root,
|
|
['worktree', 'add', '-b', branch, str(worktree_path), 'HEAD'],
|
|
)
|
|
active = WorktreeSessionState(
|
|
name=slug,
|
|
repo_root=str(self.repo_root),
|
|
common_git_dir=str(self.common_git_dir),
|
|
worktree_path=str(worktree_path),
|
|
worktree_branch=branch,
|
|
original_cwd=str(self.cwd),
|
|
original_head_commit=_git_head(self.repo_root),
|
|
created_at=_utc_now(),
|
|
)
|
|
self.active_session = active
|
|
self.cwd = Path(active.worktree_path)
|
|
self._append_history(
|
|
{
|
|
'action': 'enter',
|
|
'timestamp': active.created_at,
|
|
'name': active.name,
|
|
'repo_root': active.repo_root,
|
|
'original_cwd': active.original_cwd,
|
|
'worktree_path': active.worktree_path,
|
|
'worktree_branch': active.worktree_branch,
|
|
}
|
|
)
|
|
self._persist_state()
|
|
return self.current_report(
|
|
detail=(
|
|
f'Created worktree at {active.worktree_path} on branch {active.worktree_branch}. '
|
|
'The session should now work inside the managed worktree.'
|
|
)
|
|
)
|
|
|
|
def exit(
|
|
self,
|
|
*,
|
|
action: str = 'keep',
|
|
discard_changes: bool = False,
|
|
) -> WorktreeStatusReport:
|
|
normalized_action = action.strip().lower()
|
|
if normalized_action not in VALID_EXIT_ACTIONS:
|
|
raise ValueError(f'action must be one of {", ".join(VALID_EXIT_ACTIONS)}')
|
|
active = self.active_session
|
|
if active is None:
|
|
raise RuntimeError('No managed worktree session is currently active.')
|
|
worktree_path = Path(active.worktree_path)
|
|
if normalized_action == 'remove':
|
|
change_summary = _count_worktree_changes(
|
|
worktree_path,
|
|
active.original_head_commit,
|
|
)
|
|
if change_summary is None and not discard_changes:
|
|
raise RuntimeError(
|
|
'Could not verify worktree cleanliness. Re-run with discard_changes=true to remove it.'
|
|
)
|
|
if change_summary is not None:
|
|
changed_files, commits = change_summary
|
|
if (changed_files > 0 or commits > 0) and not discard_changes:
|
|
raise RuntimeError(
|
|
'Worktree has uncommitted files or commits. '
|
|
'Re-run with discard_changes=true to remove it.'
|
|
)
|
|
_run_git(Path(active.repo_root), ['worktree', 'remove', '--force', active.worktree_path])
|
|
_run_git(Path(active.repo_root), ['branch', '-D', active.worktree_branch], check=False)
|
|
self._append_history(
|
|
{
|
|
'action': f'exit_{normalized_action}',
|
|
'timestamp': _utc_now(),
|
|
'name': active.name,
|
|
'repo_root': active.repo_root,
|
|
'original_cwd': active.original_cwd,
|
|
'worktree_path': active.worktree_path,
|
|
'worktree_branch': active.worktree_branch,
|
|
'discard_changes': discard_changes,
|
|
}
|
|
)
|
|
self.active_session = None
|
|
self.cwd = Path(active.original_cwd)
|
|
self._persist_state()
|
|
return WorktreeStatusReport(
|
|
active=False,
|
|
detail=(
|
|
f'Exited managed worktree {active.name} and returned to {active.original_cwd}.'
|
|
if normalized_action == 'keep'
|
|
else (
|
|
f'Removed managed worktree {active.name} and returned to {active.original_cwd}.'
|
|
)
|
|
),
|
|
repo_root=active.repo_root,
|
|
common_git_dir=active.common_git_dir,
|
|
current_cwd=active.original_cwd,
|
|
original_cwd=active.original_cwd,
|
|
worktree_path=active.worktree_path,
|
|
worktree_branch=active.worktree_branch,
|
|
session_name=active.name,
|
|
state_path=str(self.state_path),
|
|
history_count=len(self.history),
|
|
metadata={
|
|
'action': normalized_action,
|
|
'discard_changes': discard_changes,
|
|
},
|
|
)
|
|
|
|
def _append_history(self, entry: dict[str, Any]) -> None:
|
|
self.history = (*self.history, dict(entry))
|
|
|
|
def _persist_state(self) -> None:
|
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
'active_session': (
|
|
asdict(self.active_session) if self.active_session is not None else None
|
|
),
|
|
'history': [dict(entry) for entry in self.history[-64:]],
|
|
}
|
|
self.state_path.write_text(
|
|
json.dumps(payload, indent=2, ensure_ascii=True),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
|
|
def _normalize_slug(raw_name: str | None) -> str:
|
|
if raw_name is None or not raw_name.strip():
|
|
return f'worktree-{datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")}'
|
|
candidate = raw_name.strip()
|
|
if len(candidate) > 64 or not _WORKTREE_SLUG_RE.match(candidate):
|
|
raise ValueError(
|
|
'worktree name may contain only letters, digits, dots, underscores, dashes, '
|
|
'and optional "/" separators, with a maximum length of 64 characters'
|
|
)
|
|
return candidate
|
|
|
|
|
|
def _find_git_common_dir(cwd: Path) -> Path | None:
|
|
try:
|
|
completed = subprocess.run(
|
|
['git', '-C', str(cwd), 'rev-parse', '--git-common-dir'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return None
|
|
output = completed.stdout.strip()
|
|
if not output:
|
|
return None
|
|
candidate = Path(output)
|
|
if not candidate.is_absolute():
|
|
candidate = (cwd / candidate).resolve()
|
|
return candidate.resolve()
|
|
|
|
|
|
def _infer_repo_root(cwd: Path, common_git_dir: Path | None) -> Path | None:
|
|
if common_git_dir is not None and common_git_dir.name == '.git':
|
|
return common_git_dir.parent.resolve()
|
|
try:
|
|
completed = subprocess.run(
|
|
['git', '-C', str(cwd), 'rev-parse', '--show-toplevel'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return None
|
|
output = completed.stdout.strip()
|
|
return Path(output).resolve() if output else None
|
|
|
|
|
|
def _load_payload(state_path: Path) -> dict[str, Any]:
|
|
if not state_path.exists():
|
|
return {}
|
|
try:
|
|
payload = json.loads(state_path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def _session_from_payload(payload: Any) -> WorktreeSessionState | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
required_keys = (
|
|
'name',
|
|
'repo_root',
|
|
'common_git_dir',
|
|
'worktree_path',
|
|
'worktree_branch',
|
|
'original_cwd',
|
|
'created_at',
|
|
)
|
|
if not all(isinstance(payload.get(key), str) and payload.get(key) for key in required_keys):
|
|
return None
|
|
return WorktreeSessionState(
|
|
name=str(payload['name']),
|
|
repo_root=str(payload['repo_root']),
|
|
common_git_dir=str(payload['common_git_dir']),
|
|
worktree_path=str(payload['worktree_path']),
|
|
worktree_branch=str(payload['worktree_branch']),
|
|
original_cwd=str(payload['original_cwd']),
|
|
original_head_commit=(
|
|
str(payload['original_head_commit'])
|
|
if isinstance(payload.get('original_head_commit'), str)
|
|
else None
|
|
),
|
|
created_at=str(payload['created_at']),
|
|
)
|
|
|
|
|
|
def _run_git(repo_root: Path, arguments: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
['git', '-C', str(repo_root), *arguments],
|
|
check=check,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def _branch_exists(repo_root: Path, branch: str) -> bool:
|
|
completed = _run_git(
|
|
repo_root,
|
|
['show-ref', '--verify', '--quiet', f'refs/heads/{branch}'],
|
|
check=False,
|
|
)
|
|
return completed.returncode == 0
|
|
|
|
|
|
def _git_head(repo_root: Path) -> str | None:
|
|
completed = _run_git(repo_root, ['rev-parse', 'HEAD'], check=False)
|
|
if completed.returncode != 0:
|
|
return None
|
|
value = completed.stdout.strip()
|
|
return value or None
|
|
|
|
|
|
def _count_worktree_changes(worktree_path: Path, original_head_commit: str | None) -> tuple[int, int] | None:
|
|
status = subprocess.run(
|
|
['git', '-C', str(worktree_path), 'status', '--porcelain'],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if status.returncode != 0:
|
|
return None
|
|
changed_files = len([line for line in status.stdout.splitlines() if line.strip()])
|
|
if not original_head_commit:
|
|
return None
|
|
rev_list = subprocess.run(
|
|
['git', '-C', str(worktree_path), 'rev-list', '--count', f'{original_head_commit}..HEAD'],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if rev_list.returncode != 0:
|
|
return None
|
|
try:
|
|
commits = int(rev_list.stdout.strip() or '0')
|
|
except ValueError:
|
|
commits = 0
|
|
return changed_files, commits
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|