Coverage for src / openenv / docker / runtime.py: 96.39%
65 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""Runtime inspection helpers for running bot containers."""
3from __future__ import annotations
5import json
6import subprocess
7from dataclasses import dataclass, field
9from openenv.core.errors import CommandError
12SNAPSHOT_SCRIPT_TEMPLATE = """\
13import json
14from pathlib import Path
16skills_root = Path({workspace!r}) / "skills"
17payload = []
18if skills_root.exists():
19 for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()):
20 files = {{}}
21 for file_path in sorted(path for path in skill_dir.rglob("*") if path.is_file()):
22 relative_path = file_path.relative_to(skill_dir).as_posix()
23 files[relative_path] = file_path.read_text(encoding="utf-8", errors="replace")
24 payload.append({{"name": skill_dir.name, "files": files}})
25print(json.dumps(payload, sort_keys=True))
26"""
29@dataclass(slots=True)
30class CapturedSkill:
31 """A skill snapshot collected from a running container."""
33 name: str
34 description: str
35 content: str
36 source: str | None = None
37 assets: dict[str, str] = field(default_factory=dict)
40def list_running_container_names() -> set[str]:
41 """Return the names of running Docker containers."""
42 stdout = _run_command(
43 ["docker", "ps", "--format", "{{.Names}}"],
44 unavailable_message=(
45 "Docker is not available on PATH. Install Docker or Docker Desktop "
46 "before listing running bots."
47 ),
48 failure_message="Failed to list running Docker containers.",
49 )
50 return {line.strip() for line in stdout.splitlines() if line.strip()}
53def fetch_container_logs(container_name: str, *, tail: int = 120) -> str:
54 """Return recent logs for a running container."""
55 return _run_command(
56 ["docker", "logs", "--tail", str(tail), container_name],
57 unavailable_message=(
58 "Docker is not available on PATH. Install Docker or Docker Desktop "
59 "before reading bot logs."
60 ),
61 failure_message=f"Failed to read logs for container `{container_name}`.",
62 )
65def snapshot_installed_skills(
66 container_name: str,
67 *,
68 workspace: str,
69) -> list[CapturedSkill]:
70 """Snapshot installed skills from a running bot container."""
71 stdout = _run_command(
72 [
73 "docker",
74 "exec",
75 container_name,
76 "python",
77 "-c",
78 SNAPSHOT_SCRIPT_TEMPLATE.format(workspace=workspace),
79 ],
80 unavailable_message=(
81 "Docker is not available on PATH. Install Docker or Docker Desktop "
82 "before creating a skill snapshot."
83 ),
84 failure_message=f"Failed to snapshot installed skills for `{container_name}`.",
85 )
86 try:
87 payload = json.loads(stdout.strip() or "[]")
88 except json.JSONDecodeError as exc:
89 raise CommandError(
90 f"Container `{container_name}` returned an unreadable skill snapshot payload."
91 ) from exc
92 if not isinstance(payload, list):
93 raise CommandError(
94 f"Container `{container_name}` returned an invalid skill snapshot payload."
95 )
97 snapshots: list[CapturedSkill] = []
98 for item in payload:
99 if not isinstance(item, dict):
100 continue
101 name = str(item.get("name", "")).strip()
102 files = item.get("files", {})
103 if not name or not isinstance(files, dict):
104 continue
105 skill_md = files.get("SKILL.md")
106 if not isinstance(skill_md, str) or not skill_md.strip():
107 continue
108 assets = {
109 path: content
110 for path, content in sorted(files.items())
111 if path != "SKILL.md" and isinstance(path, str) and isinstance(content, str)
112 }
113 frontmatter = _parse_frontmatter(skill_md)
114 snapshots.append(
115 CapturedSkill(
116 name=name,
117 description=frontmatter.get(
118 "description",
119 f"Snapshotted skill from running container {container_name}",
120 ),
121 content=skill_md,
122 source=frontmatter.get("source"),
123 assets=assets,
124 )
125 )
126 return snapshots
129def _run_command(
130 command: list[str],
131 *,
132 unavailable_message: str,
133 failure_message: str,
134) -> str:
135 """Execute a Docker command and normalize transport and process failures."""
136 try:
137 completed = subprocess.run(
138 command,
139 check=True,
140 capture_output=True,
141 text=True,
142 )
143 except OSError as exc:
144 raise CommandError(unavailable_message) from exc
145 except subprocess.CalledProcessError as exc:
146 stderr = (exc.stderr or "").strip()
147 details = f" Docker said: {stderr}" if stderr else ""
148 raise CommandError(f"{failure_message}{details}", exit_code=exc.returncode) from exc
149 return completed.stdout
152def _parse_frontmatter(content: str) -> dict[str, str]:
153 """Extract a simple YAML-frontmatter key/value mapping from `SKILL.md` content."""
154 lines = content.splitlines()
155 if not lines or lines[0].strip() != "---":
156 return {}
157 fields: dict[str, str] = {}
158 for line in lines[1:]: 158 ↛ 166line 158 didn't jump to line 166 because the loop on line 158 didn't complete
159 stripped = line.strip()
160 if stripped == "---":
161 break
162 if ":" not in line: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 continue
164 key, value = line.split(":", 1)
165 fields[key.strip().lower()] = value.strip()
166 return fields