Skip to content

Commit daaf643

Browse files
abrichrclaude
andauthored
feat: add openadapt doctor command and fix audit/wormhole bugs (#8)
Add diagnostic command that checks all dependencies and configuration: - Python version, data directory writability, database connectivity - Core deps: openadapt-capture, openadapt-privacy, psutil - Optional deps: boto3, huggingface_hub, magic-wormhole - Backend credentials (S3 keys, HF token) when configured - Shows actionable install instructions for missing dependencies Bug fixes: - audit.py: create parent directory before writing log entries - wormhole.py: use subprocess.run() instead of Popen to await completion and capture exit code properly Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e41ae0b commit daaf643

4 files changed

Lines changed: 139 additions & 11 deletions

File tree

engine/audit.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,7 @@ def log(self, event: str, **data: Any) -> None:
5858
**data,
5959
}
6060

61-
# TODO: Ensure log_path parent directory exists
62-
# TODO: Append JSONL entry atomically
61+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
6362
line = json.dumps(entry)
6463
with open(self.log_path, "a") as f:
6564
f.write(line + "\n")

engine/backends/wormhole.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,27 +42,26 @@ def upload(self, archive_path: Path, metadata: dict) -> UploadResult:
4242
UploadResult with the wormhole code in metadata.
4343
"""
4444
try:
45-
proc = subprocess.Popen(
45+
result = subprocess.run(
4646
["wormhole", "send", str(archive_path)],
47-
stdout=subprocess.PIPE,
48-
stderr=subprocess.STDOUT,
47+
capture_output=True,
4948
text=True,
49+
timeout=3600,
5050
)
51-
# Read the wormhole code from output
51+
# Extract the wormhole code from output
5252
code = ""
53-
for line in proc.stdout or []:
54-
line = line.strip()
53+
for line in (result.stdout + result.stderr).splitlines():
5554
if "wormhole receive" in line:
56-
# Extract the code from "wormhole receive <code>"
5755
parts = line.split()
5856
code = parts[-1] if parts else ""
5957
break
6058

6159
return UploadResult(
62-
success=True,
60+
success=result.returncode == 0,
6361
remote_url="",
6462
bytes_sent=archive_path.stat().st_size,
65-
metadata={"wormhole_code": code, "pid": proc.pid},
63+
metadata={"wormhole_code": code},
64+
error=result.stderr if result.returncode != 0 else None,
6665
)
6766
except FileNotFoundError:
6867
return UploadResult(

engine/cli.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
openadapt health
1818
openadapt cleanup
1919
openadapt config
20+
openadapt doctor
2021
"""
2122

2223
from __future__ import annotations
2324

2425
import argparse
26+
import os
2527
import sys
2628
import types
2729
from pathlib import Path
@@ -287,6 +289,108 @@ def cmd_config(args: argparse.Namespace, engine: types.SimpleNamespace) -> None:
287289
print(engine.config.model_dump_json(indent=2))
288290

289291

