Skip to content

Commit 9a6532c

Browse files
solomonneascursoragentclaude
authored
feat(evidence): own crawler runtime selection and propagate crawl health (#404)
Brigade owns the public crawl contract: it resolves the crawler binary, runs a non-mutating compatibility check (read-only `discrawl version` / `doctor --json`) before delegating to MiseLedger, refuses an incompatible runtime, and persists per-source last-run so a failed crawl is surfaced instead of masked by a NO_PENDING queue. Evidence doctor/status health is worst(miseledger, crawler compat, latest-run); `brigade evidence doctor` exits 1 on crawler FAIL while the workspace doctor stays advisory. The resolved runtime is reused via PATH-prepend (a true MiseLedger `--crawler` flag is a disclosed follow-up). Independent Opus and Fable design reviews were recorded on the issue before implementation. Closes #404 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24615d5 commit 9a6532c

5 files changed

Lines changed: 1017 additions & 16 deletions

File tree

docs/technical-guide.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,7 +1376,11 @@ These plan commands do not generate or copy PSKs, start services, or mutate brow
13761376

13771377
`evidence` (alias `ledger`) is the local evidence-ledger station. MiseLedger remains a process-boundary Go binary; Brigade never imports it.
13781378
`brigade setup` installs MiseLedger and its SessionFind companion from the exact release manifest. `brigade add evidence` remains a one-release compatibility fallback for an independent install.
1379-
Use `brigade evidence status` and `brigade evidence doctor` for advisory health with explicit `next` commands. `brigade evidence crawl <args...>` and `brigade evidence search <args...>` relay a safe argv list to MiseLedger, preserving its text or JSON output and exit status. `--code-reference <brigade.code-reference.v1 JSON>` is passed to MiseLedger unchanged, so its exact code-reference lookup runs before lexical fallback. Crawl defaults to 900 seconds and can be changed with the positive finite numeric `BRIGADE_EVIDENCE_CRAWL_TIMEOUT_SECONDS`; search remains at 30 seconds. An invalid crawl timeout reports a diagnostic and exits 2 before starting MiseLedger. `brigade evidence crawl plan` still previews miseledger init/crawl/doctor commands, and `brigade evidence export plan` still previews `brigade receipts export miseledger --new-only --import`. Product page: https://brigade.tools/miseledger.
1379+
Use `brigade evidence status` and `brigade evidence doctor` for advisory health with explicit `next` commands. `brigade evidence crawl <args...>` and `brigade evidence search <args...>` relay a safe argv list to MiseLedger, preserving its text or JSON output and exit status. `--code-reference <brigade.code-reference.v1 JSON>` is passed to MiseLedger unchanged, so its exact code-reference lookup runs before lexical fallback.
1380+
1381+
For supported sources (e.g., Discord), Brigade owns crawler runtime selection and the read-only compatibility gate: it resolves the crawler via `DISCRAWL_BIN`, `<SOURCE>_CRAWLER_BIN`, or PATH, probes `version` and `doctor --json`, and refuses the crawl when the archive is unreadable or required capabilities are missing. MiseLedger still performs the actual crawl and archive mutation; Discrawl owns the Discord archive. Because MiseLedger has no `--crawler` pass-through flag today, Brigade prepends the resolved crawler's directory to PATH when spawning `miseledger crawl`, so MiseLedger's own discovery lands on the same binary. A true MiseLedger-side `--crawler` option remains a follow-up limitation. After each crawl attempt, Brigade writes `.brigade/evidence/<source>-last-run.json` and propagates a non-zero exit through the receipt; a failed last-run makes later status/doctor reports unhealthy even when the queue reads `NO_PENDING`.
1382+
1383+
Crawl defaults to 900 seconds and can be changed with the positive finite numeric `BRIGADE_EVIDENCE_CRAWL_TIMEOUT_SECONDS`; search remains at 30 seconds. An invalid crawl timeout reports a diagnostic and exits 2 before starting MiseLedger. `brigade evidence crawl plan` still previews miseledger init/crawl/doctor commands, and `brigade evidence export plan` still previews `brigade receipts export miseledger --target . --new-only --import`. Product page: https://brigade.tools/miseledger.
13801384

13811385
`code` runs the GraphTrail installed by `brigade setup` through a process boundary: `brigade code sync|context|impact <args...>` uses safe argument forwarding and preserves engine text, JSON, and exit status. Sync defaults to 900 seconds and can be changed with the positive finite numeric `BRIGADE_CODE_SYNC_TIMEOUT_SECONDS`; context and impact remain at 30 seconds. An invalid sync timeout reports a diagnostic and exits 2 before starting GraphTrail. `brigade add search` and `brigade add graphtrail` remain one-release compatibility paths for independent installations. `search` retains its `status`, `doctor`, and `sync plan` surfaces; its executable `sync`, `context`, and `impact` forms are compatibility aliases for `code` for at least two minor releases or 90 days, whichever is longer. `search` still wires optional code-search-api, and the `code-search-mcp` compatibility key points to the bridge maintained under `code-search-api/mcp`.
13821386

@@ -1387,7 +1391,7 @@ Use `brigade tokens status` / `doctor` and review-only `brigade tokens wire plan
13871391

13881392
`evidence` (alias `ledger`) is the local evidence-ledger station. MiseLedger remains a process-boundary Go binary; Brigade never imports it.
13891393
`brigade setup` installs MiseLedger and its SessionFind companion from the exact release manifest. `brigade add evidence` remains a one-release compatibility fallback for an independent install.
1390-
Use `brigade evidence status` and `brigade evidence doctor` for advisory health with explicit `next` commands. `brigade evidence crawl <args...>` and `brigade evidence search <args...>` relay a safe argv list to MiseLedger, preserving its text or JSON output and exit status. `brigade evidence crawl plan` still previews miseledger init/crawl/doctor commands, and `brigade evidence export plan` still previews `brigade receipts export miseledger --new-only --import`. Product page: https://brigade.tools/miseledger.
1394+
Use `brigade evidence status` and `brigade evidence doctor` for advisory health with explicit `next` commands. `brigade evidence crawl <args...>` and `brigade evidence search <args...>` relay a safe argv list to MiseLedger, preserving its text or JSON output and exit status. Brigade selects and health-checks the crawler runtime for supported sources before delegating; MiseLedger still performs the crawl and import. `brigade evidence crawl plan` still previews miseledger init/crawl/doctor commands, and `brigade evidence export plan` still previews `brigade receipts export miseledger --new-only --import`. Product page: https://brigade.tools/miseledger.
13911395

13921396
Security commands:
13931397

src/brigade/evidence_cmd.py

Lines changed: 238 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from pathlib import Path
1616
from typing import Any
1717

18-
from . import evidence_brief, proc
18+
from . import evidence_brief, evidence_runtime, proc
1919
from .localio import utc_now_iso as _now
2020

2121

@@ -35,37 +35,247 @@ def _configured_timeout(environment_variable: str, default: float) -> float | No
3535
return timeout if math.isfinite(timeout) and timeout > 0 else None
3636

3737

38-
def _json_print(payload: dict[str, Any]) -> None:
39-
print(json.dumps(payload, indent=2, sort_keys=True))
38+
def _last_run_dir(target: Path) -> Path:
39+
return target / ".brigade" / "evidence"
4040

4141

42-
def run_engine(verb: str, arguments: list[str]) -> int:
43-
"""Run MiseLedger without changing its argv, output, or data contract.
42+
def _last_run_path(target: Path, source: str) -> Path:
43+
return _last_run_dir(target) / f"{source}-last-run.json"
4444

45-
A ``--code-reference`` JSON value is relayed verbatim to MiseLedger, which
46-
performs exact code-reference matching before lexical fallback.
45+
46+
def _read_last_run(target: Path, source: str) -> dict[str, Any] | None:
47+
path = _last_run_path(target, source)
48+
if not path.is_file():
49+
return None
50+
try:
51+
data = json.loads(path.read_text())
52+
except (json.JSONDecodeError, OSError, ValueError):
53+
return None
54+
return data if isinstance(data, dict) else None
55+
56+
57+
def _write_last_run(
58+
target: Path,
59+
source: str,
60+
*,
61+
status: str,
62+
exit_code: int,
63+
crawler_version: str | None,
64+
database: str | None,
65+
started_at: str,
66+
finished_at: str,
67+
detail: str,
68+
) -> None:
69+
_last_run_dir(target).mkdir(parents=True, exist_ok=True)
70+
payload = {
71+
"status": status,
72+
"exit_code": exit_code,
73+
"crawler_version": crawler_version,
74+
"database": database,
75+
"started_at": started_at,
76+
"finished_at": finished_at,
77+
"detail": detail,
78+
}
79+
_last_run_path(target, source).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
80+
81+
82+
_HEALTH_RANK = {
83+
"ok": 0,
84+
"warn": 1,
85+
"incomplete": 2,
86+
"unwired": 3,
87+
"timeout": 4,
88+
"missing": 5,
89+
"fail": 6,
90+
}
91+
92+
93+
def _health_rank(health: str | None) -> int:
94+
if health is None:
95+
return 0
96+
return _HEALTH_RANK.get(health, 0)
97+
98+
99+
def _env_override_active(source: str) -> bool:
100+
"""Return True when an explicit crawler override is present for ``source``."""
101+
if os.environ.get(f"{source.upper()}_CRAWLER_BIN"):
102+
return True
103+
if source == "discord" and os.environ.get("DISCRAWL_BIN"):
104+
return True
105+
return False
106+
107+
108+
def _enrich_crawler_health(payload: dict[str, Any], target: Path) -> dict[str, Any]:
109+
"""Add per-source crawler health and upgrade the overall health if needed.
110+
111+
A failed crawler compatibility check or an unhealthy last-run must be
112+
visible before a clean ``NO_PENDING`` queue state. The station stays
113+
advisory in the workspace doctor path; only ``brigade evidence doctor``
114+
reflects crawler-driven failures in its own exit code.
47115
"""
48116

117+
target = target.expanduser().resolve()
118+
crawlers: dict[str, Any] = {}
119+
worst_health = payload.get("health")
120+
for source in evidence_runtime.known_sources():
121+
last_run = _read_last_run(target, source)
122+
if last_run is None and not _env_override_active(source):
123+
# No evidence the operator expects this crawler; keep the station
124+
# advisory and do not probe the host for optional tools.
125+
continue
126+
runtime = evidence_runtime.resolve_crawler(source)
127+
if runtime is None:
128+
continue
129+
compat = evidence_runtime.check_compatibility(runtime)
130+
block = {
131+
"resolved_path": runtime.resolved_path,
132+
"version": runtime.version,
133+
"compatibility": {"state": compat.state, "detail": compat.detail},
134+
"required_capabilities": runtime.required_capabilities,
135+
"config_path": compat.config_path,
136+
"override": runtime.override,
137+
"latest_run": last_run,
138+
}
139+
crawlers[source] = block
140+
if _health_rank(compat.state) > _health_rank(worst_health):
141+
worst_health = compat.state
142+
if last_run is not None:
143+
last_status = last_run.get("status")
144+
if _health_rank(last_status) > _health_rank(worst_health):
145+
worst_health = last_status
146+
if crawlers:
147+
payload["crawlers"] = crawlers
148+
if worst_health != payload.get("health"):
149+
payload["health"] = worst_health
150+
if worst_health == "fail":
151+
payload["summary"] = f"crawler unhealthy; {payload['summary']}"
152+
return payload
153+
154+
155+
def _json_print(payload: dict[str, Any]) -> None:
156+
print(json.dumps(payload, indent=2, sort_keys=True))
157+
158+
159+
def _run_miseledger_result(
160+
verb: str,
161+
arguments: list[str],
162+
*,
163+
env: dict[str, str] | None = None,
164+
) -> proc.Result:
165+
"""Run MiseLedger and return the raw result without printing."""
166+
49167
timeout = _SHORT_OPERATION_TIMEOUT_SECONDS
50168
if verb == "crawl":
51169
configured_timeout = _configured_timeout(_CRAWL_TIMEOUT_ENV, _CRAWL_TIMEOUT_SECONDS)
52170
if configured_timeout is None:
53171
print(f"error: {_CRAWL_TIMEOUT_ENV} must be a positive finite number of seconds", file=sys.stderr)
54-
return 2
172+
return proc.Result(2, "", "")
55173
timeout = configured_timeout
56174

57175
binary = evidence_brief._miseledger_bin()
58176
if binary is None:
59177
print("error: miseledger is not installed; run `brigade setup`", file=sys.stderr)
60-
return 127
61-
result = proc.run([binary, verb, *arguments], timeout=timeout)
178+
return proc.Result(127, "", "miseledger is not installed; run `brigade setup`")
179+
run_kwargs: dict[str, Any] = {}
180+
if env is not None:
181+
run_kwargs["env"] = env
182+
return proc.run([binary, verb, *arguments], timeout=timeout, **run_kwargs)
183+
184+
185+
def _run_miseledger(verb: str, arguments: list[str], *, env: dict[str, str] | None = None) -> int:
186+
"""Run MiseLedger, relaying output and returning the exit code."""
187+
188+
result = _run_miseledger_result(verb, arguments, env=env)
62189
if result.stdout:
63190
print(result.stdout, end="")
64191
if result.stderr:
65192
print(result.stderr, end="", file=sys.stderr)
66193
return result.code
67194

68195

196+
def _run_crawl(arguments: list[str]) -> int:
197+
"""Resolve and health-check the crawler before delegating to MiseLedger.
198+
199+
The resolved crawler's directory is prepended to PATH so that MiseLedger's
200+
own discovery lands on the same binary. A true miseledger ``--crawler``
201+
pass-through flag does not exist today and is a MiseLedger-side follow-up.
202+
"""
203+
204+
raw_source = arguments[0] if arguments else None
205+
# Normalize the source for gating so a differently-cased spelling (e.g.
206+
# "Discord") cannot bypass the compatibility gate; the original arguments are
207+
# still passed to MiseLedger unchanged.
208+
source = raw_source.lower() if raw_source is not None else None
209+
env = dict(os.environ)
210+
target = Path.cwd().expanduser().resolve()
211+
started_at = _now()
212+
213+
if source is None:
214+
return _run_miseledger("crawl", arguments)
215+
216+
runtime = evidence_runtime.resolve_crawler(source, env=env)
217+
if runtime is None:
218+
# No crawler contract for this source; delegate directly.
219+
return _run_miseledger("crawl", arguments)
220+
221+
compat = evidence_runtime.check_compatibility(runtime, env=env)
222+
if compat.state == "fail":
223+
detail = compat.detail
224+
print(f"error: evidence crawl refused for {source}: {detail}", file=sys.stderr)
225+
_write_last_run(
226+
target=target,
227+
source=source,
228+
status="fail",
229+
exit_code=1,
230+
crawler_version=runtime.version,
231+
database=compat.database,
232+
started_at=started_at,
233+
finished_at=_now(),
234+
detail=detail,
235+
)
236+
return 1
237+
238+
if runtime.resolved_path is None:
239+
print(f"error: evidence crawl refused for {source}: no executable resolved", file=sys.stderr)
240+
return 1
241+
242+
# Prepend the resolved crawler directory so MiseLedger rediscovers the same
243+
# binary. This is a Brigade-side substitute for a miseledger --crawler flag.
244+
crawler_dir = str(Path(runtime.resolved_path).parent)
245+
env["PATH"] = crawler_dir + os.pathsep + env.get("PATH", "")
246+
247+
result = _run_miseledger_result("crawl", arguments, env=env)
248+
status = "ok" if result.code == 0 else "fail"
249+
_write_last_run(
250+
target=target,
251+
source=source,
252+
status=status,
253+
exit_code=result.code,
254+
crawler_version=runtime.version,
255+
database=compat.database,
256+
started_at=started_at,
257+
finished_at=_now(),
258+
detail=(result.stderr or "").strip()[:500],
259+
)
260+
if result.stdout:
261+
print(result.stdout, end="")
262+
if result.stderr:
263+
print(result.stderr, end="", file=sys.stderr)
264+
return result.code
265+
266+
267+
def run_engine(verb: str, arguments: list[str]) -> int:
268+
"""Run MiseLedger without changing its argv, output, or data contract.
269+
270+
A ``--code-reference`` JSON value is relayed verbatim to MiseLedger, which
271+
performs exact code-reference matching before lexical fallback.
272+
"""
273+
274+
if verb == "crawl":
275+
return _run_crawl(arguments)
276+
return _run_miseledger(verb, arguments)
277+
278+
69279
def _run_json(args: list[str], *, timeout: float = 30.0) -> dict[str, Any]:
70280
result = proc.run(args, timeout=timeout)
71281
data = result.json()
@@ -91,13 +301,12 @@ def _cursor_path(target: Path) -> Path:
91301
return target / ".brigade" / "work" / "miseledger-export-cursor.json"
92302

93303

94-
def status_payload(
304+
def _build_status_payload(
95305
target: Path,
96306
*,
97307
include_doctor: bool = True,
98308
timeout: float = 120.0,
99309
) -> dict[str, Any]:
100-
target = target.expanduser().resolve()
101310
binary = evidence_brief._miseledger_bin()
102311
installed = binary is not None
103312
cursor = _cursor_path(target)
@@ -270,6 +479,17 @@ def status_payload(
270479
return payload
271480

272481

482+
def status_payload(
483+
target: Path,
484+
*,
485+
include_doctor: bool = True,
486+
timeout: float = 120.0,
487+
) -> dict[str, Any]:
488+
target = target.expanduser().resolve()
489+
payload = _build_status_payload(target, include_doctor=include_doctor, timeout=timeout)
490+
return _enrich_crawler_health(payload, target)
491+
492+
273493
def status(*, target: Path, json_output: bool = False) -> int:
274494
payload = status_payload(target)
275495
if json_output:
@@ -283,6 +503,11 @@ def status(*, target: Path, json_output: bool = False) -> int:
283503
for key in ("items", "item_count", "sources", "archive", "path", "db"):
284504
if key in status_data:
285505
print(f"{key}: {status_data.get(key)}")
506+
crawlers = payload.get("crawlers")
507+
if isinstance(crawlers, dict):
508+
for source, block in crawlers.items():
509+
compat = block.get("compatibility") or {}
510+
print(f"crawler/{source}: {compat.get('state')} - {compat.get('detail')}")
286511
doctor_data = (payload.get("doctor") or {}).get("stdout_json") or {}
287512
if isinstance(doctor_data, dict) and doctor_data.get("checks"):
288513
print("checks:")
@@ -316,7 +541,7 @@ def doctor(*, target: Path, json_output: bool = False) -> int:
316541
_print_next(payload)
317542
print(
318543
"note: evidence checks are advisory for workspace doctor; "
319-
"this command exits 1 on miseledger fail/incomplete/timeout"
544+
"this command exits 1 on miseledger fail/incomplete/timeout or crawler fail"
320545
)
321546
health = payload.get("health")
322547
if health in ("fail", "incomplete", "timeout"):

0 commit comments

Comments
 (0)