Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddefd90e4e |
@@ -1801,6 +1801,21 @@ def export_graph() -> str:
|
||||
|
||||
mcp_servers_size = mcp_servers_path.stat().st_size
|
||||
|
||||
# === GIT VERSIONING ===
|
||||
git_info: dict = {"initialized": False, "commit": None}
|
||||
try:
|
||||
from framework.utils.git import commit_all, init_repo
|
||||
|
||||
init_repo(exports_dir)
|
||||
git_info["initialized"] = True
|
||||
commit_sha = commit_all(
|
||||
exports_dir,
|
||||
f"Export: {session.goal.name} ({len(session.nodes)} nodes, {len(edges_list)} edges)",
|
||||
)
|
||||
git_info["commit"] = commit_sha
|
||||
except Exception as e:
|
||||
logger.warning("Git versioning skipped: %s", e)
|
||||
|
||||
# Get file sizes
|
||||
agent_json_size = agent_json_path.stat().st_size
|
||||
readme_size = readme_path.stat().st_size
|
||||
@@ -1833,6 +1848,7 @@ def export_graph() -> str:
|
||||
"node_count": len(session.nodes),
|
||||
"edge_count": len(edges_list),
|
||||
"mcp_servers_count": len(session.mcp_servers),
|
||||
"git": git_info,
|
||||
"note": f"Agent exported to {exports_dir}. Files: agent.json, README.md"
|
||||
+ (", mcp_servers.json" if session.mcp_servers else ""),
|
||||
},
|
||||
|
||||
@@ -157,6 +157,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
from framework.server.routes_graphs import register_routes as register_graph_routes
|
||||
from framework.server.routes_logs import register_routes as register_log_routes
|
||||
from framework.server.routes_sessions import register_routes as register_session_routes
|
||||
from framework.server.routes_versions import register_routes as register_version_routes
|
||||
|
||||
register_credential_routes(app)
|
||||
register_execution_routes(app)
|
||||
@@ -164,6 +165,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
register_session_routes(app)
|
||||
register_graph_routes(app)
|
||||
register_log_routes(app)
|
||||
register_version_routes(app)
|
||||
|
||||
# Static file serving — Option C production mode
|
||||
# If frontend/dist/ exists, serve built frontend files on /
|
||||
|
||||
@@ -645,6 +645,7 @@ async def handle_discover(request: web.Request) -> web.Response:
|
||||
"tags": entry.tags,
|
||||
"last_active": entry.last_active,
|
||||
"is_loaded": str(entry.path) in loaded_paths,
|
||||
"version": entry.version,
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Agent version management routes.
|
||||
|
||||
Agent-bound endpoints for git-based versioning. These are NOT session-bound —
|
||||
the version history lives with the agent in exports/{agent_name}/.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/agents/{agent_name}/versions — list all versions
|
||||
- POST /api/agents/{agent_name}/versions — create new version
|
||||
- GET /api/agents/{agent_name}/versions/{version} — version detail
|
||||
- DELETE /api/agents/{agent_name}/versions/{version} — delete version tag
|
||||
- GET /api/agents/{agent_name}/history — commit log
|
||||
- GET /api/agents/{agent_name}/files — list files at ref
|
||||
- GET /api/agents/{agent_name}/files/{path:.+} — file content at ref
|
||||
- GET /api/agents/{agent_name}/diff — diff between refs
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.server.app import safe_path_segment
|
||||
from framework.utils.git import (
|
||||
commit_all,
|
||||
create_tag,
|
||||
delete_tag,
|
||||
diff_between,
|
||||
has_changes,
|
||||
is_git_repo,
|
||||
latest_version,
|
||||
list_files_at_ref,
|
||||
list_tags,
|
||||
log,
|
||||
next_version,
|
||||
parse_semver,
|
||||
show_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_agent_dir(request: web.Request) -> tuple[Path, str]:
|
||||
"""Resolve {agent_name} to an exports/ directory.
|
||||
|
||||
Returns (agent_dir, agent_name).
|
||||
Raises HTTPNotFound if the directory doesn't exist.
|
||||
Raises HTTPBadRequest if the name is unsafe.
|
||||
"""
|
||||
agent_name = safe_path_segment(request.match_info["agent_name"])
|
||||
agent_dir = Path("exports") / agent_name
|
||||
if not agent_dir.is_dir():
|
||||
raise web.HTTPNotFound(
|
||||
text=f'{{"error": "Agent \\"{agent_name}\\" not found in exports/"}}',
|
||||
content_type="application/json",
|
||||
)
|
||||
return agent_dir, agent_name
|
||||
|
||||
|
||||
def _require_git(agent_dir: Path, agent_name: str) -> None:
|
||||
"""Raise HTTPConflict if the agent dir is not a git repo."""
|
||||
if not is_git_repo(agent_dir):
|
||||
raise web.HTTPConflict(
|
||||
text=f'{{"error": "Agent \\"{agent_name}\\" has no git repository. Export the agent first."}}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version listing and creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_list_versions(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/versions — list all semver versions."""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
|
||||
if not is_git_repo(agent_dir):
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"has_git": False,
|
||||
"versions": [],
|
||||
"latest": None,
|
||||
})
|
||||
|
||||
tags = list_tags(agent_dir)
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"has_git": True,
|
||||
"versions": tags,
|
||||
"latest": tags[0]["tag"] if tags else None,
|
||||
})
|
||||
|
||||
|
||||
async def handle_create_version(request: web.Request) -> web.Response:
|
||||
"""POST /api/agents/{agent_name}/versions — create a new version tag.
|
||||
|
||||
Body: {"version": "v1.0.0", "message": "..."} or {"bump": "patch", "message": "..."}
|
||||
"""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
body = await request.json()
|
||||
version = body.get("version")
|
||||
bump = body.get("bump")
|
||||
message = body.get("message", "")
|
||||
|
||||
if not version and not bump:
|
||||
return web.json_response(
|
||||
{"error": "Provide 'version' (e.g. 'v1.0.0') or 'bump' ('patch'|'minor'|'major')"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if bump:
|
||||
if bump not in ("patch", "minor", "major"):
|
||||
return web.json_response(
|
||||
{"error": f"Invalid bump type '{bump}'. Use 'patch', 'minor', or 'major'"},
|
||||
status=400,
|
||||
)
|
||||
version = next_version(agent_dir, bump)
|
||||
|
||||
# Auto-commit uncommitted changes before tagging
|
||||
if has_changes(agent_dir):
|
||||
commit_all(agent_dir, "pre-release commit")
|
||||
|
||||
try:
|
||||
create_tag(agent_dir, version, message)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=409)
|
||||
except RuntimeError as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# Find the tag info we just created
|
||||
tags = list_tags(agent_dir)
|
||||
tag_info = next((t for t in tags if t["tag"] == version), {"tag": version})
|
||||
|
||||
return web.json_response(tag_info, status=201)
|
||||
|
||||
|
||||
async def handle_get_version(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/versions/{version} — version detail."""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
version = request.match_info["version"]
|
||||
|
||||
# Find the tag
|
||||
tags = list_tags(agent_dir)
|
||||
tag_info = next((t for t in tags if t["tag"] == version), None)
|
||||
if tag_info is None:
|
||||
return web.json_response(
|
||||
{"error": f"Version '{version}' not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Get files at this version
|
||||
files = list_files_at_ref(agent_dir, version)
|
||||
|
||||
# Get diff from previous version
|
||||
tag_idx = next((i for i, t in enumerate(tags) if t["tag"] == version), -1)
|
||||
prev_diff = ""
|
||||
if tag_idx >= 0 and tag_idx + 1 < len(tags):
|
||||
prev_tag = tags[tag_idx + 1]["tag"]
|
||||
prev_diff = diff_between(agent_dir, prev_tag, version)
|
||||
|
||||
return web.json_response({
|
||||
**tag_info,
|
||||
"files": files,
|
||||
"diff_from_previous": prev_diff,
|
||||
})
|
||||
|
||||
|
||||
async def handle_delete_version(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/agents/{agent_name}/versions/{version} — delete a version tag."""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
version = request.match_info["version"]
|
||||
|
||||
try:
|
||||
delete_tag(agent_dir, version)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
|
||||
return web.json_response({"deleted": version})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_history(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/history — commit log.
|
||||
|
||||
Query params: ?limit=50&since=v1.0.0
|
||||
"""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
limit = int(request.query.get("limit", "50"))
|
||||
since = request.query.get("since", "")
|
||||
|
||||
commits = log(agent_dir, limit=limit, since_tag=since)
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"commits": commits,
|
||||
"total": len(commits),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File inspection at specific refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_list_files(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/files — list files at a ref.
|
||||
|
||||
Query params: ?ref=v1.0.0 (default: HEAD)
|
||||
"""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
ref = request.query.get("ref", "HEAD")
|
||||
files = list_files_at_ref(agent_dir, ref)
|
||||
|
||||
if not files and ref != "HEAD":
|
||||
return web.json_response(
|
||||
{"error": f"Ref '{ref}' not found or has no files"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"ref": ref,
|
||||
"files": files,
|
||||
})
|
||||
|
||||
|
||||
async def handle_get_file(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/files/{path:.+} — file content at a ref.
|
||||
|
||||
Query params: ?ref=v1.0.0 (default: HEAD)
|
||||
"""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
file_path = request.match_info["path"]
|
||||
ref = request.query.get("ref", "HEAD")
|
||||
|
||||
content = show_file(agent_dir, ref, file_path)
|
||||
if content is None:
|
||||
return web.json_response(
|
||||
{"error": f"File '{file_path}' not found at ref '{ref}'"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"ref": ref,
|
||||
"path": file_path,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_diff(request: web.Request) -> web.Response:
|
||||
"""GET /api/agents/{agent_name}/diff — diff between two refs.
|
||||
|
||||
Query params: ?from=v1.0.0&to=v1.1.0
|
||||
"""
|
||||
agent_dir, agent_name = _resolve_agent_dir(request)
|
||||
_require_git(agent_dir, agent_name)
|
||||
|
||||
ref_from = request.query.get("from")
|
||||
ref_to = request.query.get("to", "HEAD")
|
||||
|
||||
if not ref_from:
|
||||
return web.json_response(
|
||||
{"error": "'from' query parameter is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
diff = diff_between(agent_dir, ref_from, ref_to)
|
||||
return web.json_response({
|
||||
"agent_name": agent_name,
|
||||
"from": ref_from,
|
||||
"to": ref_to,
|
||||
"diff": diff,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register version management routes."""
|
||||
# Versions (tags)
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/versions", handle_list_versions
|
||||
)
|
||||
app.router.add_post(
|
||||
"/api/agents/{agent_name}/versions", handle_create_version
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/versions/{version}", handle_get_version
|
||||
)
|
||||
app.router.add_delete(
|
||||
"/api/agents/{agent_name}/versions/{version}", handle_delete_version
|
||||
)
|
||||
|
||||
# History
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/history", handle_history
|
||||
)
|
||||
|
||||
# Files at ref
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/files", handle_list_files
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/files/{path:.+}", handle_get_file
|
||||
)
|
||||
|
||||
# Diff
|
||||
app.router.add_get(
|
||||
"/api/agents/{agent_name}/diff", handle_diff
|
||||
)
|
||||
@@ -159,7 +159,13 @@ class SessionManager:
|
||||
|
||||
# Start queen with worker profile + lifecycle + monitoring tools
|
||||
worker_identity = (
|
||||
build_worker_profile(session.worker_runtime) if session.worker_runtime else None
|
||||
build_worker_profile(
|
||||
session.worker_runtime,
|
||||
agent_path=session.worker_path,
|
||||
storage_path=session.runner._storage_path if session.runner else None,
|
||||
)
|
||||
if session.worker_runtime
|
||||
else None
|
||||
)
|
||||
await self._start_queen(session, worker_identity=worker_identity, initial_prompt=initial_prompt)
|
||||
|
||||
@@ -596,7 +602,11 @@ class SessionManager:
|
||||
if node is None or not hasattr(node, "inject_event"):
|
||||
return
|
||||
|
||||
profile = build_worker_profile(session.worker_runtime)
|
||||
profile = build_worker_profile(
|
||||
session.worker_runtime,
|
||||
agent_path=session.worker_path,
|
||||
storage_path=session.runner._storage_path if session.runner else None,
|
||||
)
|
||||
await node.inject_event(f"[SYSTEM] Worker loaded.{profile}")
|
||||
|
||||
async def _emit_worker_loaded(self, session: Session) -> None:
|
||||
|
||||
@@ -60,17 +60,28 @@ class WorkerSessionAdapter:
|
||||
worker_path: Path | None = None
|
||||
|
||||
|
||||
def build_worker_profile(runtime: AgentRuntime) -> str:
|
||||
def build_worker_profile(
|
||||
runtime: AgentRuntime,
|
||||
*,
|
||||
agent_path: Path | str | None = None,
|
||||
storage_path: Path | str | None = None,
|
||||
) -> str:
|
||||
"""Build a worker capability profile from its graph/goal definition.
|
||||
|
||||
Injected into the queen's system prompt so it knows what the worker
|
||||
can and cannot do — enabling correct delegation decisions.
|
||||
|
||||
Args:
|
||||
runtime: The worker's AgentRuntime.
|
||||
agent_path: Path to the agent source directory (e.g. exports/my_agent).
|
||||
storage_path: Path to runtime storage (e.g. ~/.hive/agents/my_agent).
|
||||
"""
|
||||
graph = runtime.graph
|
||||
goal = runtime.goal
|
||||
agent_name = runtime.graph_id
|
||||
|
||||
lines = ["\n\n# Worker Profile"]
|
||||
lines.append(f"Agent: {runtime.graph_id}")
|
||||
lines.append(f"Agent: {agent_name}")
|
||||
lines.append(f"Goal: {goal.name}")
|
||||
if goal.description:
|
||||
lines.append(f"Description: {goal.description}")
|
||||
@@ -97,6 +108,26 @@ def build_worker_profile(runtime: AgentRuntime) -> str:
|
||||
if all_tools:
|
||||
lines.append(f"\n## Worker Tools\n{', '.join(sorted(all_tools))}")
|
||||
|
||||
# Operational context — paths and inspection guidance
|
||||
lines.append("\n## Worker Runtime Context")
|
||||
if agent_path:
|
||||
lines.append(f"- Source: {agent_path}")
|
||||
if storage_path:
|
||||
lines.append(f"- Storage: {storage_path}")
|
||||
lines.append(f"- Sessions: {storage_path}/sessions/")
|
||||
lines.append(f"- Logs: {{session_dir}}/logs/ (summary.json, details.jsonl, tool_logs.jsonl)")
|
||||
lines.append(f"- Checkpoints: {{session_dir}}/checkpoints/")
|
||||
|
||||
lines.append(f"\n## Inspecting This Worker")
|
||||
lines.append(f'- list_agent_sessions("{agent_name}") — find sessions')
|
||||
lines.append(f'- get_agent_session_state("{agent_name}", "{{session_id}}") — see status')
|
||||
lines.append(f'- get_agent_session_memory("{agent_name}", "{{session_id}}") — inspect data')
|
||||
lines.append(f'- list_agent_checkpoints("{agent_name}", "{{session_id}}") — trace execution')
|
||||
lines.append(f'- get_agent_checkpoint("{agent_name}", "{{session_id}}") — load checkpoint')
|
||||
if agent_path:
|
||||
lines.append(f'- read_file("{agent_path}/agent.py") — read agent source')
|
||||
lines.append(f'- read_file("{agent_path}/nodes/__init__.py") — read node definitions')
|
||||
|
||||
lines.append("\nStatus at session start: idle (not started).")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -606,7 +606,11 @@ class AdenTUI(App):
|
||||
# Build worker profile for queen's system prompt.
|
||||
from framework.tools.queen_lifecycle_tools import build_worker_profile
|
||||
|
||||
worker_identity = build_worker_profile(self.runtime)
|
||||
worker_identity = build_worker_profile(
|
||||
self.runtime,
|
||||
agent_path=getattr(self, '_runner', None) and self._runner.agent_path,
|
||||
storage_path=storage_path,
|
||||
)
|
||||
|
||||
# Adjust queen graph: filter tools to what's registered and
|
||||
# append worker identity to the system prompt.
|
||||
|
||||
@@ -38,6 +38,7 @@ class AgentEntry:
|
||||
tool_count: int = 0
|
||||
tags: list[str] = field(default_factory=list)
|
||||
last_active: str | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
def _get_last_active(agent_name: str) -> str | None:
|
||||
@@ -157,6 +158,16 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get version from git if available
|
||||
version = None
|
||||
try:
|
||||
from framework.utils.git import is_git_repo, latest_version
|
||||
|
||||
if is_git_repo(path):
|
||||
version = latest_version(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries.append(
|
||||
AgentEntry(
|
||||
path=path,
|
||||
@@ -168,6 +179,7 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
tool_count=tool_count,
|
||||
tags=tags,
|
||||
last_active=_get_last_active(path.name),
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
if entries:
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Git utilities for per-agent version management.
|
||||
|
||||
Each agent in exports/{agent_name}/ can have its own local git repository
|
||||
for tracking changes, versioning with semver tags, and inspecting history.
|
||||
|
||||
All operations use subprocess — no external git library dependency.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Semver pattern: v{major}.{minor}.{patch} with optional pre-release
|
||||
_SEMVER_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
_AGENT_GITIGNORE = """\
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def git_run(
|
||||
repo_dir: Path,
|
||||
*args: str,
|
||||
timeout: int = 30,
|
||||
check: bool = False,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Execute a git command inside *repo_dir*.
|
||||
|
||||
Returns the CompletedProcess. Raises RuntimeError if git is not installed.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(repo_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=check,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"git is not installed or not on PATH. "
|
||||
"Agent versioning requires git."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("git command timed out: git %s (in %s)", " ".join(args), repo_dir)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_git_repo(agent_dir: Path) -> bool:
|
||||
"""Return True if *agent_dir* contains a .git directory."""
|
||||
try:
|
||||
return (agent_dir / ".git").is_dir()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def init_repo(agent_dir: Path) -> None:
|
||||
"""Idempotent git-init for an agent directory.
|
||||
|
||||
Creates .gitignore, and if files already exist, makes an initial commit.
|
||||
"""
|
||||
if is_git_repo(agent_dir):
|
||||
return
|
||||
|
||||
git_run(agent_dir, "init", check=True)
|
||||
|
||||
# Write .gitignore
|
||||
gitignore = agent_dir / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text(_AGENT_GITIGNORE)
|
||||
|
||||
# If there are existing files, create an initial commit
|
||||
result = git_run(agent_dir, "status", "--porcelain")
|
||||
if result.stdout.strip():
|
||||
git_run(agent_dir, "add", "-A")
|
||||
git_run(agent_dir, "commit", "-m", "Initial commit")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def has_changes(agent_dir: Path) -> bool:
|
||||
"""Return True if the working tree has uncommitted changes."""
|
||||
if not is_git_repo(agent_dir):
|
||||
return False
|
||||
result = git_run(agent_dir, "status", "--porcelain")
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def commit_all(agent_dir: Path, message: str) -> str | None:
|
||||
"""Stage all changes and commit.
|
||||
|
||||
Returns the commit SHA on success, or None if there was nothing to commit.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
logger.warning("commit_all called on non-git dir: %s", agent_dir)
|
||||
return None
|
||||
|
||||
# Check for changes first
|
||||
result = git_run(agent_dir, "status", "--porcelain")
|
||||
if not result.stdout.strip():
|
||||
return None
|
||||
|
||||
git_run(agent_dir, "add", "-A")
|
||||
|
||||
result = git_run(agent_dir, "commit", "-m", message)
|
||||
if result.returncode != 0:
|
||||
# Retry once on lock contention
|
||||
if "index.lock" in (result.stderr or ""):
|
||||
time.sleep(0.5)
|
||||
result = git_run(agent_dir, "commit", "-m", message)
|
||||
if result.returncode != 0:
|
||||
logger.warning("git commit failed: %s", result.stderr)
|
||||
return None
|
||||
|
||||
sha_result = git_run(agent_dir, "rev-parse", "HEAD")
|
||||
return sha_result.stdout.strip() or None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag / version operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_semver(tag: str) -> tuple[int, int, int] | None:
|
||||
"""Parse a semver tag like 'v1.2.3' into (major, minor, patch).
|
||||
|
||||
Returns None if the tag doesn't match semver format.
|
||||
"""
|
||||
m = _SEMVER_RE.match(tag)
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
|
||||
|
||||
def create_tag(
|
||||
agent_dir: Path,
|
||||
version: str,
|
||||
message: str = "",
|
||||
) -> None:
|
||||
"""Create an annotated git tag with semver validation.
|
||||
|
||||
Raises ValueError if the version format is invalid or already exists.
|
||||
"""
|
||||
if parse_semver(version) is None:
|
||||
raise ValueError(
|
||||
f"Invalid semver tag '{version}'. Expected format: v{{major}}.{{minor}}.{{patch}} (e.g. v1.0.0)"
|
||||
)
|
||||
if tag_exists(agent_dir, version):
|
||||
raise ValueError(f"Tag '{version}' already exists")
|
||||
|
||||
msg = message or version
|
||||
result = git_run(agent_dir, "tag", "-a", version, "-m", msg)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create tag: {result.stderr}")
|
||||
|
||||
|
||||
def delete_tag(agent_dir: Path, tag: str) -> None:
|
||||
"""Delete a git tag."""
|
||||
result = git_run(agent_dir, "tag", "-d", tag)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag '{tag}' not found or could not be deleted")
|
||||
|
||||
|
||||
def tag_exists(agent_dir: Path, tag: str) -> bool:
|
||||
"""Check if a tag exists."""
|
||||
result = git_run(agent_dir, "tag", "-l", tag)
|
||||
return tag in result.stdout.strip().split("\n")
|
||||
|
||||
|
||||
def list_tags(agent_dir: Path) -> list[dict]:
|
||||
"""List all tags sorted by semver descending.
|
||||
|
||||
Returns list of {tag, sha, date, message}.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return []
|
||||
|
||||
result = git_run(
|
||||
agent_dir,
|
||||
"tag",
|
||||
"-l",
|
||||
"--format=%(refname:short)\t%(objectname:short)\t%(creatordate:iso-strict)\t%(contents:subject)",
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return []
|
||||
|
||||
tags = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t", 3)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
tag_name = parts[0]
|
||||
# Only include semver tags
|
||||
if parse_semver(tag_name) is None:
|
||||
continue
|
||||
tags.append({
|
||||
"tag": tag_name,
|
||||
"sha": parts[1] if len(parts) > 1 else "",
|
||||
"date": parts[2] if len(parts) > 2 else "",
|
||||
"message": parts[3] if len(parts) > 3 else "",
|
||||
})
|
||||
|
||||
# Sort by semver descending
|
||||
tags.sort(key=lambda t: parse_semver(t["tag"]) or (0, 0, 0), reverse=True)
|
||||
return tags
|
||||
|
||||
|
||||
def latest_version(agent_dir: Path) -> str | None:
|
||||
"""Return the latest semver tag, or None if no tags exist."""
|
||||
tags = list_tags(agent_dir)
|
||||
return tags[0]["tag"] if tags else None
|
||||
|
||||
|
||||
def next_version(agent_dir: Path, bump: str = "patch") -> str:
|
||||
"""Compute the next semver version based on the latest tag.
|
||||
|
||||
Args:
|
||||
bump: One of 'patch', 'minor', 'major'.
|
||||
|
||||
Returns:
|
||||
The next version string (e.g. 'v1.0.1').
|
||||
"""
|
||||
current = latest_version(agent_dir)
|
||||
if current is None:
|
||||
return "v0.1.0"
|
||||
|
||||
parsed = parse_semver(current)
|
||||
if parsed is None:
|
||||
return "v0.1.0"
|
||||
|
||||
major, minor, patch = parsed
|
||||
if bump == "major":
|
||||
return f"v{major + 1}.0.0"
|
||||
elif bump == "minor":
|
||||
return f"v{major}.{minor + 1}.0"
|
||||
else: # patch
|
||||
return f"v{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_head(agent_dir: Path) -> dict | None:
|
||||
"""Get current HEAD commit info.
|
||||
|
||||
Returns {sha, short_sha, message, date, author} or None.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return None
|
||||
|
||||
result = git_run(
|
||||
agent_dir,
|
||||
"log",
|
||||
"-1",
|
||||
"--format=%H\t%h\t%s\t%aI\t%an",
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
|
||||
parts = result.stdout.strip().split("\t", 4)
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
|
||||
return {
|
||||
"sha": parts[0],
|
||||
"short_sha": parts[1],
|
||||
"message": parts[2],
|
||||
"date": parts[3],
|
||||
"author": parts[4],
|
||||
}
|
||||
|
||||
|
||||
def log(
|
||||
agent_dir: Path,
|
||||
limit: int = 50,
|
||||
since_tag: str = "",
|
||||
) -> list[dict]:
|
||||
"""Get commit log.
|
||||
|
||||
Returns list of {sha, short_sha, message, date, author, tags}.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return []
|
||||
|
||||
args = [
|
||||
"log",
|
||||
f"--max-count={limit}",
|
||||
"--format=%H\t%h\t%s\t%aI\t%an\t%D",
|
||||
]
|
||||
if since_tag:
|
||||
args.append(f"{since_tag}..HEAD")
|
||||
|
||||
result = git_run(agent_dir, *args)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return []
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t", 5)
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
|
||||
# Extract tags from ref decoration
|
||||
refs = parts[5] if len(parts) > 5 else ""
|
||||
tags = []
|
||||
if refs:
|
||||
for ref in refs.split(","):
|
||||
ref = ref.strip()
|
||||
if ref.startswith("tag: "):
|
||||
tags.append(ref[5:])
|
||||
|
||||
commits.append({
|
||||
"sha": parts[0],
|
||||
"short_sha": parts[1],
|
||||
"message": parts[2],
|
||||
"date": parts[3],
|
||||
"author": parts[4],
|
||||
"tags": tags,
|
||||
})
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File inspection at specific refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def show_file(agent_dir: Path, ref: str, file_path: str) -> str | None:
|
||||
"""Read file content at a specific ref (tag, commit SHA, etc.).
|
||||
|
||||
Uses `git show ref:path`. Returns None if the file doesn't exist at that ref.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return None
|
||||
|
||||
result = git_run(agent_dir, "show", f"{ref}:{file_path}")
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout
|
||||
|
||||
|
||||
def list_files_at_ref(agent_dir: Path, ref: str = "HEAD") -> list[str]:
|
||||
"""List all files tracked at a specific ref.
|
||||
|
||||
Returns a list of relative file paths.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return []
|
||||
|
||||
result = git_run(agent_dir, "ls-tree", "-r", "--name-only", ref)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
return [f for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
|
||||
|
||||
def diff_between(agent_dir: Path, ref_a: str, ref_b: str) -> str:
|
||||
"""Get unified diff between two refs.
|
||||
|
||||
Returns the diff output as a string.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
return ""
|
||||
|
||||
result = git_run(agent_dir, "diff", ref_a, ref_b)
|
||||
return result.stdout if result.returncode == 0 else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version export / extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def export_at_ref(
|
||||
agent_dir: Path,
|
||||
ref: str,
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Extract the agent files at a specific ref to a directory.
|
||||
|
||||
Uses `git archive` for a clean extraction without .git metadata.
|
||||
If output_dir is None, uses ~/.hive/versions/{agent_name}/{ref}/.
|
||||
|
||||
Returns the output directory path.
|
||||
"""
|
||||
if not is_git_repo(agent_dir):
|
||||
raise ValueError(f"Not a git repo: {agent_dir}")
|
||||
|
||||
agent_name = agent_dir.name
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = Path.home() / ".hive" / "versions" / agent_name / ref
|
||||
|
||||
# Check if already extracted (cache hit)
|
||||
if output_dir.exists() and any(output_dir.iterdir()):
|
||||
# Verify the ref matches by checking a marker file
|
||||
marker = output_dir / ".version_ref"
|
||||
if marker.exists() and marker.read_text().strip() == ref:
|
||||
return output_dir
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Extract via git archive piped to tar (binary mode, not text)
|
||||
archive = subprocess.run(
|
||||
["git", "archive", ref],
|
||||
cwd=str(agent_dir),
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if archive.returncode != 0:
|
||||
raise ValueError(f"Failed to archive ref '{ref}': {archive.stderr.decode()}")
|
||||
|
||||
extract = subprocess.run(
|
||||
["tar", "xf", "-"],
|
||||
cwd=str(output_dir),
|
||||
input=archive.stdout,
|
||||
capture_output=True,
|
||||
)
|
||||
if extract.returncode != 0:
|
||||
raise RuntimeError(f"Failed to extract archive: {extract.stderr.decode()}")
|
||||
|
||||
# Write marker for cache validation
|
||||
(output_dir / ".version_ref").write_text(ref)
|
||||
|
||||
return output_dir
|
||||
@@ -39,6 +39,7 @@ export interface DiscoverEntry {
|
||||
tags: string[];
|
||||
last_active: string | null;
|
||||
is_loaded: boolean;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
/** Keyed by category name. */
|
||||
|
||||
@@ -117,6 +117,88 @@ PROJECT_ROOT: str = ""
|
||||
SNAPSHOT_DIR: str = ""
|
||||
|
||||
|
||||
# ── Auto-commit tracking for agent versioning ─────────────────────────────
|
||||
|
||||
_dirty_agent_dirs: set[str] = set()
|
||||
|
||||
|
||||
def _is_agent_dir(resolved_path: str) -> str | None:
|
||||
"""If *resolved_path* is under exports/{agent}/, return the agent dir."""
|
||||
exports_dir = os.path.join(PROJECT_ROOT, "exports")
|
||||
if not resolved_path.startswith(exports_dir + os.sep):
|
||||
return None
|
||||
rel = os.path.relpath(resolved_path, exports_dir)
|
||||
agent_name = rel.split(os.sep)[0]
|
||||
return os.path.join(exports_dir, agent_name)
|
||||
|
||||
|
||||
def _track_agent_write(resolved_path: str) -> None:
|
||||
"""Mark an agent dir as dirty after a write/edit."""
|
||||
agent_dir = _is_agent_dir(resolved_path)
|
||||
if agent_dir:
|
||||
_dirty_agent_dirs.add(agent_dir)
|
||||
|
||||
|
||||
def _flush_agent_commits() -> None:
|
||||
"""Commit all pending agent directory changes (called before reads)."""
|
||||
if not _dirty_agent_dirs:
|
||||
return
|
||||
for agent_dir in list(_dirty_agent_dirs):
|
||||
if os.path.isdir(agent_dir):
|
||||
_git_auto_commit(agent_dir)
|
||||
_dirty_agent_dirs.clear()
|
||||
|
||||
|
||||
def _git_auto_commit(repo_dir: str) -> str | None:
|
||||
"""Init repo if needed and commit all changes with an auto message."""
|
||||
try:
|
||||
# Init if needed
|
||||
if not os.path.isdir(os.path.join(repo_dir, ".git")):
|
||||
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True)
|
||||
gitignore_path = os.path.join(repo_dir, ".gitignore")
|
||||
if not os.path.exists(gitignore_path):
|
||||
with open(gitignore_path, "w") as f:
|
||||
f.write("__pycache__/\n*.pyc\n*.pyo\n.DS_Store\n")
|
||||
|
||||
# Check for changes
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
return None
|
||||
|
||||
# Auto-generate message from changed files
|
||||
lines = [l for l in result.stdout.strip().split("\n") if l.strip()]
|
||||
changed = [l[3:].strip() for l in lines]
|
||||
message = "Auto: " + ", ".join(changed[:5])
|
||||
if len(changed) > 5:
|
||||
message += f" (+{len(changed) - 5} more)"
|
||||
|
||||
# Stage and commit
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
sha = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return sha.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
logger.warning("git not found — agent auto-commit skipped")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("agent auto-commit failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ── Path resolution ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -319,6 +401,7 @@ def read_file(path: str, offset: int = 1, limit: int = 0) -> str:
|
||||
Returns:
|
||||
File contents with line numbers, or error message
|
||||
"""
|
||||
_flush_agent_commits()
|
||||
resolved = _resolve_path(path)
|
||||
|
||||
if os.path.isdir(resolved):
|
||||
@@ -408,6 +491,7 @@ def write_file(path: str, content: str) -> str:
|
||||
|
||||
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
||||
action = "Updated" if existed else "Created"
|
||||
_track_agent_write(resolved)
|
||||
return f"{action} {path} ({len(content):,} bytes, {line_count} lines)"
|
||||
except Exception as e:
|
||||
return f"Error writing file: {e}"
|
||||
@@ -502,6 +586,7 @@ def edit_file(path: str, old_text: str, new_text: str, replace_all: bool = False
|
||||
result = f"Replaced {count} occurrence(s) in {path}{match_info}"
|
||||
if diff:
|
||||
result += f"\n\n{diff}"
|
||||
_track_agent_write(resolved)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error editing file: {e}"
|
||||
@@ -521,6 +606,7 @@ def list_directory(path: str = ".", recursive: bool = False) -> str:
|
||||
Returns:
|
||||
Sorted directory listing with / suffix for directories
|
||||
"""
|
||||
_flush_agent_commits()
|
||||
resolved = _resolve_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
return f"Error: Directory not found: {path}"
|
||||
@@ -579,6 +665,7 @@ def search_files(pattern: str, path: str = ".", include: str = "") -> str:
|
||||
Returns:
|
||||
Matching lines grouped by file with line numbers
|
||||
"""
|
||||
_flush_agent_commits()
|
||||
resolved = _resolve_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
return f"Error: Directory not found: {path}"
|
||||
@@ -668,6 +755,7 @@ def run_command(command: str, cwd: str = "", timeout: int = 120) -> str:
|
||||
Returns:
|
||||
Combined stdout/stderr with exit code
|
||||
"""
|
||||
_flush_agent_commits()
|
||||
timeout = min(timeout, 300) # Cap at 5 minutes
|
||||
work_dir = _resolve_path(cwd) if cwd else PROJECT_ROOT
|
||||
|
||||
|
||||
Reference in New Issue
Block a user