stacktrace-cli 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
Files changed (34) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +375 -0
  3. stacktrace_cli/cli.py +174 -18
  4. stacktrace_cli/correlate/acquire.py +5 -9
  5. stacktrace_cli/correlate/composition.py +59 -0
  6. stacktrace_cli/correlate/join.py +48 -2
  7. stacktrace_cli/correlate/orchestrate.py +37 -3
  8. stacktrace_cli/correlate/record.py +8 -0
  9. stacktrace_cli/detector/deterministic.py +95 -47
  10. stacktrace_cli/detector/finding.py +111 -2
  11. stacktrace_cli/detector/render.py +62 -2
  12. stacktrace_cli/detector/run.py +385 -17
  13. stacktrace_cli/monitor/__init__.py +12 -0
  14. stacktrace_cli/monitor/escalate.py +172 -0
  15. stacktrace_cli/monitor/render.py +252 -0
  16. stacktrace_cli/monitor/server.py +477 -0
  17. stacktrace_cli/monitor/site/app.js +1045 -0
  18. stacktrace_cli/monitor/site/fonts/OFL.txt +210 -0
  19. stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
  20. stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
  21. stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
  22. stacktrace_cli/monitor/site/index.html +104 -0
  23. stacktrace_cli/monitor/site/styles.css +628 -0
  24. stacktrace_cli/monitor/state.py +102 -0
  25. stacktrace_cli/monitor/verdicts.py +52 -0
  26. stacktrace_cli/monitor/watch.py +349 -0
  27. stacktrace_cli/remote/detect_payload.py +99 -27
  28. stacktrace_cli/remote/redact.py +13 -2
  29. stacktrace_cli/remote/sync_detect.py +79 -42
  30. stacktrace_cli/remote/upload_contract.py +21 -0
  31. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/METADATA +1 -1
  32. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/RECORD +34 -19
  33. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/WHEEL +0 -0
  34. {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """Placeholder CLI package for Stacktrace.ai."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,375 @@
1
+ """The read pipeline, in the one place that knows its order.
2
+
3
+ `run_detector` takes a `CorrelatedView`, not a `SessionView`, so
4
+ sessions -> correlate -> detect is stitched by the caller. It was stitched
5
+ twice — once in `cli.py`'s `detect`, once in `sync_detect.py` — and `monitor`
6
+ would have been the third copy of an order that must not differ between them.
7
+ A page showing different findings from the command, for no reason but a
8
+ divergent call site, is the failure this module exists to prevent.
9
+
10
+ Both halves of the result travel together because neither is recoverable from
11
+ the other: `run` carries the findings, and `view` carries the component a call
12
+ resolved to and the coverage a reader weighs them against.
13
+
14
+ This module knows nothing about Click. Every caller renders an error its own
15
+ way — a `ClickException` at the command line, a `SyncError` on the upload path —
16
+ so the failures here are `ValueError`, and the message is the one the upload
17
+ path's tests already pin.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Iterator
23
+ from dataclasses import dataclass, replace
24
+ from datetime import UTC, datetime, timedelta
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from stacktrace_cli.correlate.orchestrate import Acquired, acquire_correlated_view
29
+ from stacktrace_cli.correlate.project_map import parse_mapping
30
+ from stacktrace_cli.correlate.record import CorrelatedSession, CorrelatedView, Coverage
31
+ from stacktrace_cli.detector.cache import VerdictCache
32
+ from stacktrace_cli.detector.run import (
33
+ DEFAULT_BUDGET,
34
+ DEFAULT_SAMPLE_BUDGET,
35
+ DetectorRun,
36
+ run_detector,
37
+ )
38
+ from stacktrace_cli.sessions.access import SessionView, collect_sessions
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Analysis:
43
+ """One run of the read pipeline, and the window it read."""
44
+
45
+ run: DetectorRun
46
+ #: The correlated record the detector judged. Kept because a consumer needs
47
+ #: what a call resolved to — `monitor` renders it, `sync detect` counts it —
48
+ #: and a `Detection` carries neither the resolution nor the coverage.
49
+ view: CorrelatedView
50
+ #: `parse_since`'s cutoff, retained. Nothing downstream of session
51
+ #: selection needed it before, so nothing kept it.
52
+ window_start: datetime
53
+ #: `now(UTC)` taken *before* collection, so the window never claims to
54
+ #: cover a session that arrived while the pipeline was running.
55
+ window_end: datetime
56
+ #: Whether correlation has run. `False` only on `analyse_progressively`'s
57
+ #: preview stages, which carry the sessions and their built-in activity
58
+ #: before a composition has been built — so a consumer knows that coverage
59
+ #: is not yet countable and that only calls needing no composition are
60
+ #: listed. `analyse` is always placed.
61
+ placed: bool = True
62
+
63
+
64
+ def parse_since(value: str) -> datetime:
65
+ """`7d` or `7` -> an aware UTC cut-off.
66
+
67
+ Raises `ValueError`, never a Click or transport exception: this module is
68
+ below both. The message is the upload path's, which its tests pin.
69
+ """
70
+ text = value.strip().lower()
71
+ try:
72
+ days = int(text[:-1]) if text.endswith("d") else int(text)
73
+ except ValueError:
74
+ raise ValueError(f"--since must be a positive day count, not {value!r}") from None
75
+ if days <= 0:
76
+ raise ValueError(f"--since must be a positive day count, not {value!r}")
77
+ try:
78
+ return datetime.now(UTC) - timedelta(days=days)
79
+ except OverflowError:
80
+ # `timedelta` takes any `int` but overflows past `timedelta.max.days`.
81
+ # The same user mistake as a non-numeric value, so the same diagnostic.
82
+ raise ValueError(f"--since must be a positive day count, not {value!r}") from None
83
+
84
+
85
+ def _only(view: SessionView, session_ids: tuple[str, ...]) -> SessionView:
86
+ """The window narrowed to named sessions, or the window itself.
87
+
88
+ Narrowed *before* correlation rather than after judgement, because the point
89
+ is that nothing else can be dispatched: a caller that pays for one named
90
+ session must not be able to spend that payment on another one the detector
91
+ happened to rank higher. `unavailable` is carried through unchanged — a
92
+ reader that failed is still a reader that failed, whichever session was
93
+ asked for.
94
+ """
95
+ if not session_ids:
96
+ return view
97
+ wanted = set(session_ids)
98
+ return replace(view, sessions=tuple(s for s in view.sessions if s.session_id in wanted))
99
+
100
+
101
+ def analyse(
102
+ *,
103
+ agent_kinds: tuple[str, ...] = (),
104
+ since: str | datetime = "7d",
105
+ bom_paths: tuple[Path, ...] = (),
106
+ project_map: tuple[str, ...] = (),
107
+ root: Path | None = None,
108
+ session_ids: tuple[str, ...] = (),
109
+ escalate: bool = False,
110
+ budget: int = DEFAULT_BUDGET,
111
+ sample_budget: int = DEFAULT_SAMPLE_BUDGET,
112
+ cache: VerdictCache | None = None,
113
+ attach_advisories: Any = None,
114
+ ) -> Analysis:
115
+ """Read this machine's sessions, correlate them, and judge them.
116
+
117
+ `window_end` is taken before collection rather than after: a window that
118
+ ends when the pipeline finished would claim to cover a session that started
119
+ while it ran.
120
+
121
+ `attach_advisories` is correlation's own seam. A long-lived process passes a
122
+ cache through it so osv.dev is asked once per component rather than once per
123
+ pass; a one-shot command passes nothing and gets the default.
124
+
125
+ `session_ids` narrows the window to named sessions. Empty means the whole
126
+ window, which is what every command passes; monitor's escalate button is
127
+ what needs the narrowing, and needs it to be structural.
128
+
129
+ `since` is the window's spelling — `parse_since` reads it — or the cutoff
130
+ itself, for a caller whose own `--since` spelling is not this one's.
131
+ """
132
+ window_end = datetime.now(UTC)
133
+ window_start = since if isinstance(since, datetime) else parse_since(since)
134
+ acquired = _correlated(
135
+ agent_kinds=agent_kinds,
136
+ window_start=window_start,
137
+ bom_paths=bom_paths,
138
+ project_map=project_map,
139
+ root=root,
140
+ session_ids=session_ids,
141
+ attach_advisories=attach_advisories,
142
+ )
143
+ run = run_detector(
144
+ acquired.view,
145
+ escalate=escalate,
146
+ budget=budget,
147
+ sample_budget=sample_budget,
148
+ cache=cache,
149
+ )
150
+ return Analysis(
151
+ run=run,
152
+ view=acquired.view,
153
+ window_start=window_start,
154
+ window_end=window_end,
155
+ )
156
+
157
+
158
+ #: How far back the first, cheap read goes. Collection scales with the window
159
+ #: on a fixed floor — measured at 0.24s for a minute, 0.33s for an hour and
160
+ #: 1.20s for three days — so reading the newest hour first is what puts rows on
161
+ #: screen in a third of a second instead of one and a half.
162
+ PREVIEW_WINDOW = timedelta(hours=1)
163
+
164
+
165
+ def _collected(
166
+ *,
167
+ agent_kinds: tuple[str, ...],
168
+ window_start: datetime,
169
+ root: Path | None,
170
+ session_ids: tuple[str, ...],
171
+ ) -> SessionView:
172
+ """Read the window. Its own function so a caller can read twice."""
173
+ return _only(collect_sessions(list(agent_kinds) or None, window_start, root=root), session_ids)
174
+
175
+
176
+ def _acquire(
177
+ sessions: SessionView,
178
+ *,
179
+ bom_paths: tuple[Path, ...],
180
+ project_map: tuple[str, ...],
181
+ attach_advisories: Any,
182
+ ) -> Acquired:
183
+ """Correlate what was read."""
184
+ hook = {} if attach_advisories is None else {"attach_advisories": attach_advisories}
185
+ return acquire_correlated_view(
186
+ sessions,
187
+ bom_paths=bom_paths,
188
+ project_map=tuple(parse_mapping(mapping) for mapping in project_map),
189
+ **hook,
190
+ )
191
+
192
+
193
+ def _correlated(
194
+ *,
195
+ agent_kinds: tuple[str, ...],
196
+ window_start: datetime,
197
+ bom_paths: tuple[Path, ...],
198
+ project_map: tuple[str, ...],
199
+ root: Path | None,
200
+ session_ids: tuple[str, ...],
201
+ attach_advisories: Any,
202
+ ) -> Acquired:
203
+ """Everything up to the judging, shared by both entry points."""
204
+ return _acquire(
205
+ _collected(
206
+ agent_kinds=agent_kinds,
207
+ window_start=window_start,
208
+ root=root,
209
+ session_ids=session_ids,
210
+ ),
211
+ bom_paths=bom_paths,
212
+ project_map=project_map,
213
+ attach_advisories=attach_advisories,
214
+ )
215
+
216
+
217
+ def _unplaced(sessions: SessionView, *, window_start: datetime, window_end: datetime) -> Analysis:
218
+ """The sessions as read, before anything has been correlated or judged.
219
+
220
+ Correlation is 2.7s of a 4.4s first paint and it places 0.9% of calls; the
221
+ other 99% are built-in tools, which need no composition at all. So this
222
+ carries every session read and lets the render list the calls a
223
+ composition is not needed for — coverage stays uncounted, and no finding is
224
+ claimed, because neither is known yet.
225
+ """
226
+ return Analysis(
227
+ run=DetectorRun(
228
+ collection_failures=run_detector(
229
+ CorrelatedView(unavailable=sessions.unavailable)
230
+ ).collection_failures
231
+ ),
232
+ view=CorrelatedView(
233
+ unavailable=sessions.unavailable,
234
+ sessions=tuple(
235
+ CorrelatedSession(
236
+ session=session,
237
+ composition=None,
238
+ resolutions={},
239
+ coverage=Coverage(total=0, resolved=0, by_outcome={}, by_component_type={}),
240
+ )
241
+ for session in sessions.sessions
242
+ ),
243
+ ),
244
+ window_start=window_start,
245
+ window_end=window_end,
246
+ placed=False,
247
+ )
248
+
249
+
250
+ def analyse_progressively(
251
+ *,
252
+ agent_kinds: tuple[str, ...] = (),
253
+ since: str | datetime = "7d",
254
+ bom_paths: tuple[Path, ...] = (),
255
+ project_map: tuple[str, ...] = (),
256
+ root: Path | None = None,
257
+ batch: int = 8,
258
+ preview: timedelta | None = PREVIEW_WINDOW,
259
+ cache: VerdictCache | None = None,
260
+ attach_advisories: Any = None,
261
+ ) -> Iterator[Analysis]:
262
+ """The same pipeline, delivered in instalments.
263
+
264
+ `analyse` answers once, which is right for a command that prints and exits
265
+ and wrong for a page meant to look alive. Measured over 339 real sessions
266
+ the work splits about 6.6s to collect and correlate, then about 12ms to
267
+ judge each session — so a caller that waits for the whole run shows nothing
268
+ for six seconds while already holding the sessions and their activity.
269
+
270
+ Three kinds of yield, cheapest first.
271
+
272
+ **Preview stages, before correlation** (`placed=False`). Correlation is
273
+ 2.7s of a 4.4s first paint and it places 0.9% of a real window's calls; the
274
+ other 99% are built-in tools, which `join.py`'s predicates answer for in a
275
+ millisecond. So the sessions and those calls go out first — the newest
276
+ `preview` of them, then the whole window — and the page fills in ~0.35s
277
+ rather than ~4.4s. Pass `preview=None` for attributed data only.
278
+
279
+ **Then batches of judged sessions**, newest first, because someone watching
280
+ a machine cares about what just happened and should not have to scroll for
281
+ it. Every one of these carries the *whole* session list: the preview
282
+ already published it, and a later stage carrying fewer would make the count
283
+ on the page go backwards.
284
+
285
+ The last yield equals what `analyse` would have returned. If it did not,
286
+ the page and the command would disagree, which is the thing one pipeline
287
+ exists to prevent.
288
+
289
+ `cache` is read and never written. A session graded by an earlier
290
+ `detect --escalate` shows that grade here; this path never commissions one.
291
+
292
+ **No `escalate` parameter, deliberately.** Stage 3's budget is per *run*, so
293
+ judging in batches would give each batch its own budget and spend a multiple
294
+ of what was authorised. A streaming escalation needs a budget shared across
295
+ batches; until it has one, this path does not offer the option rather than
296
+ offering it wrongly.
297
+ """
298
+ window_end = datetime.now(UTC)
299
+ window_start = since if isinstance(since, datetime) else parse_since(since)
300
+
301
+ def read(cutoff: datetime) -> SessionView:
302
+ return _collected(agent_kinds=agent_kinds, window_start=cutoff, root=root, session_ids=())
303
+
304
+ if preview is not None:
305
+ cutoff = window_end - preview
306
+ # A window already narrower than the preview is read once, not twice.
307
+ if cutoff > window_start:
308
+ yield _unplaced(read(cutoff), window_start=window_start, window_end=window_end)
309
+
310
+ sessions = read(window_start)
311
+ if preview is not None:
312
+ yield _unplaced(sessions, window_start=window_start, window_end=window_end)
313
+
314
+ view = _acquire(
315
+ sessions,
316
+ bom_paths=bom_paths,
317
+ project_map=project_map,
318
+ attach_advisories=attach_advisories,
319
+ ).view
320
+
321
+ def _stage(run: DetectorRun, sessions: tuple[CorrelatedSession, ...]) -> Analysis:
322
+ """One instalment: every session, and the findings judged so far.
323
+
324
+ The whole session list, every time. It grew with the findings until the
325
+ preview stages existed — the point then was that the feed should not
326
+ appear all at once — but the preview publishes every session within
327
+ ~1.6s and a client that paces its own reveal streams them in anyway. A
328
+ stage carrying fewer sessions than the preview did would make the count
329
+ on the page go backwards.
330
+ """
331
+ return Analysis(
332
+ run=run,
333
+ view=replace(view, sessions=sessions),
334
+ window_start=window_start,
335
+ window_end=window_end,
336
+ )
337
+
338
+ # The detector derives these from the view, and a stage that has not judged
339
+ # anything yet still has to carry them: a reader that failed is the one
340
+ # thing a page must never render as silence.
341
+ failures = run_detector(CorrelatedView(unavailable=view.unavailable)).collection_failures
342
+
343
+ # Stage zero: what could not be read, before a session is judged. A reader
344
+ # that failed is the one thing a page must never render as silence, and it
345
+ # is known before the detector has done anything at all. Skipped when a
346
+ # preview already carried it, since that stage said the same and carried
347
+ # the sessions too.
348
+ if preview is None:
349
+ yield _stage(DetectorRun(collection_failures=failures), ())
350
+
351
+ ordered = sorted(
352
+ view.sessions,
353
+ key=lambda correlated: correlated.session.started_at or datetime.min.replace(tzinfo=UTC),
354
+ reverse=True,
355
+ )
356
+ step = max(batch, 1)
357
+ accumulated = DetectorRun(collection_failures=failures)
358
+ for index in range(0, len(ordered), step):
359
+ judged = run_detector(
360
+ CorrelatedView(sessions=tuple(ordered[index : index + step])),
361
+ # Read, never written, and never escalating: a session an earlier
362
+ # `detect --escalate` graded renders with that grade here, and this
363
+ # path commissions nothing (ADR-0026 clause 2).
364
+ cache=cache,
365
+ )
366
+ accumulated = DetectorRun(
367
+ detections=accumulated.detections + judged.detections,
368
+ unknowns=accumulated.unknowns + judged.unknowns,
369
+ sessions=accumulated.sessions + judged.sessions,
370
+ escalated=accumulated.escalated + judged.escalated,
371
+ analysed=accumulated.analysed + judged.analysed,
372
+ cache_hits=accumulated.cache_hits + judged.cache_hits,
373
+ collection_failures=failures,
374
+ )
375
+ yield _stage(accumulated, tuple(ordered))
stacktrace_cli/cli.py CHANGED
@@ -25,12 +25,12 @@ import click
25
25
  from openaca.cli import main as openaca_cli
26
26
 
27
27
  from . import __version__
28
- from .correlate.orchestrate import acquire_correlated_view
29
- from .correlate.project_map import parse_mapping
28
+ from .analysis import analyse, parse_since
30
29
  from .detector.cache import VerdictCache, default_directory
31
30
  from .detector.render import render_json as render_detections_json
32
31
  from .detector.render import render_text as render_detections_text
33
- from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET, run_detector
32
+ from .detector.run import DEFAULT_BUDGET, DEFAULT_SAMPLE_BUDGET
33
+ from .monitor.server import serve
34
34
  from .remote.cli import main as remote_cmd
35
35
  from .sessions.access import collect_sessions
36
36
  from .sessions.render import render_json, render_text
@@ -50,6 +50,36 @@ def _parse_since(value: str) -> datetime:
50
50
  return datetime.now(UTC) - timedelta(days=int(match.group(1)))
51
51
 
52
52
 
53
+ def _window_start(ctx: click.Context, param: click.Parameter, value: str) -> datetime:
54
+ """`detect`'s window, resolved by this command's own parser.
55
+
56
+ The window otherwise reaches `analyse` as a string and is parsed there, by
57
+ the parser that also answers the upload path — whose value comes from a
58
+ config file rather than a shell, so it is more forgiving about the spelling
59
+ and stricter about the count. Applying it here as well would quietly change
60
+ what this command accepts, so the cutoff travels already resolved.
61
+ """
62
+ return _parse_since(value)
63
+
64
+
65
+ def _day_count(ctx: click.Context, param: click.Parameter, value: str) -> str:
66
+ """Refuse here every `--since` the pipeline would refuse later.
67
+
68
+ The window reaches `analyse` as a string and is parsed there, once for every
69
+ caller. That parser also answers the upload path, whose value comes from a
70
+ config file rather than a shell, so it is the more forgiving of the two —
71
+ and the narrower spelling stays here, where a mistyped option is a usage
72
+ error with the usage line attached rather than a command that started,
73
+ read nothing and failed.
74
+ """
75
+ _parse_since(value)
76
+ try:
77
+ parse_since(value)
78
+ except ValueError as error:
79
+ raise click.BadParameter(str(error)) from error
80
+ return value
81
+
82
+
53
83
  def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
54
84
  # Click invokes an eager option's callback on every invocation of the
55
85
  # group, with `value=False` when the flag was absent, so an unguarded
@@ -165,7 +195,13 @@ def sessions(
165
195
 
166
196
  @click.command()
167
197
  @click.option("--agent-kind", "agent_kinds", multiple=True, help="Limit to these agent kinds.")
168
- @click.option("--since", default="7d", show_default=True, help="How far back to read.")
198
+ @click.option(
199
+ "--since",
200
+ default="7d",
201
+ show_default=True,
202
+ callback=_window_start,
203
+ help="How far back to read.",
204
+ )
169
205
  @click.option(
170
206
  "--bom",
171
207
  "bom_paths",
@@ -239,7 +275,7 @@ def sessions(
239
275
  )
240
276
  def detect(
241
277
  agent_kinds: tuple[str, ...],
242
- since: str,
278
+ since: datetime,
243
279
  bom_paths: tuple[Path, ...],
244
280
  output_format: str,
245
281
  detail: bool,
@@ -275,23 +311,26 @@ def detect(
275
311
  components a session invoked to osv.dev. Coordinates only -- never a
276
312
  prompt, an argument or a result.
277
313
  """
278
- view = collect_sessions(list(agent_kinds) or None, _parse_since(since), root=root)
279
314
  try:
280
- acquired = acquire_correlated_view(
281
- view, bom_paths=bom_paths, project_map=tuple(parse_mapping(m) for m in project_map)
315
+ # One call, because the order it runs in is `analysis.py`'s to know:
316
+ # `monitor` and `sync detect` read the same pipeline and must not be
317
+ # able to disagree with this command about what it found.
318
+ analysis = analyse(
319
+ agent_kinds=agent_kinds,
320
+ since=since,
321
+ bom_paths=bom_paths,
322
+ project_map=project_map,
323
+ root=root,
324
+ escalate=escalate,
325
+ budget=budget,
326
+ sample_budget=sample_budget,
327
+ # Only ever consulted when stage 3 runs: there is nothing to reuse
328
+ # otherwise, and opening a directory to discover that is waste.
329
+ cache=VerdictCache(default_directory()) if cache and escalate else None,
282
330
  )
283
331
  except ValueError as error:
284
332
  raise click.ClickException(str(error)) from error
285
-
286
- result = run_detector(
287
- acquired.view,
288
- escalate=escalate,
289
- budget=budget,
290
- sample_budget=sample_budget,
291
- # Only ever consulted when stage 3 runs: there is nothing to reuse
292
- # otherwise, and opening a directory to discover that is waste.
293
- cache=VerdictCache(default_directory()) if cache and escalate else None,
294
- )
333
+ result = analysis.run
295
334
  output = (
296
335
  render_detections_json(result)
297
336
  if output_format == "json"
@@ -300,6 +339,122 @@ def detect(
300
339
  click.echo(output, nl=not output.endswith("\n"))
301
340
 
302
341
 
342
+ @click.command()
343
+ @click.option("--port", type=int, default=0, help="Serve on this port instead of an unused one.")
344
+ @click.option(
345
+ "--host",
346
+ default="127.0.0.1",
347
+ show_default=True,
348
+ help="Loopback only. This page renders working directories and matched "
349
+ "values, so a non-loopback address is refused rather than warned about.",
350
+ )
351
+ @click.option(
352
+ "--interval",
353
+ # Above zero, not merely non-negative: the wait between passes is what
354
+ # makes this page free to leave open, and `Event.wait(0)` returns at once —
355
+ # a zero or negative interval walks the transcript tree in a hot loop.
356
+ type=click.FloatRange(min=0, min_open=True),
357
+ default=4.0,
358
+ show_default=True,
359
+ help="Seconds between checks for new agent activity. A pass is skipped "
360
+ "entirely when nothing on disk has changed.",
361
+ )
362
+ @click.option(
363
+ "--escalate/--no-escalate",
364
+ default=False,
365
+ show_default=True,
366
+ help="Let the page send one session to the agent's own CLI for semantic "
367
+ "analysis. Off by default: stage 3 spends your provider quota, and a page "
368
+ "left open is the wrong place for that to be implicit. Capped by --budget.",
369
+ )
370
+ @click.option(
371
+ "--budget",
372
+ type=click.IntRange(min=1),
373
+ default=DEFAULT_BUDGET,
374
+ show_default=True,
375
+ help="Escalations this monitor may spend in total, if --escalate is on.",
376
+ )
377
+ @click.option(
378
+ "--no-open", is_flag=True, default=False, help="Print the URL, do not open a browser."
379
+ )
380
+ @click.option("--agent-kind", "agent_kinds", multiple=True, help="Limit to these agent kinds.")
381
+ @click.option(
382
+ "--since",
383
+ default="3d",
384
+ show_default=True,
385
+ callback=_day_count,
386
+ help="How far back to read. Shorter than `detect`'s week deliberately: the "
387
+ "whole window is collected and correlated before the page can render "
388
+ "anything — measured at ~2.5s for a day and ~6s for a week of a busy "
389
+ "machine — and a live view is about what is happening rather than what "
390
+ "happened. Widen it when you want the history.",
391
+ )
392
+ @click.option(
393
+ "--bom",
394
+ "bom_paths",
395
+ multiple=True,
396
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
397
+ help="Use this Agent BOM instead of scanning for its kind.",
398
+ )
399
+ @click.option(
400
+ "--project-map",
401
+ "project_map",
402
+ multiple=True,
403
+ metavar="OLD=NEW",
404
+ help="Where a project a session ran in lives now, for a directory that has moved.",
405
+ )
406
+ @click.option(
407
+ "--root",
408
+ type=click.Path(file_okay=False, path_type=Path),
409
+ default=None,
410
+ help="Read agent transcripts from here instead of the default location.",
411
+ )
412
+ def monitor(
413
+ port: int,
414
+ host: str,
415
+ interval: float,
416
+ escalate: bool,
417
+ budget: int,
418
+ no_open: bool,
419
+ agent_kinds: tuple[str, ...],
420
+ since: str,
421
+ bom_paths: tuple[Path, ...],
422
+ project_map: tuple[str, ...],
423
+ root: Path | None,
424
+ ) -> None:
425
+ """Watch this machine's agents in a browser, live.
426
+
427
+ Serves a local page — a summary, what your agents are doing, and what the
428
+ detector makes of it — refreshing as they work. Everything on it is
429
+ computed in this process by the same code `stacktrace detect` runs; the
430
+ page renders and never judges.
431
+
432
+ Loopback only, and free to leave open: a pass is skipped when nothing has
433
+ changed, advisory lookups are asked once per component, and the two stages
434
+ that need no model are the only ones that run. --escalate adds a button
435
+ that spends your provider quota, off unless asked for.
436
+ """
437
+ try:
438
+ code = serve(
439
+ host=host,
440
+ port=port,
441
+ interval=interval,
442
+ escalate=escalate,
443
+ budget=budget,
444
+ open_browser=not no_open,
445
+ echo=click.echo,
446
+ agent_kinds=agent_kinds,
447
+ since=since,
448
+ bom_paths=bom_paths,
449
+ project_map=project_map,
450
+ root=root,
451
+ )
452
+ except (OSError, ValueError) as error:
453
+ raise click.ClickException(str(error)) from error
454
+ if code:
455
+ raise click.exceptions.Exit(code)
456
+
457
+
303
458
  # Named, never discovered: an upstream release must not be able to enlarge this
304
459
  # CLI. A missing name is a `KeyError` at import time, which names the command
305
460
  # and the line, and lands at upgrade time.
@@ -312,3 +467,4 @@ for _name in PASSTHROUGH:
312
467
  main.add_command(remote_cmd, "remote")
313
468
  main.add_command(sessions)
314
469
  main.add_command(detect)
470
+ main.add_command(monitor)
@@ -31,6 +31,7 @@ from stacktrace_cli.correlate.composition import (
31
31
  Composition,
32
32
  composition_from_bom,
33
33
  reject_malformed_shape,
34
+ root_property,
34
35
  )
35
36
 
36
37
  _AGENT_KIND_PROPERTY = "openaca:agent_kind"
@@ -362,12 +363,7 @@ def _text_or_absent(value: object) -> bool:
362
363
 
363
364
 
364
365
  def _declared_kind(document: dict[str, Any]) -> str | None:
365
- metadata = document.get("metadata")
366
- root = metadata.get("component") if isinstance(metadata, dict) else None
367
- if not isinstance(root, dict):
368
- return None
369
- for prop in root.get("properties") or []:
370
- if isinstance(prop, dict) and prop.get("name") == _AGENT_KIND_PROPERTY:
371
- value = prop.get("value")
372
- return value if isinstance(value, str) else None
373
- return None
366
+ """The kind property, read by the same walk that reads every other root
367
+ property. Kept as a name of its own because the *discriminator* is what
368
+ this module reads before it has anything to ask — see `load_bom`."""
369
+ return root_property(document, _AGENT_KIND_PROPERTY)