create-caspian-app 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,505 +1,536 @@
1
- """Read the dev-session browser log and report per-route front-end health.
2
-
3
- Why this exists
4
- ---------------
5
- `settings/dev-log-bridge.ts` forwards browser-side PulsePoint errors into the
6
- `npm run dev` terminal. That only helps whoever owns that terminal: an AI agent
7
- working in a different session cannot see stdout, and spawning a second
8
- `npm run dev` to get its own copy would bind different ports and orphan the
9
- browser tab the developer is actually looking at.
10
-
11
- So the bridge also appends every event to `.casp/browser-log.jsonl`, and this
12
- script renders it. `npm run logs`.
13
-
14
- The hard part is not reading errors, it is not lying about their absence. Three
15
- ways a naive error log misleads a reader, and how the format answers each:
16
-
17
- * **Empty is ambiguous.** No errors could mean the route is fine, or that nobody
18
- ever opened it, or that the dev server is not running. The log records `load`
19
- events and a `session` header, so those three are distinguishable and this
20
- script names them separately.
21
- * **Fixed errors look current.** A clean reload writes nothing, so an error from
22
- before the fix would sit in the file forever. Because every page load is
23
- recorded, a route's state is whatever happened during its *most recent* load.
24
- * **A reload does not re-test everything.** It re-runs mount, so it genuinely
25
- clears a mount-phase error -- but it never clicks a button. An error from a
26
- click handler survives the reload as NEEDS RECHECK instead of being reported
27
- CLEAN, which is how a live bug would otherwise get signed off.
28
- * **Reports race.** Two POSTs can arrive out of order, so an error is tied to its
29
- load by the client-generated `page` id, never by arrival time.
30
-
31
- Anyone reading the raw JSONL would see a fixed error as current, so the `session`
32
- line carries a `readme` explaining the supersession rule and a clean reload
33
- appends an explicit `resolved` event. An error whose `page` never produced a
34
- `load` in this log -- a tab left open across a dev restart -- is reported as
35
- UNCONFIRMED rather than as a fresh failure.
36
-
37
- Every source change compacts the file down to the session header, a `restart`
38
- marker, and the errors still open, so a dev session that runs for hours without a
39
- restart cannot grow an unbounded log. Errors that survive a compaction are marked
40
- `carried` and dropped at the next one, so a stale interaction error cannot haunt
41
- the log forever.
42
-
43
- Usage:
44
-
45
- python settings/browser_log.py # human-readable digest
46
- python settings/browser_log.py --json # machine-readable status
47
- python settings/browser_log.py --fail-on-error # exit 1 if any route is dirty
48
-
49
- Exit code is 0 by default even when routes are failing: whether a route has been
50
- exercised depends on someone clicking around in a browser, so this must never
51
- become a flaky pass/fail gate. `settings/check.py` prints it, and does not let it
52
- change the gate's exit code.
53
- """
54
-
55
- from __future__ import annotations
56
-
57
- import argparse
58
- import json
59
- import socket
60
- import sys
61
- from dataclasses import dataclass, field
62
- from datetime import datetime, timezone
63
- from pathlib import Path
64
- from typing import Any
65
-
66
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
67
- LOG_FILE = PROJECT_ROOT / ".casp" / "browser-log.jsonl"
68
-
69
- try:
70
- sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
71
- except AttributeError, ValueError:
72
- pass
73
-
74
- _TTY = sys.stdout.isatty()
75
-
76
-
77
- def _c(code: str, text: str) -> str:
78
- return f"\033[{code}m{text}\033[0m" if _TTY else text
79
-
80
-
81
- def red(t: str) -> str:
82
- return _c("31", t)
83
-
84
-
85
- def green(t: str) -> str:
86
- return _c("32", t)
87
-
88
-
89
- def yellow(t: str) -> str:
90
- return _c("33", t)
91
-
92
-
93
- def gray(t: str) -> str:
94
- return _c("90", t)
95
-
96
-
97
- def bold(t: str) -> str:
98
- return _c("1", t)
99
-
100
-
101
- def cyan(t: str) -> str:
102
- return _c("36", t)
103
-
104
-
105
- @dataclass
106
- class PageLoad:
107
- """One browser page load and everything the runtime reported during it."""
108
-
109
- page: str
110
- route: str
111
- at: str = ""
112
- errors: list[dict[str, Any]] = field(default_factory=list)
113
- warnings: list[dict[str, Any]] = field(default_factory=list)
114
- #: True when the error arrived but the matching `load` event never did.
115
- orphan: bool = False
116
-
117
-
118
- @dataclass
119
- class RouteStatus:
120
- route: str
121
- last_load: str
122
- errors: list[dict[str, Any]]
123
- warnings: list[dict[str, Any]]
124
- #: Mount errors from *earlier* loads, retested and cleared by a later load.
125
- healed: int
126
- #: The newest errors came from a page with no `load` in this log -- typically
127
- #: a tab opened before the last dev restart. Real, but possibly already fixed.
128
- unconfirmed: bool = False
129
- #: Interaction errors a reload could not retest, plus errors carried across a
130
- #: source change. Not proof of a live bug, and not proof of a fix either.
131
- recheck: list[dict[str, Any]] = field(default_factory=list)
132
-
133
- @property
134
- def clean(self) -> bool:
135
- return not self.errors and not self.recheck
136
-
137
-
138
- @dataclass
139
- class LogReport:
140
- """Everything a caller needs to describe front-end health without guessing."""
141
-
142
- #: "missing" (no dev session ever wrote), "live", "ended", "stale".
143
- session: str
144
- started: str = ""
145
- pid: int = 0
146
- port: int = 0
147
- routes: list[RouteStatus] = field(default_factory=list)
148
- #: When the log was last compacted because source files changed.
149
- last_restart: str = ""
150
-
151
- @property
152
- def failing(self) -> list[RouteStatus]:
153
- return [r for r in self.routes if not r.clean]
154
-
155
- @property
156
- def observed(self) -> bool:
157
- return bool(self.routes)
158
-
159
-
160
- def _read_events(path: Path) -> list[dict[str, Any]]:
161
- if not path.exists():
162
- return []
163
- events: list[dict[str, Any]] = []
164
- for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
165
- line = line.strip()
166
- if not line:
167
- continue
168
- try:
169
- parsed = json.loads(line)
170
- except json.JSONDecodeError:
171
- # A torn final line (server killed mid-write) must not hide the rest.
172
- continue
173
- if isinstance(parsed, dict):
174
- events.append(parsed)
175
- return events
176
-
177
-
178
- def _port_is_listening(port: int) -> bool:
179
- if not port:
180
- return False
181
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
182
- sock.settimeout(0.25)
183
- return sock.connect_ex(("127.0.0.1", port)) == 0
184
-
185
-
186
- def build_report(path: Path = LOG_FILE) -> LogReport:
187
- """Collapse the raw event stream into current per-route status."""
188
- events = _read_events(path)
189
- if not events:
190
- return LogReport(session="missing")
191
-
192
- started = ""
193
- pid = 0
194
- port = 0
195
- ended = False
196
- last_restart = ""
197
- for event in events:
198
- if event.get("type") == "session":
199
- started = str(event.get("t") or "")
200
- pid = int(event.get("pid") or 0)
201
- port = int(event.get("port") or 0)
202
- ended = False
203
- elif event.get("type") == "session-end":
204
- ended = True
205
- elif event.get("type") == "restart":
206
- last_restart = str(event.get("t") or "")
207
-
208
- # Group by the client's page id so an error is attributed to the load that
209
- # produced it regardless of the order the two POSTs landed in.
210
- pages: dict[str, PageLoad] = {}
211
- order: list[str] = []
212
- # Errors that survived a compaction. Their `load` event was dropped with the
213
- # rest of the history, so they are tracked by route instead of by page.
214
- carried: dict[str, list[dict[str, Any]]] = {}
215
-
216
- def _page_for(event: dict[str, Any]) -> PageLoad:
217
- key = str(event.get("page") or f"anon-{len(order)}")
218
- if key not in pages:
219
- pages[key] = PageLoad(
220
- page=key,
221
- route=str(event.get("route") or "?"),
222
- at=str(event.get("t") or ""),
223
- orphan=True,
224
- )
225
- order.append(key)
226
- return pages[key]
227
-
228
- for event in events:
229
- kind = event.get("type")
230
- if event.get("carried"):
231
- carried.setdefault(str(event.get("route") or "?"), []).append(event)
232
- elif kind == "load":
233
- page = _page_for(event)
234
- page.orphan = False
235
- page.at = str(event.get("t") or page.at)
236
- page.route = str(event.get("route") or page.route)
237
- elif kind == "error":
238
- _page_for(event).errors.append(event)
239
- elif kind == "warn":
240
- _page_for(event).warnings.append(event)
241
-
242
- # Latest load wins: an error is only current if it happened on the most
243
- # recent load of that route.
244
- by_route: dict[str, list[PageLoad]] = {}
245
- for key in order:
246
- page = pages[key]
247
- by_route.setdefault(page.route, []).append(page)
248
-
249
- routes: list[RouteStatus] = []
250
- for route in sorted(set(by_route) | set(carried)):
251
- loads = by_route.get(route, [])
252
- # Carried errors always need rechecking: the code changed under them.
253
- recheck = list(carried.get(route, []))
254
-
255
- if not loads:
256
- routes.append(
257
- RouteStatus(
258
- route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck
259
- )
260
- )
261
- continue
262
-
263
- latest = loads[-1]
264
- healed = 0
265
- for page in loads[:-1]:
266
- for err in page.errors:
267
- # A later load re-ran mount, so a mount error is genuinely
268
- # retested. An interaction error is not: nothing reloaded here
269
- # clicked anything, so it stays open rather than reading CLEAN.
270
- if err.get("phase") == "interaction":
271
- recheck.append(err)
272
- else:
273
- healed += 1
274
-
275
- routes.append(
276
- RouteStatus(
277
- route=route,
278
- last_load=latest.at,
279
- errors=latest.errors,
280
- warnings=latest.warnings,
281
- healed=healed,
282
- unconfirmed=latest.orphan,
283
- recheck=recheck,
284
- )
285
- )
286
-
287
- if ended:
288
- session = "ended"
289
- elif _port_is_listening(port):
290
- session = "live"
291
- else:
292
- session = "stale"
293
-
294
- return LogReport(
295
- session=session,
296
- started=started,
297
- pid=pid,
298
- port=port,
299
- routes=routes,
300
- last_restart=last_restart,
301
- )
302
-
303
-
304
- def _age(iso: str) -> str:
305
- if not iso:
306
- return ""
307
- try:
308
- moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
309
- except ValueError:
310
- return ""
311
- seconds = int((datetime.now(timezone.utc) - moment).total_seconds())
312
- if seconds < 60:
313
- return f"{seconds}s ago"
314
- if seconds < 3600:
315
- return f"{seconds // 60}m ago"
316
- return f"{seconds // 3600}h ago"
317
-
318
-
319
- def _clock(iso: str) -> str:
320
- if not iso:
321
- return "?"
322
- try:
323
- return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone().strftime("%H:%M:%S")
324
- except ValueError:
325
- return iso
326
-
327
-
328
- _SESSION_LINES = {
329
- "missing": (
330
- "no browser log for this dev session",
331
- "Nothing has been observed. Start `npm run dev` and open the route "
332
- "in a browser before drawing any conclusion about the front end.",
333
- ),
334
- "stale": (
335
- "dev server is NOT running",
336
- "This log is left over from an exited dev session. Treat every line "
337
- "below as history, not as the current state of the app.",
338
- ),
339
- "ended": (
340
- "dev session ended cleanly",
341
- "The dev server has shut down. The results below are from that finished session.",
342
- ),
343
- }
344
-
345
-
346
- def _print_errors(errors: list[dict[str, Any]]) -> None:
347
- """Print each distinct message once, with the frames that locate it."""
348
- seen: set[str] = set()
349
- for err in errors:
350
- message = str(err.get("message") or "").strip()
351
- if message in seen:
352
- continue
353
- seen.add(message)
354
- after = err.get("afterMs")
355
- timing = ""
356
- if err.get("phase") == "interaction" and isinstance(after, int):
357
- timing = gray(f" (fired {after // 1000}s after load -- interaction, not mount)")
358
- for line in message.split("\n"):
359
- print(f" {red(line)}{timing}")
360
- timing = ""
361
- for frame in list(err.get("stack") or [])[:2]:
362
- print(gray(f" {frame}"))
363
-
364
-
365
- def print_report(report: LogReport) -> None:
366
- print()
367
- print(bold("Browser log") + gray(f" ({LOG_FILE.relative_to(PROJECT_ROOT)})"))
368
- print("=" * 60)
369
-
370
- if report.session == "live":
371
- age = _age(report.started)
372
- detail = f"pid {report.pid}, port {report.port}, started {_clock(report.started)}"
373
- print(
374
- f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}"
375
- )
376
- else:
377
- headline, advice = _SESSION_LINES[report.session]
378
- mark = yellow("WARN") if report.session != "missing" else gray("NONE")
379
- print(f" {mark} {headline}")
380
- print(gray(f" {advice}"))
381
- if report.session == "missing":
382
- print()
383
- return
384
-
385
- print()
386
-
387
- if not report.observed:
388
- print(yellow(" No page loads recorded yet."))
389
- print(gray(" The log only knows about routes someone actually opened."))
390
- print(gray(" An empty log is not evidence that the front end is healthy."))
391
- print()
392
- return
393
-
394
- for status in sorted(report.routes, key=lambda r: (r.clean, r.route)):
395
- # Compaction drops load events, so a carried-only route has no load time.
396
- when = (
397
- gray(f"last load {_clock(status.last_load)} {_age(status.last_load)}")
398
- if status.last_load
399
- else gray("no load since the last source change")
400
- )
401
- if status.clean:
402
- healed = gray(f" ({status.healed} earlier error(s) resolved)") if status.healed else ""
403
- warn = yellow(f" {len(status.warnings)} warning(s)") if status.warnings else ""
404
- print(f" {green('CLEAN')} {cyan(status.route)} {when}{warn}{healed}")
405
- continue
406
-
407
- if status.errors:
408
- label = "UNCONFIRMED" if status.unconfirmed else f"{len(status.errors)} ERROR(S)"
409
- else:
410
- label = "NEEDS RECHECK"
411
- header = f" {red(bold(label))} {cyan(status.route)}"
412
- print(header if status.unconfirmed else f"{header} {when}")
413
-
414
- if status.unconfirmed:
415
- # No matching load: the reporting tab was opened before this log
416
- # existed, so the error may already be fixed. Say that plainly rather
417
- # than sending someone hunting a bug that no longer exists.
418
- print(
419
- gray(
420
- " Reported by a page loaded before this log started "
421
- "(e.g. before the last dev restart)."
422
- )
423
- )
424
- print(gray(" Reload the route and re-run to confirm whether it is still live."))
425
-
426
- _print_errors(status.errors)
427
-
428
- if status.recheck:
429
- # The critical honesty case: a reload re-runs mount, so it clears a
430
- # mount error -- but it never clicks a button. Reporting these as
431
- # CLEAN is how a live bug gets signed off.
432
- print(gray(" Not re-tested by the reloads since:"))
433
- _print_errors(status.recheck)
434
- if any(e.get("carried") for e in status.recheck):
435
- print(gray(" Source changed since these fired; they may already be fixed."))
436
- print(gray(" Repeat the interaction (click/submit) and re-run to confirm."))
437
-
438
- print()
439
- if report.failing:
440
- print(red(bold(f"{len(report.failing)} route(s) failing in the browser.")))
441
- else:
442
- print(green(bold("Every route loaded this session is clean.")))
443
- print(gray("Routes not listed were never opened -- that is no signal, not a pass."))
444
- print()
445
-
446
-
447
- def to_json(report: LogReport) -> str:
448
- return json.dumps(
449
- {
450
- "session": report.session,
451
- "started": report.started,
452
- "pid": report.pid,
453
- "port": report.port,
454
- "lastRestart": report.last_restart,
455
- "routes": [
456
- {
457
- "route": r.route,
458
- "lastLoad": r.last_load,
459
- "clean": r.clean,
460
- "unconfirmed": r.unconfirmed,
461
- "needsRecheck": [
462
- {
463
- "message": e.get("message"),
464
- "phase": e.get("phase"),
465
- "carried": bool(e.get("carried")),
466
- }
467
- for e in r.recheck
468
- ],
469
- "errors": [
470
- {"message": e.get("message"), "stack": e.get("stack")} for e in r.errors
471
- ],
472
- "warnings": [{"message": w.get("message")} for w in r.warnings],
473
- "healed": r.healed,
474
- }
475
- for r in report.routes
476
- ],
477
- },
478
- indent=2,
479
- )
480
-
481
-
482
- def main() -> int:
483
- parser = argparse.ArgumentParser(description="Report browser-side health for this dev session.")
484
- parser.add_argument("--json", action="store_true", help="Emit machine-readable status.")
485
- parser.add_argument(
486
- "--fail-on-error",
487
- action="store_true",
488
- help="Exit 1 when a route is currently failing (off by default: presence of "
489
- "a signal depends on someone opening the page, so it is not a stable gate).",
490
- )
491
- args = parser.parse_args()
492
-
493
- report = build_report()
494
- if args.json:
495
- print(to_json(report))
496
- else:
497
- print_report(report)
498
-
499
- if args.fail_on_error and report.session == "live" and report.failing:
500
- return 1
501
- return 0
502
-
503
-
504
- if __name__ == "__main__":
505
- raise SystemExit(main())
1
+ """Read the dev-session browser log and report per-route front-end health.
2
+
3
+ Why this exists
4
+ ---------------
5
+ `settings/dev-log-bridge.ts` forwards browser-side PulsePoint errors into the
6
+ `npm run dev` terminal. That only helps whoever owns that terminal: an AI agent
7
+ working in a different session cannot see stdout, and spawning a second
8
+ `npm run dev` to get its own copy would bind different ports and orphan the
9
+ browser tab the developer is actually looking at.
10
+
11
+ So the bridge also appends every event to `.casp/browser-log.jsonl`, and this
12
+ script renders it. `npm run logs`.
13
+
14
+ The hard part is not reading errors, it is not lying about their absence. Three
15
+ ways a naive error log misleads a reader, and how the format answers each:
16
+
17
+ * **Empty is ambiguous.** No errors could mean the route is fine, or that nobody
18
+ ever opened it, or that the dev server is not running. The log records `load`
19
+ events and a `session` header, so those three are distinguishable and this
20
+ script names them separately.
21
+ * **Fixed errors look current.** A clean reload writes nothing, so an error from
22
+ before the fix would sit in the file forever. Because every page load is
23
+ recorded, a route's state is whatever happened during its *most recent* load.
24
+ * **A reload does not re-test everything.** It re-runs mount, so it genuinely
25
+ clears a mount-phase error -- but it never clicks a button. An error from a
26
+ click handler survives the reload as NEEDS RECHECK instead of being reported
27
+ CLEAN, which is how a live bug would otherwise get signed off.
28
+ * **Reports race.** Two POSTs can arrive out of order, so an error is tied to its
29
+ load by the client-generated `page` id, never by arrival time.
30
+
31
+ Anyone reading the raw JSONL would see a fixed error as current, so the `session`
32
+ line carries a `readme` explaining the supersession rule and a clean reload
33
+ appends an explicit `resolved` event. An error whose `page` never produced a
34
+ `load` in this log -- a tab left open across a dev restart -- is reported as
35
+ UNCONFIRMED rather than as a fresh failure.
36
+
37
+ Every source change compacts the file down to the session header, a `restart`
38
+ marker, and the errors still open, so a dev session that runs for hours without a
39
+ restart cannot grow an unbounded log. Errors that survive a compaction are marked
40
+ `carried` and dropped at the next one, so a stale interaction error cannot haunt
41
+ the log forever.
42
+
43
+ Usage:
44
+
45
+ python settings/browser_log.py # human-readable digest
46
+ python settings/browser_log.py --json # machine-readable status
47
+ python settings/browser_log.py --fail-on-error # exit 1 if any route is dirty
48
+
49
+ Exit code is 0 by default even when routes are failing: whether a route has been
50
+ exercised depends on someone clicking around in a browser, so this must never
51
+ become a flaky pass/fail gate. `settings/check.py` prints it, and does not let it
52
+ change the gate's exit code.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import argparse
58
+ import json
59
+ import socket
60
+ import sys
61
+ import time
62
+ from dataclasses import dataclass, field
63
+ from datetime import datetime, timezone
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
68
+ LOG_FILE = PROJECT_ROOT / ".casp" / "browser-log.jsonl"
69
+
70
+ try:
71
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
72
+ except AttributeError, ValueError:
73
+ pass
74
+
75
+ _TTY = sys.stdout.isatty()
76
+
77
+
78
+ def _c(code: str, text: str) -> str:
79
+ return f"\033[{code}m{text}\033[0m" if _TTY else text
80
+
81
+
82
+ def red(t: str) -> str:
83
+ return _c("31", t)
84
+
85
+
86
+ def green(t: str) -> str:
87
+ return _c("32", t)
88
+
89
+
90
+ def yellow(t: str) -> str:
91
+ return _c("33", t)
92
+
93
+
94
+ def gray(t: str) -> str:
95
+ return _c("90", t)
96
+
97
+
98
+ def bold(t: str) -> str:
99
+ return _c("1", t)
100
+
101
+
102
+ def cyan(t: str) -> str:
103
+ return _c("36", t)
104
+
105
+
106
+ @dataclass
107
+ class PageLoad:
108
+ """One browser page load and everything the runtime reported during it."""
109
+
110
+ page: str
111
+ route: str
112
+ at: str = ""
113
+ errors: list[dict[str, Any]] = field(default_factory=list)
114
+ warnings: list[dict[str, Any]] = field(default_factory=list)
115
+ #: True when the error arrived but the matching `load` event never did.
116
+ orphan: bool = False
117
+
118
+
119
+ @dataclass
120
+ class RouteStatus:
121
+ route: str
122
+ last_load: str
123
+ errors: list[dict[str, Any]]
124
+ warnings: list[dict[str, Any]]
125
+ #: Mount errors from *earlier* loads, retested and cleared by a later load.
126
+ healed: int
127
+ #: The newest errors came from a page with no `load` in this log -- typically
128
+ #: a tab opened before the last dev restart. Real, but possibly already fixed.
129
+ unconfirmed: bool = False
130
+ #: Interaction errors a reload could not retest, plus errors carried across a
131
+ #: source change. Not proof of a live bug, and not proof of a fix either.
132
+ recheck: list[dict[str, Any]] = field(default_factory=list)
133
+
134
+ @property
135
+ def clean(self) -> bool:
136
+ return not self.errors and not self.recheck
137
+
138
+
139
+ @dataclass
140
+ class LogReport:
141
+ """Everything a caller needs to describe front-end health without guessing."""
142
+
143
+ #: "missing" (no dev session ever wrote), "live", "ended", "stale".
144
+ session: str
145
+ started: str = ""
146
+ pid: int = 0
147
+ port: int = 0
148
+ routes: list[RouteStatus] = field(default_factory=list)
149
+ #: When the log was last compacted because source files changed.
150
+ last_restart: str = ""
151
+
152
+ @property
153
+ def failing(self) -> list[RouteStatus]:
154
+ return [r for r in self.routes if not r.clean]
155
+
156
+ @property
157
+ def observed(self) -> bool:
158
+ return bool(self.routes)
159
+
160
+
161
+ def _read_events(path: Path) -> list[dict[str, Any]]:
162
+ if not path.exists():
163
+ return []
164
+ events: list[dict[str, Any]] = []
165
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
166
+ line = line.strip()
167
+ if not line:
168
+ continue
169
+ try:
170
+ parsed = json.loads(line)
171
+ except json.JSONDecodeError:
172
+ # A torn final line (server killed mid-write) must not hide the rest.
173
+ continue
174
+ if isinstance(parsed, dict):
175
+ events.append(parsed)
176
+ return events
177
+
178
+
179
+ def _port_is_listening(port: int) -> bool:
180
+ if not port:
181
+ return False
182
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
183
+ sock.settimeout(0.25)
184
+ return sock.connect_ex(("127.0.0.1", port)) == 0
185
+
186
+
187
+ def build_report(path: Path = LOG_FILE) -> LogReport:
188
+ """Collapse the raw event stream into current per-route status."""
189
+ events = _read_events(path)
190
+ if not events:
191
+ return LogReport(session="missing")
192
+
193
+ started = ""
194
+ pid = 0
195
+ port = 0
196
+ ended = False
197
+ last_restart = ""
198
+ for event in events:
199
+ if event.get("type") == "session":
200
+ started = str(event.get("t") or "")
201
+ pid = int(event.get("pid") or 0)
202
+ port = int(event.get("port") or 0)
203
+ ended = False
204
+ elif event.get("type") == "session-end":
205
+ ended = True
206
+ elif event.get("type") == "restart":
207
+ last_restart = str(event.get("t") or "")
208
+
209
+ # Group by the client's page id so an error is attributed to the load that
210
+ # produced it regardless of the order the two POSTs landed in.
211
+ pages: dict[str, PageLoad] = {}
212
+ order: list[str] = []
213
+ # Errors that survived a compaction. Their `load` event was dropped with the
214
+ # rest of the history, so they are tracked by route instead of by page.
215
+ carried: dict[str, list[dict[str, Any]]] = {}
216
+
217
+ def _page_for(event: dict[str, Any]) -> PageLoad:
218
+ key = str(event.get("page") or f"anon-{len(order)}")
219
+ if key not in pages:
220
+ pages[key] = PageLoad(
221
+ page=key,
222
+ route=str(event.get("route") or "?"),
223
+ at=str(event.get("t") or ""),
224
+ orphan=True,
225
+ )
226
+ order.append(key)
227
+ return pages[key]
228
+
229
+ for event in events:
230
+ kind = event.get("type")
231
+ if event.get("carried"):
232
+ carried.setdefault(str(event.get("route") or "?"), []).append(event)
233
+ elif kind == "load":
234
+ page = _page_for(event)
235
+ page.orphan = False
236
+ page.at = str(event.get("t") or page.at)
237
+ page.route = str(event.get("route") or page.route)
238
+ elif kind == "error":
239
+ _page_for(event).errors.append(event)
240
+ elif kind == "warn":
241
+ _page_for(event).warnings.append(event)
242
+
243
+ # Latest load wins: an error is only current if it happened on the most
244
+ # recent load of that route.
245
+ by_route: dict[str, list[PageLoad]] = {}
246
+ for key in order:
247
+ page = pages[key]
248
+ by_route.setdefault(page.route, []).append(page)
249
+
250
+ routes: list[RouteStatus] = []
251
+ for route in sorted(set(by_route) | set(carried)):
252
+ loads = by_route.get(route, [])
253
+ # Carried errors always need rechecking: the code changed under them.
254
+ recheck = list(carried.get(route, []))
255
+
256
+ if not loads:
257
+ routes.append(
258
+ RouteStatus(
259
+ route=route, last_load="", errors=[], warnings=[], healed=0, recheck=recheck
260
+ )
261
+ )
262
+ continue
263
+
264
+ latest = loads[-1]
265
+ healed = 0
266
+ for page in loads[:-1]:
267
+ for err in page.errors:
268
+ # A later load re-ran mount, so a mount error is genuinely
269
+ # retested. An interaction error is not: nothing reloaded here
270
+ # clicked anything, so it stays open rather than reading CLEAN.
271
+ if err.get("phase") == "interaction":
272
+ recheck.append(err)
273
+ else:
274
+ healed += 1
275
+
276
+ routes.append(
277
+ RouteStatus(
278
+ route=route,
279
+ last_load=latest.at,
280
+ errors=latest.errors,
281
+ warnings=latest.warnings,
282
+ healed=healed,
283
+ unconfirmed=latest.orphan,
284
+ recheck=recheck,
285
+ )
286
+ )
287
+
288
+ if ended:
289
+ session = "ended"
290
+ elif _port_is_listening(port):
291
+ session = "live"
292
+ else:
293
+ session = "stale"
294
+
295
+ return LogReport(
296
+ session=session,
297
+ started=started,
298
+ pid=pid,
299
+ port=port,
300
+ routes=routes,
301
+ last_restart=last_restart,
302
+ )
303
+
304
+
305
+ def _age(iso: str) -> str:
306
+ if not iso:
307
+ return ""
308
+ try:
309
+ moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
310
+ except ValueError:
311
+ return ""
312
+ seconds = int((datetime.now(timezone.utc) - moment).total_seconds())
313
+ if seconds < 60:
314
+ return f"{seconds}s ago"
315
+ if seconds < 3600:
316
+ return f"{seconds // 60}m ago"
317
+ return f"{seconds // 3600}h ago"
318
+
319
+
320
+ def _clock(iso: str) -> str:
321
+ if not iso:
322
+ return "?"
323
+ try:
324
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone().strftime("%H:%M:%S")
325
+ except ValueError:
326
+ return iso
327
+
328
+
329
+ _SESSION_LINES = {
330
+ "missing": (
331
+ "no browser log for this dev session",
332
+ "Nothing has been observed. Start `npm run dev` and open the route "
333
+ "in a browser before drawing any conclusion about the front end.",
334
+ ),
335
+ "stale": (
336
+ "dev server is NOT running",
337
+ "This log is left over from an exited dev session. Treat every line "
338
+ "below as history, not as the current state of the app.",
339
+ ),
340
+ "ended": (
341
+ "dev session ended cleanly",
342
+ "The dev server has shut down. The results below are from that finished session.",
343
+ ),
344
+ }
345
+
346
+
347
+ def _print_errors(errors: list[dict[str, Any]]) -> None:
348
+ """Print each distinct message once, with the frames that locate it."""
349
+ seen: set[str] = set()
350
+ for err in errors:
351
+ message = str(err.get("message") or "").strip()
352
+ if message in seen:
353
+ continue
354
+ seen.add(message)
355
+ after = err.get("afterMs")
356
+ timing = ""
357
+ if err.get("phase") == "interaction" and isinstance(after, int):
358
+ timing = gray(f" (fired {after // 1000}s after load -- interaction, not mount)")
359
+ for line in message.split("\n"):
360
+ print(f" {red(line)}{timing}")
361
+ timing = ""
362
+ for frame in list(err.get("stack") or [])[:2]:
363
+ print(gray(f" {frame}"))
364
+
365
+
366
+ def _print_dev_hold_warning() -> None:
367
+ """
368
+ Warn when a dev hold is suppressing reloads.
369
+
370
+ While an agent holds the dev stack, no restart or reload has happened since
371
+ its edits landed, so every line below was produced by code that has already
372
+ changed. Reading this digest as current state is exactly how an agent signs
373
+ off on a bug it has not actually retested.
374
+ """
375
+ try:
376
+ hold = json.loads((PROJECT_ROOT / ".casp" / "dev-hold.json").read_text("utf-8"))
377
+ touched_at = float(hold["touchedAt"]) / 1000
378
+ acquired_at = float(hold["acquiredAt"]) / 1000
379
+ edits = int(hold["edits"])
380
+ except OSError, ValueError, KeyError, TypeError:
381
+ return
382
+
383
+ now = time.time()
384
+ # Mirrors STALE_HOLD_MS / MAX_HOLD_MS in settings/dev-hold.ts.
385
+ if now - touched_at > 120 or now - acquired_at > 600:
386
+ return
387
+
388
+ print()
389
+ print(yellow(bold(" DEV HOLD ACTIVE -- this digest is stale.")))
390
+ print(gray(f" {edits} edit(s) are queued but not applied: the server has not"))
391
+ print(gray(" restarted and the browser has not reloaded since they landed."))
392
+ print(gray(" Run `npm run dev:resume`, reload the route, then re-run this command."))
393
+
394
+
395
+ def print_report(report: LogReport) -> None:
396
+ print()
397
+ print(bold("Browser log") + gray(f" ({LOG_FILE.relative_to(PROJECT_ROOT)})"))
398
+ print("=" * 60)
399
+ _print_dev_hold_warning()
400
+
401
+ if report.session == "live":
402
+ age = _age(report.started)
403
+ detail = f"pid {report.pid}, port {report.port}, started {_clock(report.started)}"
404
+ print(
405
+ f" {green('LIVE')} dev session active {gray(f'({detail}{", " + age if age else ""})')}"
406
+ )
407
+ else:
408
+ headline, advice = _SESSION_LINES[report.session]
409
+ mark = yellow("WARN") if report.session != "missing" else gray("NONE")
410
+ print(f" {mark} {headline}")
411
+ print(gray(f" {advice}"))
412
+ if report.session == "missing":
413
+ print()
414
+ return
415
+
416
+ print()
417
+
418
+ if not report.observed:
419
+ print(yellow(" No page loads recorded yet."))
420
+ print(gray(" The log only knows about routes someone actually opened."))
421
+ print(gray(" An empty log is not evidence that the front end is healthy."))
422
+ print()
423
+ return
424
+
425
+ for status in sorted(report.routes, key=lambda r: (r.clean, r.route)):
426
+ # Compaction drops load events, so a carried-only route has no load time.
427
+ when = (
428
+ gray(f"last load {_clock(status.last_load)} {_age(status.last_load)}")
429
+ if status.last_load
430
+ else gray("no load since the last source change")
431
+ )
432
+ if status.clean:
433
+ healed = gray(f" ({status.healed} earlier error(s) resolved)") if status.healed else ""
434
+ warn = yellow(f" {len(status.warnings)} warning(s)") if status.warnings else ""
435
+ print(f" {green('CLEAN')} {cyan(status.route)} {when}{warn}{healed}")
436
+ continue
437
+
438
+ if status.errors:
439
+ label = "UNCONFIRMED" if status.unconfirmed else f"{len(status.errors)} ERROR(S)"
440
+ else:
441
+ label = "NEEDS RECHECK"
442
+ header = f" {red(bold(label))} {cyan(status.route)}"
443
+ print(header if status.unconfirmed else f"{header} {when}")
444
+
445
+ if status.unconfirmed:
446
+ # No matching load: the reporting tab was opened before this log
447
+ # existed, so the error may already be fixed. Say that plainly rather
448
+ # than sending someone hunting a bug that no longer exists.
449
+ print(
450
+ gray(
451
+ " Reported by a page loaded before this log started "
452
+ "(e.g. before the last dev restart)."
453
+ )
454
+ )
455
+ print(gray(" Reload the route and re-run to confirm whether it is still live."))
456
+
457
+ _print_errors(status.errors)
458
+
459
+ if status.recheck:
460
+ # The critical honesty case: a reload re-runs mount, so it clears a
461
+ # mount error -- but it never clicks a button. Reporting these as
462
+ # CLEAN is how a live bug gets signed off.
463
+ print(gray(" Not re-tested by the reloads since:"))
464
+ _print_errors(status.recheck)
465
+ if any(e.get("carried") for e in status.recheck):
466
+ print(gray(" Source changed since these fired; they may already be fixed."))
467
+ print(gray(" Repeat the interaction (click/submit) and re-run to confirm."))
468
+
469
+ print()
470
+ if report.failing:
471
+ print(red(bold(f"{len(report.failing)} route(s) failing in the browser.")))
472
+ else:
473
+ print(green(bold("Every route loaded this session is clean.")))
474
+ print(gray("Routes not listed were never opened -- that is no signal, not a pass."))
475
+ print()
476
+
477
+
478
+ def to_json(report: LogReport) -> str:
479
+ return json.dumps(
480
+ {
481
+ "session": report.session,
482
+ "started": report.started,
483
+ "pid": report.pid,
484
+ "port": report.port,
485
+ "lastRestart": report.last_restart,
486
+ "routes": [
487
+ {
488
+ "route": r.route,
489
+ "lastLoad": r.last_load,
490
+ "clean": r.clean,
491
+ "unconfirmed": r.unconfirmed,
492
+ "needsRecheck": [
493
+ {
494
+ "message": e.get("message"),
495
+ "phase": e.get("phase"),
496
+ "carried": bool(e.get("carried")),
497
+ }
498
+ for e in r.recheck
499
+ ],
500
+ "errors": [
501
+ {"message": e.get("message"), "stack": e.get("stack")} for e in r.errors
502
+ ],
503
+ "warnings": [{"message": w.get("message")} for w in r.warnings],
504
+ "healed": r.healed,
505
+ }
506
+ for r in report.routes
507
+ ],
508
+ },
509
+ indent=2,
510
+ )
511
+
512
+
513
+ def main() -> int:
514
+ parser = argparse.ArgumentParser(description="Report browser-side health for this dev session.")
515
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable status.")
516
+ parser.add_argument(
517
+ "--fail-on-error",
518
+ action="store_true",
519
+ help="Exit 1 when a route is currently failing (off by default: presence of "
520
+ "a signal depends on someone opening the page, so it is not a stable gate).",
521
+ )
522
+ args = parser.parse_args()
523
+
524
+ report = build_report()
525
+ if args.json:
526
+ print(to_json(report))
527
+ else:
528
+ print_report(report)
529
+
530
+ if args.fail_on_error and report.session == "live" and report.failing:
531
+ return 1
532
+ return 0
533
+
534
+
535
+ if __name__ == "__main__":
536
+ raise SystemExit(main())