Skip to content

Commit d29aed8

Browse files
committed
feat(cli): serial-read --grep + vserial-start live-tail hint
Two small onboarding wins for AI callers hunting a specific line in a noisy serial backlog: 1. serial-read --grep <regex> Server-side re.search filter applied BEFORE the tail/paging window, so non-matching entries do not consume the byte budget. Cursor semantics unchanged: 'next' still points into the raw ring so a caller can drop the filter and keep paging. Invalid regex returns success=false + invalid_grep=true + a human-readable error instead of a 500. Direct-mode falls back to a local re.search over the tail so the flag behaves the same either way. 2. vserial-start epilog with copy-pasteable live-tail recipes For continuous 'wait for PANIC' style workflows the right tool is a PTY + standard unix ('timeout 60 grep -m1 PANIC /tmp/fpb-tty…'), not a bespoke --follow flag. The updated epilog spells that out so a first-time AI doesn't reach for polling loops. Tests: - core/serial_read.py: 7 new cases (tail filter, no-match, paging, budget accounting, invalid regex envelope, overflow-with-filter, grep=None regression guard). - routes/logs: grep filters before budget, invalid regex returns structured error (not 500). - cli/fpb_cli: proxy forwards grep, invalid_grep surfaces on the CLI JSON envelope so callers can branch on success. Coverage 86.0% (gate 85%), lint clean, verified against live server: valid regex returns only matching entries, invalid regex returns the structured error envelope.
1 parent 25f8985 commit d29aed8

8 files changed

Lines changed: 283 additions & 14 deletions

File tree

Tools/WebServer/app/routes/logs.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ def api_serial_read():
123123
max_bytes hard cap on returned data bytes; default 4096
124124
tail return only the newest N bytes (ignores ``since``)
125125
drop "1"/"true" to skip the backlog and just advance the cursor
126+
grep optional regex; only entries matching (``re.search``) are
127+
considered before tail/paging is applied
126128
127129
Returns data + next cursor + pending_bytes/entries + buffer_overflowed so a
128130
reader can page the whole backlog without ever over-reading its context.
@@ -134,6 +136,7 @@ def api_serial_read():
134136
tail = request.args.get("tail", 0, type=int)
135137
drop_raw = (request.args.get("drop", "") or "").lower()
136138
drop = drop_raw in ("1", "true", "yes")
139+
grep = request.args.get("grep", None, type=str) or None
137140

138141
device = state.device
139142
snapshot = list(device.raw_serial_log)
@@ -146,8 +149,9 @@ def api_serial_read():
146149
max_bytes=max_bytes,
147150
tail=tail,
148151
drop=drop,
152+
grep=grep,
149153
)
150-
result["success"] = True
154+
result["success"] = not result.get("invalid_grep", False)
151155
return jsonify(result)
152156

153157

Tools/WebServer/cli/arg_parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ def build_parser(prog: str) -> argparse.ArgumentParser:
388388
help="Skip the buffered backlog and just advance the cursor "
389389
"(adb logcat -c style); returns the new 'next'.",
390390
)
391+
serial_read_parser.add_argument(
392+
"--grep",
393+
type=str,
394+
default=None,
395+
help="Server-side regex filter (re.search); only matching entries "
396+
"count against --tail / --max-bytes. Great for spotting PANIC/assert "
397+
"in a big backlog without dragging the whole thing into context. "
398+
"Invalid regex returns success=false with invalid_grep=true.",
399+
)
391400