292+
def cmd_doctor(args: argparse.Namespace, engine: types.SimpleNamespace) -> None:
293+
"""Check system dependencies and configuration."""
294+
from engine import __version__
295+
296+
checks: list[tuple[str, bool, str]] = []
297+
298+
# Engine version
299+
checks.append(("Engine version", True, f"v{__version__}"))
300+
301+
# Python version
302+
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
303+
py_ok = sys.version_info >= (3, 11)
304+
checks.append(("Python", py_ok, py_ver if py_ok else f"{py_ver} (need >=3.11)"))
305+
306+
# Data directory
307+
data_ok = engine.config.data_dir.exists() and os.access(engine.config.data_dir, os.W_OK)
308+
checks.append(("Data directory", data_ok, str(engine.config.data_dir)))
309+
310+
# Database
311+
try:
312+
engine.db.conn.execute("SELECT 1").fetchone()
313+
checks.append(("Database (SQLite)", True, "connected"))
314+
except Exception as e:
315+
checks.append(("Database (SQLite)", False, str(e)))
316+
317+
# openadapt-capture
318+
try:
319+
import openadapt_capture
320+
ver = getattr(openadapt_capture, "__version__", "installed")
321+
checks.append(("openadapt-capture", True, ver))
322+
except ImportError:
323+
checks.append(("openadapt-capture", False, "not installed (recording disabled)"))
324+
325+
# openadapt-privacy
326+
try:
327+
import openadapt_privacy
328+
ver = getattr(openadapt_privacy, "__version__", "installed")
329+
checks.append(("openadapt-privacy", True, ver))
330+
except ImportError:
331+
checks.append(("openadapt-privacy", False, "not installed (advanced scrubbing disabled)"))
332+
333+
# psutil
334+
try:
335+
import psutil
336+
checks.append(("psutil", True, psutil.__version__))
337+
except ImportError:
338+
checks.append(("psutil", False, "not installed (health monitoring disabled)"))
339+
340+
# boto3 (optional)
341+
try:
342+
import boto3
343+
checks.append(("boto3 (S3 backend)", True, boto3.__version__))
344+
except ImportError:
345+
checks.append(("boto3 (S3 backend)", False,
346+
"not installed (pip install openadapt-desktop[enterprise])"))
347+
348+
# huggingface_hub (optional)
349+
try:
350+
import huggingface_hub
351+
checks.append(("huggingface_hub (HF backend)", True, huggingface_hub.__version__))
352+
except ImportError:
353+
checks.append(("huggingface_hub (HF backend)", False,
354+
"not installed (pip install openadapt-desktop[community])"))
355+
356+
# magic-wormhole
357+
import shutil
358+
wormhole_path = shutil.which("wormhole")
359+
checks.append((
360+
"magic-wormhole (P2P backend)",
361+
wormhole_path is not None,
362+
wormhole_path or "not found (pip install magic-wormhole)",
363+
))
364+
365+
# Storage mode
366+
checks.append(("Storage mode", True, engine.config.storage_mode))
367+
368+
# S3 credentials (if configured)
369+
if engine.config.s3_bucket:
370+
has_creds = bool(engine.config.s3_access_key_id and engine.config.s3_secret_access_key)
371+
detail = f"bucket={engine.config.s3_bucket}" if has_creds else "bucket set but keys missing"
372+
checks.append(("S3 credentials", has_creds, detail))
373+
374+
# HF token (if configured)
375+
if engine.config.hf_token:
376+
checks.append(("HuggingFace token", True, f"repo={engine.config.hf_repo}"))
377+
378+
# Print results
379+
print("OpenAdapt Doctor")
380+
print("=" * 60)
381+
ok_count = sum(1 for _, ok, _ in checks if ok)
382+
for name, ok, detail in checks:
383+
marker = "OK" if ok else "!!"
384+
print(f" [{marker}] {name}: {detail}")
385+
386+
print("=" * 60)
387+
total = len(checks)
388+
print(f"{ok_count}/{total} checks passed")
389+
390+
if ok_count < total:
391+
print("\nRun 'pip install openadapt-desktop[full]' to install all optional dependencies.")
392+
393+
290394
_COMMANDS = {
291395
"record": cmd_record,
292396
"list": cmd_list,
@@ -301,6 +405,7 @@ def cmd_config(args: argparse.Namespace, engine: types.SimpleNamespace) -> None:
301405
"health": cmd_health,
302406
"cleanup": cmd_cleanup,
303407
"config": cmd_config,
408+
"doctor": cmd_doctor,
304409
}
305410

306411

@@ -359,6 +464,9 @@ def main(argv: list[str] | None = None) -> None:
359464
# config
360465
subparsers.add_parser("config", help="Show configuration")
361466

467+
# doctor
468+
subparsers.add_parser("doctor", help="Check dependencies and configuration")
469+
362470
args = parser.parse_args(argv)
363471

364472
config = EngineConfig()

tests/test_engine/test_cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,25 @@ def test_backends_shows_wormhole(self, cli_config: EngineConfig, capsys) -> None
9090
main(["backends"])
9191
captured = capsys.readouterr()
9292
assert "wormhole" in captured.out
93+
94+
def test_doctor_runs(self, cli_config: EngineConfig, capsys) -> None:
95+
"""Doctor command should show checks and pass count."""
96+
with patch("engine.cli.EngineConfig", return_value=cli_config):
97+
main(["doctor"])
98+
captured = capsys.readouterr()
99+
assert "OpenAdapt Doctor" in captured.out
100+
assert "checks passed" in captured.out
101+
102+
def test_doctor_checks_python(self, cli_config: EngineConfig, capsys) -> None:
103+
"""Doctor should verify Python version."""
104+
with patch("engine.cli.EngineConfig", return_value=cli_config):
105+
main(["doctor"])
106+
captured = capsys.readouterr()
107+
assert "[OK] Python" in captured.out
108+
109+
def test_doctor_checks_database(self, cli_config: EngineConfig, capsys) -> None:
110+
"""Doctor should verify database connectivity."""
111+
with patch("engine.cli.EngineConfig", return_value=cli_config):
112+
main(["doctor"])
113+
captured = capsys.readouterr()
114+
assert "[OK] Database (SQLite)" in captured.out

0 commit comments

Comments
 (0)