forked from Rockachopa/Timmy-time-dashboard
refactor: Phase 2b — consolidate 28 modules into 14 packages
Complete the module consolidation planned in REFACTORING_PLAN.md: Modules merged: - work_orders/ + task_queue/ → swarm/ (subpackages) - self_modify/ + self_tdd/ + upgrades/ → self_coding/ (subpackages) - tools/ → creative/tools/ - chat_bridge/ + telegram_bot/ + shortcuts/ + voice/ → integrations/ (new) - ws_manager/ + notifications/ + events/ + router/ → infrastructure/ (new) - agents/ + agent_core/ + memory/ → timmy/ (subpackages) Updated across codebase: - 66 source files: import statements rewritten - 13 test files: import + patch() target strings rewritten - pyproject.toml: wheel includes (28→14), entry points updated - CLAUDE.md: singleton paths, module map, entry points table - AGENTS.md: file convention updates - REFACTORING_PLAN.md: execution status, success metrics Extras: - Module-level CLAUDE.md added to 6 key packages (Phase 6.2) - Zero test regressions: 1462 tests passing https://claude.ai/code/session_01JNjWfHqusjT3aiN4vvYgUk
This commit is contained in:
1
src/self_coding/upgrades/__init__.py
Normal file
1
src/self_coding/upgrades/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Upgrades — System upgrade queue and execution pipeline."""
|
||||
331
src/self_coding/upgrades/models.py
Normal file
331
src/self_coding/upgrades/models.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Database models for Self-Upgrade Approval Queue."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DB_PATH = Path("data/swarm.db")
|
||||
|
||||
|
||||
class UpgradeStatus(str, Enum):
|
||||
"""Status of an upgrade proposal."""
|
||||
PROPOSED = "proposed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
APPLIED = "applied"
|
||||
FAILED = "failed"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Upgrade:
|
||||
"""A self-modification upgrade proposal."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
status: UpgradeStatus = UpgradeStatus.PROPOSED
|
||||
|
||||
# Timestamps
|
||||
proposed_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
approved_at: Optional[str] = None
|
||||
applied_at: Optional[str] = None
|
||||
rejected_at: Optional[str] = None
|
||||
|
||||
# Proposal details
|
||||
branch_name: str = ""
|
||||
description: str = ""
|
||||
files_changed: list[str] = field(default_factory=list)
|
||||
diff_preview: str = ""
|
||||
|
||||
# Test results
|
||||
test_passed: bool = False
|
||||
test_output: str = ""
|
||||
|
||||
# Execution results
|
||||
error_message: Optional[str] = None
|
||||
approved_by: Optional[str] = None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Get database connection with schema initialized."""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS upgrades (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'proposed',
|
||||
proposed_at TEXT NOT NULL,
|
||||
approved_at TEXT,
|
||||
applied_at TEXT,
|
||||
rejected_at TEXT,
|
||||
branch_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
files_changed TEXT, -- JSON array
|
||||
diff_preview TEXT,
|
||||
test_passed INTEGER DEFAULT 0,
|
||||
test_output TEXT,
|
||||
error_message TEXT,
|
||||
approved_by TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Indexes
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrades_status ON upgrades(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrades_proposed ON upgrades(proposed_at)")
|
||||
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def create_upgrade(
|
||||
branch_name: str,
|
||||
description: str,
|
||||
files_changed: list[str],
|
||||
diff_preview: str,
|
||||
test_passed: bool = False,
|
||||
test_output: str = "",
|
||||
) -> Upgrade:
|
||||
"""Create a new upgrade proposal.
|
||||
|
||||
Args:
|
||||
branch_name: Git branch name for the upgrade
|
||||
description: Human-readable description
|
||||
files_changed: List of files that would be modified
|
||||
diff_preview: Short diff preview for review
|
||||
test_passed: Whether tests passed on the branch
|
||||
test_output: Test output text
|
||||
|
||||
Returns:
|
||||
The created Upgrade
|
||||
"""
|
||||
upgrade = Upgrade(
|
||||
branch_name=branch_name,
|
||||
description=description,
|
||||
files_changed=files_changed,
|
||||
diff_preview=diff_preview,
|
||||
test_passed=test_passed,
|
||||
test_output=test_output,
|
||||
)
|
||||
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO upgrades (id, status, proposed_at, branch_name, description,
|
||||
files_changed, diff_preview, test_passed, test_output)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
upgrade.id,
|
||||
upgrade.status.value,
|
||||
upgrade.proposed_at,
|
||||
upgrade.branch_name,
|
||||
upgrade.description,
|
||||
json.dumps(files_changed),
|
||||
upgrade.diff_preview,
|
||||
int(test_passed),
|
||||
test_output,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return upgrade
|
||||
|
||||
|
||||
def get_upgrade(upgrade_id: str) -> Optional[Upgrade]:
|
||||
"""Get upgrade by ID."""
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM upgrades WHERE id = ?", (upgrade_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return Upgrade(
|
||||
id=row["id"],
|
||||
status=UpgradeStatus(row["status"]),
|
||||
proposed_at=row["proposed_at"],
|
||||
approved_at=row["approved_at"],
|
||||
applied_at=row["applied_at"],
|
||||
rejected_at=row["rejected_at"],
|
||||
branch_name=row["branch_name"],
|
||||
description=row["description"],
|
||||
files_changed=json.loads(row["files_changed"]) if row["files_changed"] else [],
|
||||
diff_preview=row["diff_preview"] or "",
|
||||
test_passed=bool(row["test_passed"]),
|
||||
test_output=row["test_output"] or "",
|
||||
error_message=row["error_message"],
|
||||
approved_by=row["approved_by"],
|
||||
)
|
||||
|
||||
|
||||
def list_upgrades(
|
||||
status: Optional[UpgradeStatus] = None,
|
||||
limit: int = 100,
|
||||
) -> list[Upgrade]:
|
||||
"""List upgrades, optionally filtered by status."""
|
||||
conn = _get_conn()
|
||||
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM upgrades WHERE status = ? ORDER BY proposed_at DESC LIMIT ?",
|
||||
(status.value, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM upgrades ORDER BY proposed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
Upgrade(
|
||||
id=r["id"],
|
||||
status=UpgradeStatus(r["status"]),
|
||||
proposed_at=r["proposed_at"],
|
||||
approved_at=r["approved_at"],
|
||||
applied_at=r["applied_at"],
|
||||
rejected_at=r["rejected_at"],
|
||||
branch_name=r["branch_name"],
|
||||
description=r["description"],
|
||||
files_changed=json.loads(r["files_changed"]) if r["files_changed"] else [],
|
||||
diff_preview=r["diff_preview"] or "",
|
||||
test_passed=bool(r["test_passed"]),
|
||||
test_output=r["test_output"] or "",
|
||||
error_message=r["error_message"],
|
||||
approved_by=r["approved_by"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def approve_upgrade(upgrade_id: str, approved_by: str = "dashboard") -> Optional[Upgrade]:
|
||||
"""Approve an upgrade proposal."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE upgrades
|
||||
SET status = ?, approved_at = ?, approved_by = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
(UpgradeStatus.APPROVED.value, now, approved_by, upgrade_id, UpgradeStatus.PROPOSED.value),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
conn.close()
|
||||
|
||||
if not updated:
|
||||
return None
|
||||
|
||||
return get_upgrade(upgrade_id)
|
||||
|
||||
|
||||
def reject_upgrade(upgrade_id: str) -> Optional[Upgrade]:
|
||||
"""Reject an upgrade proposal."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE upgrades
|
||||
SET status = ?, rejected_at = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
(UpgradeStatus.REJECTED.value, now, upgrade_id, UpgradeStatus.PROPOSED.value),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
conn.close()
|
||||
|
||||
if not updated:
|
||||
return None
|
||||
|
||||
return get_upgrade(upgrade_id)
|
||||
|
||||
|
||||
def mark_applied(upgrade_id: str) -> Optional[Upgrade]:
|
||||
"""Mark upgrade as successfully applied."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE upgrades
|
||||
SET status = ?, applied_at = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
(UpgradeStatus.APPLIED.value, now, upgrade_id, UpgradeStatus.APPROVED.value),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
conn.close()
|
||||
|
||||
if not updated:
|
||||
return None
|
||||
|
||||
return get_upgrade(upgrade_id)
|
||||
|
||||
|
||||
def mark_failed(upgrade_id: str, error_message: str) -> Optional[Upgrade]:
|
||||
"""Mark upgrade as failed."""
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE upgrades
|
||||
SET status = ?, error_message = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
(UpgradeStatus.FAILED.value, error_message, upgrade_id, UpgradeStatus.APPROVED.value),
|
||||
)
|
||||
conn.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
conn.close()
|
||||
|
||||
if not updated:
|
||||
return None
|
||||
|
||||
return get_upgrade(upgrade_id)
|
||||
|
||||
|
||||
def get_pending_count() -> int:
|
||||
"""Get count of pending (proposed) upgrades."""
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as count FROM upgrades WHERE status = ?",
|
||||
(UpgradeStatus.PROPOSED.value,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row["count"]
|
||||
|
||||
|
||||
def prune_old_upgrades(older_than_days: int = 30) -> int:
|
||||
"""Delete old completed upgrades."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
|
||||
|
||||
conn = _get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM upgrades
|
||||
WHERE proposed_at < ? AND status IN ('applied', 'rejected', 'failed')
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return deleted
|
||||
285
src/self_coding/upgrades/queue.py
Normal file
285
src/self_coding/upgrades/queue.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Upgrade Queue management - bridges self-modify loop with approval workflow."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from self_coding.upgrades.models import (
|
||||
Upgrade,
|
||||
UpgradeStatus,
|
||||
create_upgrade,
|
||||
get_upgrade,
|
||||
approve_upgrade,
|
||||
reject_upgrade,
|
||||
mark_applied,
|
||||
mark_failed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
class UpgradeQueue:
|
||||
"""Manages the upgrade approval and application workflow."""
|
||||
|
||||
@staticmethod
|
||||
def propose(
|
||||
branch_name: str,
|
||||
description: str,
|
||||
files_changed: list[str],
|
||||
diff_preview: str,
|
||||
test_passed: bool = False,
|
||||
test_output: str = "",
|
||||
) -> Upgrade:
|
||||
"""Propose a new upgrade for approval.
|
||||
|
||||
This is called by the self-modify loop when it generates changes.
|
||||
The upgrade is created in 'proposed' state and waits for human approval.
|
||||
|
||||
Args:
|
||||
branch_name: Git branch with the changes
|
||||
description: What the upgrade does
|
||||
files_changed: List of modified files
|
||||
diff_preview: Short diff for review
|
||||
test_passed: Whether tests passed
|
||||
test_output: Test output
|
||||
|
||||
Returns:
|
||||
The created Upgrade proposal
|
||||
"""
|
||||
upgrade = create_upgrade(
|
||||
branch_name=branch_name,
|
||||
description=description,
|
||||
files_changed=files_changed,
|
||||
diff_preview=diff_preview,
|
||||
test_passed=test_passed,
|
||||
test_output=test_output,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Upgrade proposed: %s (%s) - %d files",
|
||||
upgrade.id[:8],
|
||||
branch_name,
|
||||
len(files_changed),
|
||||
)
|
||||
|
||||
# Log to event log
|
||||
try:
|
||||
from swarm.event_log import log_event, EventType
|
||||
log_event(
|
||||
EventType.SYSTEM_INFO,
|
||||
source="upgrade_queue",
|
||||
data={
|
||||
"upgrade_id": upgrade.id,
|
||||
"branch": branch_name,
|
||||
"description": description,
|
||||
"test_passed": test_passed,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return upgrade
|
||||
|
||||
@staticmethod
|
||||
def approve(upgrade_id: str, approved_by: str = "dashboard") -> Optional[Upgrade]:
|
||||
"""Approve an upgrade proposal.
|
||||
|
||||
Called from dashboard when user clicks "Approve".
|
||||
Does NOT apply the upgrade - that happens separately.
|
||||
|
||||
Args:
|
||||
upgrade_id: The upgrade to approve
|
||||
approved_by: Who approved it (for audit)
|
||||
|
||||
Returns:
|
||||
Updated Upgrade or None if not found/not in proposed state
|
||||
"""
|
||||
upgrade = approve_upgrade(upgrade_id, approved_by)
|
||||
|
||||
if upgrade:
|
||||
logger.info("Upgrade approved: %s by %s", upgrade_id[:8], approved_by)
|
||||
|
||||
return upgrade
|
||||
|
||||
@staticmethod
|
||||
def reject(upgrade_id: str) -> Optional[Upgrade]:
|
||||
"""Reject an upgrade proposal.
|
||||
|
||||
Called from dashboard when user clicks "Reject".
|
||||
Cleans up the branch.
|
||||
|
||||
Args:
|
||||
upgrade_id: The upgrade to reject
|
||||
|
||||
Returns:
|
||||
Updated Upgrade or None
|
||||
"""
|
||||
upgrade = reject_upgrade(upgrade_id)
|
||||
|
||||
if upgrade:
|
||||
logger.info("Upgrade rejected: %s", upgrade_id[:8])
|
||||
|
||||
# Clean up branch
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", upgrade.branch_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete branch %s: %s", upgrade.branch_name, exc)
|
||||
|
||||
return upgrade
|
||||
|
||||
@staticmethod
|
||||
def apply(upgrade_id: str) -> tuple[bool, str]:
|
||||
"""Apply an approved upgrade.
|
||||
|
||||
This is the critical operation that actually modifies the codebase:
|
||||
1. Checks out the branch
|
||||
2. Runs tests
|
||||
3. If tests pass: merges to main
|
||||
4. Updates upgrade status
|
||||
|
||||
Args:
|
||||
upgrade_id: The approved upgrade to apply
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
upgrade = get_upgrade(upgrade_id)
|
||||
|
||||
if not upgrade:
|
||||
return False, "Upgrade not found"
|
||||
|
||||
if upgrade.status != UpgradeStatus.APPROVED:
|
||||
return False, f"Upgrade not approved (status: {upgrade.status.value})"
|
||||
|
||||
logger.info("Applying upgrade: %s (%s)", upgrade_id[:8], upgrade.branch_name)
|
||||
|
||||
try:
|
||||
# 1. Checkout branch
|
||||
result = subprocess.run(
|
||||
["git", "checkout", upgrade.branch_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
mark_failed(upgrade_id, f"Checkout failed: {result.stderr}")
|
||||
return False, f"Failed to checkout branch: {result.stderr}"
|
||||
|
||||
# 2. Run tests
|
||||
result = subprocess.run(
|
||||
["python", "-m", "pytest", "tests/", "-x", "-q"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
mark_failed(upgrade_id, f"Tests failed: {result.stdout}\n{result.stderr}")
|
||||
# Switch back to main
|
||||
subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
|
||||
return False, "Tests failed"
|
||||
|
||||
# 3. Merge to main
|
||||
result = subprocess.run(
|
||||
["git", "checkout", "main"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
mark_failed(upgrade_id, f"Failed to checkout main: {result.stderr}")
|
||||
return False, "Failed to checkout main"
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "merge", "--no-ff", upgrade.branch_name, "-m", f"Apply upgrade: {upgrade.description}"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
mark_failed(upgrade_id, f"Merge failed: {result.stderr}")
|
||||
return False, "Merge failed"
|
||||
|
||||
# 4. Mark as applied
|
||||
mark_applied(upgrade_id)
|
||||
|
||||
# 5. Clean up branch
|
||||
subprocess.run(
|
||||
["git", "branch", "-d", upgrade.branch_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
logger.info("Upgrade applied successfully: %s", upgrade_id[:8])
|
||||
return True, "Upgrade applied successfully"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
mark_failed(upgrade_id, "Tests timed out")
|
||||
subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
|
||||
return False, "Tests timed out"
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
mark_failed(upgrade_id, error_msg)
|
||||
subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
|
||||
return False, f"Error: {error_msg}"
|
||||
|
||||
@staticmethod
|
||||
def get_full_diff(upgrade_id: str) -> str:
|
||||
"""Get full git diff for an upgrade.
|
||||
|
||||
Args:
|
||||
upgrade_id: The upgrade to get diff for
|
||||
|
||||
Returns:
|
||||
Git diff output
|
||||
"""
|
||||
upgrade = get_upgrade(upgrade_id)
|
||||
if not upgrade:
|
||||
return "Upgrade not found"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "main..." + upgrade.branch_name],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else result.stderr
|
||||
except Exception as exc:
|
||||
return f"Error getting diff: {exc}"
|
||||
|
||||
|
||||
# Convenience functions for self-modify loop
|
||||
def propose_upgrade_from_loop(
|
||||
branch_name: str,
|
||||
description: str,
|
||||
files_changed: list[str],
|
||||
diff: str,
|
||||
test_output: str = "",
|
||||
) -> Upgrade:
|
||||
"""Called by self-modify loop to propose an upgrade.
|
||||
|
||||
Tests are expected to have been run by the loop before calling this.
|
||||
"""
|
||||
# Check if tests passed from output
|
||||
test_passed = "passed" in test_output.lower() or " PASSED " in test_output
|
||||
|
||||
return UpgradeQueue.propose(
|
||||
branch_name=branch_name,
|
||||
description=description,
|
||||
files_changed=files_changed,
|
||||
diff_preview=diff[:2000], # First 2000 chars
|
||||
test_passed=test_passed,
|
||||
test_output=test_output,
|
||||
)
|
||||
Reference in New Issue
Block a user