feat: enhance service status reporting with JSON output and add related tests

This commit is contained in:
2026-05-20 15:29:21 -06:00
parent b21889c4ab
commit 8c58210c0c
4 changed files with 35 additions and 4 deletions

View File

@@ -51,13 +51,14 @@ The first version can shell out to:
```bash ```bash
python3 scripts/aiw/services.py status --profile fidelity python3 scripts/aiw/services.py status --profile fidelity
python3 scripts/aiw/services.py status --profile fidelity --json
python3 scripts/aiw/services.py doctor --profile fidelity --json python3 scripts/aiw/services.py doctor --profile fidelity --json
python3 scripts/aiw/services.py start --profile fidelity python3 scripts/aiw/services.py start --profile fidelity
python3 scripts/aiw/services.py stop --profile fidelity python3 scripts/aiw/services.py stop --profile fidelity
python3 scripts/aiw/services.py restart aiw-context-mcp --profile fidelity python3 scripts/aiw/services.py restart aiw-context-mcp --profile fidelity
``` ```
Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not parse terminal text except `doctor --json`. Use `status --json` for frequent UI refreshes and `doctor --json` for explicit diagnostics. Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not parse terminal text.
## Production-Ready Rules ## Production-Ready Rules
@@ -70,7 +71,7 @@ Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not
## Implementation Phases ## Implementation Phases
1. CLI-backed SwiftUI menu bar app using `doctor --json` for status. 1. CLI-backed SwiftUI menu bar app using `status --json` for live status and `doctor --json` for diagnostics.
2. Add profile selector and service action buttons. 2. Add profile selector and service action buttons.
3. Add LaunchAgent support for start at login. 3. Add LaunchAgent support for start at login.
4. Replace shell parsing with a daemon API if daily use proves stable. 4. Replace shell parsing with a daemon API if daily use proves stable.

View File

@@ -8,6 +8,7 @@ It reads `profiles/<profile>/services.json`, starts/stops enabled services, reco
```bash ```bash
python3 scripts/aiw/services.py status --profile fidelity python3 scripts/aiw/services.py status --profile fidelity
python3 scripts/aiw/services.py status --profile fidelity --json
python3 scripts/aiw/services.py doctor --profile fidelity python3 scripts/aiw/services.py doctor --profile fidelity
python3 scripts/aiw/services.py doctor --profile fidelity --json python3 scripts/aiw/services.py doctor --profile fidelity --json
python3 scripts/aiw/services.py start --profile fidelity python3 scripts/aiw/services.py start --profile fidelity

View File

@@ -363,6 +363,16 @@ def status_service(profile: str, ref: ServiceRef) -> None:
print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})") print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})")
def status_report(profile: str, refs: list[ServiceRef]) -> dict[str, Any]:
"""Return lightweight machine-readable live service state."""
return {
"profile": profile,
"workspace": str(ROOT),
"runtime": str(RUNTIME_DIR),
"services": [service_status(profile, ref) for ref in refs],
}
def tail_log(profile: str, service: str, lines: int) -> None: def tail_log(profile: str, service: str, lines: int) -> None:
path = log_path(profile, service) path = log_path(profile, service)
if not path.is_file(): if not path.is_file():
@@ -480,6 +490,9 @@ def main() -> None:
for ref in refs: for ref in refs:
start_service(args.profile, ref, manifest, started) start_service(args.profile, ref, manifest, started)
elif args.action == "status": elif args.action == "status":
if args.json:
print(json.dumps(status_report(args.profile, refs), ensure_ascii=False, indent=2, sort_keys=True))
else:
for ref in refs: for ref in refs:
status_service(args.profile, ref) status_service(args.profile, ref)
elif args.action == "logs": elif args.action == "logs":

View File

@@ -133,6 +133,22 @@ class ServiceManagerTests(unittest.TestCase):
self.assertEqual(report["services"][0]["name"], "alpha") self.assertEqual(report["services"][0]["name"], "alpha")
self.assertIn("health", report["services"][0]) self.assertIn("health", report["services"][0])
def test_status_report_is_lightweight_machine_readable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
refs = services.service_items(sample_manifest(), include_disabled=True)
with patch.object(services, "ROOT", root), \
patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \
patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \
patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \
patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"):
report = services.status_report("test", refs)
self.assertEqual(report["profile"], "test")
self.assertEqual(len(report["services"]), 3)
self.assertIn("status", report["services"][0])
self.assertNotIn("checks", report["services"][0])
def test_read_pid_ignores_invalid_pid_file(self) -> None: def test_read_pid_ignores_invalid_pid_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
pid_dir = Path(tmp) / "pids" pid_dir = Path(tmp) / "pids"