stacktrace-cli 0.1.0__py3-none-any.whl → 0.2.1__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.
- stacktrace_cli/__init__.py +2 -2
- stacktrace_cli/analysis.py +375 -0
- stacktrace_cli/cli.py +174 -18
- stacktrace_cli/correlate/acquire.py +5 -9
- stacktrace_cli/correlate/composition.py +59 -0
- stacktrace_cli/correlate/join.py +48 -2
- stacktrace_cli/correlate/orchestrate.py +37 -3
- stacktrace_cli/correlate/record.py +8 -0
- stacktrace_cli/detector/deterministic.py +95 -47
- stacktrace_cli/detector/finding.py +111 -2
- stacktrace_cli/detector/render.py +62 -2
- stacktrace_cli/detector/run.py +385 -17
- stacktrace_cli/monitor/__init__.py +12 -0
- stacktrace_cli/monitor/escalate.py +172 -0
- stacktrace_cli/monitor/render.py +252 -0
- stacktrace_cli/monitor/server.py +477 -0
- stacktrace_cli/monitor/site/app.js +1045 -0
- stacktrace_cli/monitor/site/fonts/OFL.txt +210 -0
- stacktrace_cli/monitor/site/fonts/dm-mono-400-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/fonts/dm-mono-500-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/fonts/dm-sans-latin.woff2 +0 -0
- stacktrace_cli/monitor/site/index.html +104 -0
- stacktrace_cli/monitor/site/styles.css +628 -0
- stacktrace_cli/monitor/state.py +102 -0
- stacktrace_cli/monitor/verdicts.py +52 -0
- stacktrace_cli/monitor/watch.py +349 -0
- stacktrace_cli/remote/detect_payload.py +99 -27
- stacktrace_cli/remote/redact.py +13 -2
- stacktrace_cli/remote/sync_detect.py +79 -42
- stacktrace_cli/remote/upload_contract.py +21 -0
- stacktrace_cli-0.2.1.dist-info/METADATA +154 -0
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.1.dist-info}/RECORD +34 -19
- stacktrace_cli-0.1.0.dist-info/METADATA +0 -228
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.1.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.1.0.dist-info → stacktrace_cli-0.2.1.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The `stacktrace` command-line interface — detection and response for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1
|
|
3
|
+
__version__ = "0.2.1"
|
|
@@ -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 .
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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)
|