Skip to content

Commit b08840f

Browse files
authored
Merge pull request #561 from escoffier-labs/fix/doctor-workspace-scoping
fix(doctor): scope checks and group actionable output
2 parents 629af81 + 85deca8 commit b08840f

3 files changed

Lines changed: 193 additions & 85 deletions

File tree

src/brigade/doctor.py

Lines changed: 111 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import shutil
1010
from datetime import date, datetime, timezone
1111
from pathlib import Path
12-
from typing import List, Tuple
12+
from typing import List, Sequence, Tuple
1313

1414
from .budgets import (
1515
BOOTSTRAP_BUDGETS,
@@ -20,12 +20,15 @@
2020
from .station import DoctorContext
2121

2222
CheckResult = Tuple[str, str, str] # (status, name, detail)
23+
ScopedCheckResult = Tuple[str, str, str, str] # (status, name, detail, scope)
2324
OK = "OK"
2425
WARN = "WARN"
2526
FAIL = "FAIL"
2627
MANUAL = "MANUAL"
2728
INFO = "INFO"
28-
DEFAULT_TEXT_CHECK_LIMIT = 50
29+
SCOPE_TARGET = "target"
30+
SCOPE_OPERATOR = "operator"
31+
ACTIONABLE_STATUSES = frozenset({FAIL, WARN, MANUAL})
2932
ADAPTER_CHECK_PREFIX = "adapter: "
3033

3134

@@ -34,6 +37,29 @@ def _adapter_check_name(name: str) -> str:
3437
return f"{ADAPTER_CHECK_PREFIX}{name}"
3538

3639

40+
def _scoped_check(status: str, name: str, detail: str, scope: str = SCOPE_TARGET) -> ScopedCheckResult:
41+
return (status, name, detail, scope)
42+
43+
44+
def _operator_check(status: str, name: str, detail: str) -> ScopedCheckResult:
45+
return _scoped_check(status, name, detail, SCOPE_OPERATOR)
46+
47+
48+
def _normalize_scoped_check(
49+
check: CheckResult | ScopedCheckResult,
50+
*,
51+
default_scope: str = SCOPE_TARGET,
52+
) -> ScopedCheckResult:
53+
if len(check) == 4:
54+
return check # type: ignore[return-value]
55+
status, name, detail = check
56+
return _scoped_check(status, name, detail, default_scope)
57+
58+
59+
def _check_scope(check: CheckResult | ScopedCheckResult) -> str:
60+
return check[3] if len(check) == 4 else SCOPE_TARGET
61+
62+
3763
def build_context(target: Path, harness: str = "generic") -> DoctorContext:
3864
target = target.expanduser().resolve()
3965
from .config import load_config
@@ -62,7 +88,7 @@ def core_station_checks(ctx: DoctorContext) -> List[CheckResult]:
6288
if check is not None:
6389
checks.append(check)
6490
if "openclaw" in ctx.harnesses:
65-
checks.extend(_check_openclaw())
91+
checks.extend(_operator_check(status, name, detail) for status, name, detail in _check_openclaw())
6692
if "hermes" in ctx.harnesses:
6793
checks.extend(_check_hermes(ctx.target))
6894
checks.extend(_check_orphan_inboxes(ctx.target, ctx.harnesses))
@@ -255,35 +281,40 @@ def run(
255281
return _report(checks, full=full, target=ctx.target, operator=operator)
256282

257283

258-
def _gather_checks(ctx: DoctorContext) -> List[CheckResult]:
284+
def _gather_checks(ctx: DoctorContext) -> List[ScopedCheckResult]:
259285
from . import component_report
260286
from .registry import all_stations
261287
from . import managed
262288

263-
checks: List[CheckResult] = []
264-
checks.extend(component_report.doctor_checks())
289+
checks: List[ScopedCheckResult] = []
290+
for status, name, detail in component_report.doctor_checks():
291+
checks.append(_operator_check(status, name, detail))
265292
missing_tools: List[Tuple[str, str]] = []
266293
for station in all_stations():
267294
if station.doctor is not None:
268-
checks.extend(station.doctor(ctx))
295+
for check in station.doctor(ctx):
296+
checks.append(_normalize_scoped_check(check))
269297
for tool in managed.for_station(station.name):
270298
if tool.detect():
271-
checks.extend(tool.doctor(ctx))
299+
for check in tool.doctor(ctx):
300+
checks.append(_operator_check(check[0], check[1], check[2]))
272301
else:
273302
missing_tools.append((station.name, tool.name))
274303
if len(missing_tools) == 1:
275304
station_name, tool_name = missing_tools[0]
276-
checks.append((MANUAL, f"{station_name}: {tool_name}", f"not installed; run `brigade add {station_name}`"))
305+
checks.append(
306+
_operator_check(MANUAL, f"{station_name}: {tool_name}", f"not installed; run `brigade add {station_name}`")
307+
)
277308
elif missing_tools:
278309
stations = sorted({station for station, _ in missing_tools})
279310
checks.append(
280-
(
311+
_operator_check(
281312
MANUAL,
282313
"managed tools",
283314
f"{len(missing_tools)} managed tools not installed ({', '.join(stations)}); optional, install with `brigade add <station>`",
284315
)
285316
)
286-
checks.append(_check_receipts(ctx.target))
317+
checks.append(_normalize_scoped_check(_check_receipts(ctx.target)))
287318
return checks
288319

289320

@@ -813,9 +844,13 @@ def _check_publish_gate(target: Path) -> List[CheckResult]:
813844
scanner_dir = scrub.scanner_dir()
814845
if scanner_dir.is_dir():
815846
label = "external compatibility override" if os.environ.get("CONTENT_GUARD_DIR") else "embedded content guard"
816-
results.append((OK, "guard: embedded content guard", f"{label}: {scanner_dir}"))
847+
results.append(_operator_check(OK, "guard: embedded content guard", f"{label}: {scanner_dir}"))
817848
else:
818-
results.append((MANUAL, "guard: embedded content guard", f"not found at {scanner_dir}; reinstall brigade-cli"))
849+
results.append(
850+
_operator_check(
851+
MANUAL, "guard: embedded content guard", f"not found at {scanner_dir}; reinstall brigade-cli"
852+
)
853+
)
819854
return results
820855

821856

@@ -968,112 +1003,107 @@ def _doctor_hermes_result(item: dict) -> CheckResult:
9681003
INFO: " [info]",
9691004
}
9701005

971-
# Checks about host-global / operator state rather than the --target workspace.
972-
# Hidden by default (#478); pass --operator to include them in the report.
973-
_OPERATOR_SCOPED_PREFIXES = (
974-
"openclaw:",
975-
"components:",
976-
"bootstrap-doctor",
977-
"agentpantry",
978-
"miseledger",
979-
"usage-tracker",
980-
"code-search-api",
981-
"code-search-mcp",
982-
"token-glace",
983-
"agent-notify",
984-
"plating",
985-
)
986-
_OPERATOR_SCOPED_EXACT = {"guard: embedded content guard", "managed tools"}
987-
_MANAGED_OPERATOR_TOOL_SLUGS = {
988-
"bootstrap-doctor",
989-
"token-glace",
990-
"code-search-api",
991-
"code-search-mcp",
992-
"agentpantry",
993-
"agent-notify",
994-
"miseledger",
995-
"usage-tracker",
996-
"plating",
1006+
_SEVERITY_ORDER = (FAIL, WARN, MANUAL, INFO, OK)
1007+
_SEVERITY_GROUP_LABELS = {
1008+
FAIL: "failures:",
1009+
WARN: "warnings:",
1010+
MANUAL: "manual actions:",
1011+
INFO: "info:",
1012+
OK: "ok:",
9971013
}
1014+
_OPERATOR_SECTION_HEADER = "operator/host (not specific to this target):"
9981015

9991016

1000-
def _is_operator_scoped(name: str) -> bool:
1001-
if name in _OPERATOR_SCOPED_EXACT:
1002-
return True
1003-
if name.startswith(_OPERATOR_SCOPED_PREFIXES):
1004-
return True
1005-
if ": " in name:
1006-
_, rhs = name.split(": ", 1)
1007-
slug = rhs.split()[0]
1008-
if slug in _MANAGED_OPERATOR_TOOL_SLUGS:
1009-
return True
1010-
return False
1011-
1012-
1013-
def _filter_target_scoped_checks(checks: List[CheckResult]) -> List[CheckResult]:
1014-
return [check for check in checks if not _is_operator_scoped(check[1])]
1017+
def _filter_target_scoped_checks(checks: List[ScopedCheckResult]) -> List[ScopedCheckResult]:
1018+
return [check for check in checks if _check_scope(check) == SCOPE_TARGET]
10151019

10161020

10171021
def _target_detail_prefix(target: Path) -> str:
10181022
return f"target={target}: "
10191023

10201024

1021-
def _annotate_target_detail(target: Path, name: str, detail: str) -> str:
1022-
if _is_operator_scoped(name):
1025+
def _annotate_target_detail(target: Path, check: CheckResult | ScopedCheckResult) -> str:
1026+
status, name, detail, scope = _normalize_scoped_check(check)
1027+
if scope == SCOPE_OPERATOR:
10231028
return detail
10241029
prefix = _target_detail_prefix(target)
10251030
if detail.startswith(prefix) or detail.startswith(str(target)):
10261031
return detail
10271032
return f"{prefix}{detail}"
10281033

10291034

1030-
def _report(checks: List[CheckResult], *, full: bool = True, target: Path | None = None, operator: bool = False) -> int:
1031-
width = max((len(name) for _, name, _ in checks), default=20)
1032-
counts = _status_counts(checks)
1035+
def _report(
1036+
checks: Sequence[CheckResult | ScopedCheckResult],
1037+
*,
1038+
full: bool = True,
1039+
target: Path | None = None,
1040+
operator: bool = False,
1041+
) -> int:
1042+
scoped_checks = [_normalize_scoped_check(check) for check in checks]
1043+
width = max((len(check[1]) for check in scoped_checks), default=20)
1044+
counts = _status_counts(scoped_checks)
10331045
print(
1034-
f"triage: {len(checks)} checks, {counts[OK]} ok, {counts[WARN]} warn, "
1046+
f"triage: {len(scoped_checks)} checks, {counts[OK]} ok, {counts[WARN]} warn, "
10351047
f"{counts[FAIL]} failed, {counts[MANUAL]} manual, {counts[INFO]} info"
10361048
)
10371049

1038-
condensed = not full and len(checks) > DEFAULT_TEXT_CHECK_LIMIT
1039-
visible_checks = [check for check in checks if check[0] in {FAIL, WARN, MANUAL}] if condensed else checks
1040-
target_checks = [check for check in visible_checks if not _is_operator_scoped(check[1])]
1041-
operator_checks = [check for check in visible_checks if _is_operator_scoped(check[1])]
1050+
visible_statuses = set(_SEVERITY_ORDER) if full else ACTIONABLE_STATUSES
1051+
visible_checks = [check for check in scoped_checks if check[0] in visible_statuses]
1052+
target_checks = [check for check in visible_checks if _check_scope(check) == SCOPE_TARGET]
1053+
operator_checks = [check for check in visible_checks if _check_scope(check) == SCOPE_OPERATOR]
1054+
hidden_detail = not full and any(check[0] not in ACTIONABLE_STATUSES for check in scoped_checks)
10421055

1043-
def _emit(items: List[CheckResult]) -> None:
1044-
for status, name, detail in items:
1056+
def _emit(items: List[ScopedCheckResult]) -> None:
1057+
for status, name, detail, scope in items:
1058+
annotated_detail = detail
10451059
if target is not None:
1046-
detail = _annotate_target_detail(target, name, detail)
1047-
print(f"{_MARKERS[status]} {name.ljust(width)} {detail}")
1060+
annotated_detail = _annotate_target_detail(target, (status, name, detail, scope))
1061+
print(f"{_MARKERS[status]} {name.ljust(width)} {annotated_detail}")
1062+
1063+
def _emit_grouped(items: List[ScopedCheckResult]) -> bool:
1064+
emitted = False
1065+
for status in _SEVERITY_ORDER:
1066+
if status not in visible_statuses:
1067+
continue
1068+
group = [check for check in items if check[0] == status]
1069+
if not group:
1070+
continue
1071+
emitted = True
1072+
print(_SEVERITY_GROUP_LABELS[status])
1073+
_emit(group)
1074+
return emitted
10481075

10491076
print()
10501077
if visible_checks:
1051-
_emit(target_checks)
1078+
if not _emit_grouped(target_checks):
1079+
print(" no failures, warnings, or manual actions")
10521080
else:
10531081
print(" no failures, warnings, or manual actions")
10541082
if operator and operator_checks:
10551083
print()
1056-
print("operator/host (not specific to this target):")
1057-
_emit(operator_checks)
1084+
print(_OPERATOR_SECTION_HEADER)
1085+
_emit_grouped(operator_checks)
10581086

1059-
if condensed:
1087+
if hidden_detail:
10601088
print()
10611089
print(f"showing {len(visible_checks)} actionable checks; run `brigade doctor --full` to show all checks")
10621090

10631091
print()
1064-
print(f"summary: {len(checks)} checks, {counts[FAIL]} failed, {counts[MANUAL]} manual")
1092+
print(f"summary: {len(scoped_checks)} checks, {counts[FAIL]} failed, {counts[MANUAL]} manual")
10651093
return 1 if counts[FAIL] else 0
10661094

10671095

1068-
def _status_counts(checks: List[CheckResult]) -> dict[str, int]:
1096+
def _status_counts(checks: List[CheckResult | ScopedCheckResult]) -> dict[str, int]:
10691097
counts = {OK: 0, WARN: 0, FAIL: 0, MANUAL: 0, INFO: 0}
1070-
for status, _, _ in checks:
1098+
for check in checks:
1099+
status = check[0]
10711100
counts[status] = counts.get(status, 0) + 1
10721101
return counts
10731102

10741103

1075-
def _report_json(ctx: DoctorContext, checks: List[CheckResult], *, operator: bool = False) -> int:
1076-
counts = _status_counts(checks)
1104+
def _report_json(ctx: DoctorContext, checks: List[CheckResult | ScopedCheckResult], *, operator: bool = False) -> int:
1105+
scoped_checks = [_normalize_scoped_check(check) for check in checks]
1106+
counts = _status_counts(scoped_checks)
10771107
sel = ctx.selection
10781108
payload = {
10791109
"target": str(ctx.target),
@@ -1085,13 +1115,13 @@ def _report_json(ctx: DoctorContext, checks: List[CheckResult], *, operator: boo
10851115
{
10861116
"status": status,
10871117
"name": name,
1088-
"detail": _annotate_target_detail(ctx.target, name, detail),
1089-
"scope": "operator" if _is_operator_scoped(name) else "target",
1118+
"detail": _annotate_target_detail(ctx.target, (status, name, detail, scope)),
1119+
"scope": scope,
10901120
}
1091-
for status, name, detail in checks
1121+
for status, name, detail, scope in scoped_checks
10921122
],
10931123
"summary": {
1094-
"total": len(checks),
1124+
"total": len(scoped_checks),
10951125
"ok": counts[OK],
10961126
"warn": counts[WARN],
10971127
"manual": counts[MANUAL],

src/brigade/status.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _normalize_payload_health(raw: object, *, installed: bool | None) -> str:
5151

5252

5353
def _health_from_checks(checks: list[_doctor.CheckResult]) -> str:
54-
levels = {status for status, _name, _detail in checks}
54+
levels = {check[0] for check in checks}
5555
if _doctor.FAIL in levels:
5656
return "failed"
5757
if _doctor.WARN in levels:
@@ -105,9 +105,9 @@ def run(target: Path, *, json_output: bool = False) -> int:
105105
summary = str(payload.get("summary") or station.summary)
106106
else:
107107
checks = station.doctor(ctx) if station.doctor else []
108-
ok = sum(1 for s, _, _ in checks if s == _doctor.OK)
109-
warn = sum(1 for s, _, _ in checks if s == _doctor.WARN)
110-
fail = sum(1 for s, _, _ in checks if s == _doctor.FAIL)
108+
ok = sum(1 for check in checks if check[0] == _doctor.OK)
109+
warn = sum(1 for check in checks if check[0] == _doctor.WARN)
110+
fail = sum(1 for check in checks if check[0] == _doctor.FAIL)
111111
health = _health_from_checks(checks)
112112
summary = station.summary
113113
rows.append(

0 commit comments

Comments
 (0)