392401
# file-list command (requires device)
393402
file_list_parser = subparsers.add_parser(
@@ -457,6 +466,18 @@ def build_parser(prog: str) -> argparse.ArgumentParser:
457466
vserial_start_parser = subparsers.add_parser(
458467
"vserial-start",
459468
help="Create virtual serial passthrough on the server (requires server)",
469+
epilog=(
470+
"Live tail with standard tools once the PTY exists:\n"
471+
" timeout 30 cat /tmp/fpb-ttyACM0 "
472+
"# adb-logcat-like tail\n"
473+
" timeout 60 grep --line-buffered -m1 PANIC "
474+
"/tmp/fpb-ttyACM0 # exit on first hit\n"
475+
" tail -c 4096 <(timeout 5 cat /tmp/fpb-ttyACM0) "
476+
"# newest 4KB\n"
477+
"For programmatic cursor-based reads with a byte budget and\n"
478+
"server-side grep, use 'fpbinject serial-read --grep ...'."
479+
),
480+
formatter_class=argparse.RawDescriptionHelpFormatter,
460481
)
461482
vserial_start_parser.add_argument(
462483
"--symlink",

Tools/WebServer/cli/fpb_cli.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -959,12 +959,15 @@ def serial_read(
959959
max_bytes: int = 4096,
960960
tail: int = 0,
961961
drop: bool = False,
962+
grep: Optional[str] = None,
962963
) -> None:
963964
"""Read serial output, context-safe (adb-logcat style).
964965
965966
Bounded by ``max_bytes`` so a large backlog never blows up the
966967
consumer's context. Defaults to a tail read; page the rest with
967-
``--since <next>`` or skip it with ``--drop``.
968+
``--since <next>`` or skip it with ``--drop``. Pass ``grep`` for
969+
a server-side regex filter (proxy mode only — direct mode falls
970+
back to a local ``re.search`` over the freshly-read tail).
968971
"""
969972
try:
970973
if self._proxy:
@@ -978,6 +981,7 @@ def serial_read(
978981
max_bytes=max_bytes,
979982
tail=effective_tail,
980983
drop=drop,
984+
grep=grep,
981985
)
982986
self._emit_serial_window(win)
983987
return
@@ -1009,6 +1013,26 @@ def serial_read(
10091013
truncated = True
10101014

10111015
log_lines = [ln for ln in new_data.split("\n") if ln.strip()][-lines:]
1016+
# Direct mode has no server-side ring; apply grep locally over
1017+
# the freshly-read tail so the CLI flag behaves the same either
1018+
# way. Invalid regex surfaces as a structured error.
1019+
if grep:
1020+
import re as _re
1021+
1022+
try:
1023+
pat = _re.compile(grep)
1024+
except _re.error as re_err:
1025+
self.output_json(
1026+
{
1027+
"success": False,
1028+
"invalid_grep": True,
1029+
"error": f"invalid grep pattern: {re_err}",
1030+
}
1031+
)
1032+
return
1033+
log_lines = [ln for ln in log_lines if pat.search(ln)]
1034+
new_data = "\n".join(log_lines)
1035+
data_bytes = new_data.encode("utf-8")
10121036
out = {
10131037
"success": True,
10141038
"new_data": new_data,
@@ -1030,12 +1054,15 @@ def _emit_serial_window(self, win: dict) -> None:
10301054
"""Emit a context-safe windowed read result (proxy mode).
10311055
10321056
Builds a bounded JSON payload and attaches an actionable ``hint`` so
1033-
the caller knows how to page or skip the remaining backlog.
1057+
the caller knows how to page or skip the remaining backlog. Passes
1058+
through ``invalid_grep`` / ``error`` if the server rejected the
1059+
regex, so callers can branch on ``success`` without re-parsing text.
10341060
"""
10351061
data = win.get("data", "")
10361062
pending = win.get("pending_bytes", 0)
1063+
# Server flips success to false on invalid grep; propagate faithfully.
10371064
out = {
1038-
"success": True,
1065+
"success": win.get("success", True),
10391066
"data": data,
10401067
"next": win.get("next", 0),
10411068
"returned_bytes": win.get("returned_bytes", len(data.encode("utf-8"))),
@@ -1044,6 +1071,9 @@ def _emit_serial_window(self, win: dict) -> None:
10441071
"truncated": win.get("truncated", False),
10451072
"buffer_overflowed": win.get("buffer_overflowed", False),
10461073
}
1074+
if win.get("invalid_grep"):
1075+
out["invalid_grep"] = True
1076+
out["error"] = win.get("error", "invalid grep pattern")
10471077
hints = []
10481078
if pending and pending > 0:
10491079
hints.append(
@@ -1421,6 +1451,7 @@ def main():
14211451
args.max_bytes,
14221452
args.tail,
14231453
args.drop,
1454+
grep=getattr(args, "grep", None),
14241455
)
14251456
elif args.command == "doctor":
14261457
cli.doctor(args.start_size, args.max_size, args.timeout, args.trials)

Tools/WebServer/cli/server_proxy.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,17 +444,25 @@ def serial_read_window(
444444
max_bytes: int = 4096,
445445
tail: int = 0,
446446
drop: bool = False,
447+
grep: Optional[str] = None,
447448
) -> dict:
448449
"""Context-safe windowed serial read via /api/serial/read.
449450
450-
Returns dict with data / next / pending_bytes / pending_entries /
451-
buffer_overflowed. Never returns more than ``max_bytes`` of data.
451+
``grep`` is an optional server-side regex (``re.search``) applied
452+
BEFORE the tail/paging window, so only matching entries consume
453+
the byte budget. Returns dict with data / next / pending_bytes /
454+
pending_entries / buffer_overflowed. Never returns more than
455+
``max_bytes`` of data.
452456
"""
457+
from urllib.parse import quote
458+
453459
q = f"/api/serial/read?since={since}&max_bytes={max_bytes}"
454460
if tail and tail > 0:
455461
q += f"&tail={tail}"
456462
if drop:
457463
q += "&drop=1"
464+
if grep:
465+
q += f"&grep={quote(grep, safe='')}"
458466
return self._get(q)
459467

460468
# ------------------------------------------------------------------

Tools/WebServer/core/serial_read.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
The logic here is pure (no Flask, no device) so it can be unit tested directly.
3131
"""
3232

33-
from typing import Dict, List
33+
import re
34+
from typing import Dict, List, Optional
3435

3536
# Default byte budget for a single read. Small on purpose: a bare read must
3637
# never dump the whole backlog.
@@ -57,6 +58,15 @@ def _empty_result(next_id: int, overflowed: bool = False) -> Dict:
5758
}
5859

5960

61+
def _invalid_grep_result(next_id: int, message: str) -> Dict:
62+
"""Return an error envelope when the caller-supplied regex fails to
63+
compile. Keeps the schema stable so callers can branch on ``error``."""
64+
r = _empty_result(next_id)
65+
r["error"] = f"invalid grep pattern: {message}"
66+
r["invalid_grep"] = True
67+
return r
68+
69+
6070
def compute_read(
6171
entries: List[dict],
6272
next_id: int,
@@ -65,6 +75,7 @@ def compute_read(
6575
max_bytes: int = DEFAULT_MAX_BYTES,
6676
tail: int = 0,
6777
drop: bool = False,
78+
grep: Optional[str] = None,
6879
) -> Dict:
6980
"""Compute a context-safe windowed read over a ring buffer snapshot.
7081
@@ -75,6 +86,12 @@ def compute_read(
7586
max_bytes: Hard cap on returned ``data`` bytes. <= 0 means DEFAULT.
7687
tail: If > 0, cap the returned window to the newest ``tail`` bytes.
7788
drop: If True, return no data and advance the cursor to ``next_id``.
89+
grep: Optional regex; only entries whose ``data`` matches (``re.search``)
90+
are considered. Filtering is applied FIRST, then the tail/paging
91+
window is computed over the filtered list. ``since``/``next``
92+
cursors still refer to the underlying buffer ids so callers can
93+
keep paging after they drop the filter. Invalid regex returns an
94+
error envelope (``invalid_grep=True``) instead of raising.
7895
7996
Returns:
8097
dict: data, next, returned_bytes, pending_bytes, pending_entries,
@@ -87,21 +104,34 @@ def compute_read(
87104
if drop:
88105
return _empty_result(next_id)
89106

90-
# Overflow: a since-cursor older than the oldest retained id means the ring
91-
# evicted data the caller had not read yet. Only meaningful for paging.
107+
# Compile and apply the grep filter up-front (before overflow/tail/page
108+
# calculations) so every downstream branch operates on the filtered list.
109+
# We keep the original ids so the returned cursor remains meaningful.
110+
filtered = entries
111+
if grep:
112+
try:
113+
pat = re.compile(grep)
114+
except re.error as e:
115+
return _invalid_grep_result(next_id, str(e))
116+
filtered = [e for e in entries if pat.search(e.get("data", ""))]
117+
118+
# Overflow: a since-cursor older than the oldest retained id in the RAW
119+
# buffer means the ring evicted data the caller had not read yet -- the
120+
# dropped entry may well have been a match, so we must still flag this
121+
# even when a grep filter is active. Only meaningful for paging.
92122
overflowed = (
93123
not tail and since > 0 and bool(entries) and since < _earliest_id(entries)
94124
)
95125

96126
# -------------------- tail mode (newest window) --------------------
97127
if tail and tail > 0:
98128
budget = min(tail, max_bytes)
99-
total_all = _nbytes("".join(e.get("data", "") for e in entries))
129+
total_all = _nbytes("".join(e.get("data", "") for e in filtered))
100130
# Walk entries from newest to oldest, accumulating whole entries until
101131
# the budget is reached; then byte-trim the oldest included entry.
102132
picked: List[str] = []
103133
acc = 0
104-
for e in reversed(entries):
134+
for e in reversed(filtered):
105135
d = e.get("data", "")
106136
picked.append(d)
107137
acc += _nbytes(d)
@@ -125,7 +155,7 @@ def compute_read(
125155
}
126156

127157
# -------------------- paging mode (since, forward) --------------------
128-
working = [e for e in entries if e.get("id", 0) >= since]
158+
working = [e for e in filtered if e.get("id", 0) >= since]
129159
if not working:
130160
return _empty_result(next_id, overflowed)
131161

@@ -159,7 +189,7 @@ def compute_read(
159189
truncated = True
160190
cursor = working[0].get("id", since)
161191

162-
remaining = [e for e in entries if e.get("id", 0) >= cursor]
192+
remaining = [e for e in filtered if e.get("id", 0) >= cursor]
163193
pending_bytes = _nbytes("".join(e.get("data", "") for e in remaining))
164194
pending_entries = len(remaining)
165195
if pending_bytes > 0:

Tools/WebServer/tests/test_cli_coexistence.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ def do_GET(self):
454454
max_bytes = int(qs.get("max_bytes", [4096])[0])
455455
tail = int(qs.get("tail", [0])[0])
456456
drop = (qs.get("drop", [""])[0] or "").lower() in ("1", "true", "yes")
457+
grep = qs.get("grep", [None])[0]
457458
next_id = (
458459
max(e["id"] for e in self.log_entries) + 1 if self.log_entries else 0
459460
)
@@ -464,8 +465,10 @@ def do_GET(self):
464465
max_bytes=max_bytes,
465466
tail=tail,
466467
drop=drop,
468+
grep=grep,
467469
)
468-
result["success"] = True
470+
# Match the real route: success flips false on invalid grep.
471+
result["success"] = not result.get("invalid_grep", False)
469472
body = json.dumps(result).encode()
470473
self.send_response(200)
471474
self.send_header("Content-Type", "application/json")
@@ -599,6 +602,43 @@ def test_serial_read_incremental_workflow(self):
599602
_CursorMockHandler.log_entries.pop()
600603
cli.cleanup()
601604

605+
def test_serial_read_grep_filters_via_proxy(self):
606+
"""serial_read(grep=...) forwards to /api/serial/read; only matches
607+
come back, and the JSON envelope carries the same schema."""
608+
_CursorMockHandler.log_entries.extend(
609+
[
610+
{"id": 3, "data": "boot: ok\n"},
611+
{"id": 4, "data": "PANIC: null deref\n"},
612+
{"id": 5, "data": "boot: heartbeat\n"},
613+
]
614+
)
615+
try:
616+
cli = self._make_cli()
617+
buf = io.StringIO()
618+
with redirect_stdout(buf):
619+
cli.serial_read(grep="PANIC")
620+
result = json.loads(buf.getvalue())
621+
self.assertTrue(result["success"])
622+
self.assertIn("PANIC", result["data"])
623+
self.assertNotIn("boot:", result["data"])
624+
cli.cleanup()
625+
finally:
626+
del _CursorMockHandler.log_entries[3:]
627+
628+
def test_serial_read_invalid_grep_surfaces_error(self):
629+
"""Invalid regex must round-trip as success=false + invalid_grep=true
630+
so JSON callers can branch without regex-matching the error string."""
631+
cli = self._make_cli()
632+
buf = io.StringIO()
633+
with redirect_stdout(buf):
634+
cli.serial_read(grep="[unclosed")
635+
result = json.loads(buf.getvalue())
636+
self.assertFalse(result["success"])
637+
self.assertTrue(result.get("invalid_grep"))
638+
self.assertIn("invalid grep pattern", result.get("error", ""))
639+
self.assertEqual(result["data"], "")
640+
cli.cleanup()
641+
602642

603643
class TestFPBCLIIsRemoteUrl(unittest.TestCase):
604644
"""Test the URL locality classifier."""

Tools/WebServer/tests/test_routes.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,6 +1647,42 @@ def test_serial_read_window_overflow_flag(self):
16471647
self.assertTrue(data["success"])
16481648
self.assertTrue(data["buffer_overflowed"])
16491649

1650+
def test_serial_read_window_grep_filters_before_budget(self):
1651+
"""?grep=... keeps only matching entries; non-matches don't count."""
1652+
state.device.raw_serial_log = [
1653+
{"id": 0, "data": "boot: idle\n"},
1654+
{"id": 1, "data": "PANIC: null deref\n"},
1655+
{"id": 2, "data": "boot: ok\n"},
1656+
{"id": 3, "data": "assert: overflow\n"},
1657+
]
1658+
state.device.raw_log_next_id = 4
1659+
1660+
response = self.client.get(
1661+
"/api/serial/read?tail=4096&max_bytes=4096&grep=PANIC%7Cassert"
1662+
)
1663+
data = json.loads(response.data)
1664+
1665+
self.assertTrue(data["success"])
1666+
self.assertIn("PANIC", data["data"])
1667+
self.assertIn("assert", data["data"])
1668+
self.assertNotIn("boot:", data["data"])
1669+
1670+
def test_serial_read_window_grep_invalid_regex(self):
1671+
"""Invalid regex returns a structured error, not a 500."""
1672+
state.device.raw_serial_log = [{"id": 0, "data": "hi\n"}]
1673+
state.device.raw_log_next_id = 1
1674+
1675+
response = self.client.get("/api/serial/read?tail=4096&grep=%5Bunclosed")
1676+
self.assertEqual(response.status_code, 200)
1677+
data = json.loads(response.data)
1678+
1679+
# success flips to false when the pattern is invalid so JSON callers
1680+
# can branch on it; the error text tells the human what went wrong.
1681+
self.assertFalse(data["success"])
1682+
self.assertTrue(data.get("invalid_grep"))
1683+
self.assertIn("invalid grep pattern", data.get("error", ""))
1684+
self.assertEqual(data["data"], "")
1685+
16501686
def test_serial_send_no_data(self):
16511687
"""Test serial send without data"""
16521688
response = self.client.post(

0 commit comments

Comments
 (0)