Compare commits

...

1 Commits

Author SHA1 Message Date
Timothy ddefd90e4e feat: agent versioning 2026-02-25 12:00:30 -08:00
11 changed files with 963 additions and 5 deletions
@@ -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 ""),
},
+2
View File
@@ -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 /
+1
View File
@@ -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
]
+339
View File
@@ -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
)
+12 -2
View File
@@ -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:
+33 -2
View File
@@ -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)
+5 -1
View File
@@ -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:
+454
View File
@@ -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
+1
View File
@@ -39,6 +39,7 @@ export interface DiscoverEntry {
tags: string[];
last_active: string | null;
is_loaded: boolean;
version?: string | null;
}
/** Keyed by category name. */
+88
View File
@@ -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