autosentry 0.7.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.
- autosentry/__init__.py +3 -0
- autosentry/__main__.py +4 -0
- autosentry/_tail.py +84 -0
- autosentry/analyze.py +174 -0
- autosentry/cli/__init__.py +85 -0
- autosentry/cli/commands/__init__.py +1 -0
- autosentry/cli/commands/analyze.py +154 -0
- autosentry/cli/commands/dispatcher.py +266 -0
- autosentry/cli/commands/doctor.py +313 -0
- autosentry/cli/commands/healer.py +145 -0
- autosentry/cli/commands/incidents.py +90 -0
- autosentry/cli/commands/init.py +495 -0
- autosentry/cli/commands/onboard.py +224 -0
- autosentry/cli/commands/run.py +40 -0
- autosentry/cli/commands/skills.py +192 -0
- autosentry/cli/commands/status.py +55 -0
- autosentry/cli/commands/update.py +76 -0
- autosentry/cli/commands/watch.py +47 -0
- autosentry/cli/commands/web.py +42 -0
- autosentry/cli/style.py +163 -0
- autosentry/config.py +273 -0
- autosentry/detectors/__init__.py +66 -0
- autosentry/detectors/base.py +56 -0
- autosentry/detectors/exit_code.py +42 -0
- autosentry/detectors/pattern.py +37 -0
- autosentry/detectors/stall.py +82 -0
- autosentry/detectors/traceback.py +165 -0
- autosentry/dispatcher/__init__.py +44 -0
- autosentry/dispatcher/backends.py +471 -0
- autosentry/dispatcher/daemon.py +370 -0
- autosentry/git_ops.py +205 -0
- autosentry/healers/__init__.py +7 -0
- autosentry/healers/base.py +20 -0
- autosentry/healers/claude.py +385 -0
- autosentry/healers/rules.py +38 -0
- autosentry/inbox.py +170 -0
- autosentry/incidents/__init__.py +14 -0
- autosentry/incidents/exploder.py +306 -0
- autosentry/incidents/report.py +85 -0
- autosentry/incidents/store.py +159 -0
- autosentry/ledger.py +159 -0
- autosentry/logger.py +88 -0
- autosentry/monitor.py +584 -0
- autosentry/notifiers/__init__.py +47 -0
- autosentry/notifiers/base.py +20 -0
- autosentry/notifiers/discord_outbox.py +16 -0
- autosentry/notifiers/file_outbox.py +62 -0
- autosentry/notifiers/log.py +19 -0
- autosentry/notifiers/slack_outbox.py +15 -0
- autosentry/notifiers/webhook.py +37 -0
- autosentry/skills.py +339 -0
- autosentry/state.py +136 -0
- autosentry/supervisors/__init__.py +42 -0
- autosentry/supervisors/attach.py +189 -0
- autosentry/supervisors/base.py +124 -0
- autosentry/supervisors/docker.py +241 -0
- autosentry/supervisors/local.py +183 -0
- autosentry/supervisors/slurm.py +334 -0
- autosentry/templates/autosentry.yaml.tmpl +168 -0
- autosentry/templates/program.md.tmpl +91 -0
- autosentry/templates/recovery.md.tmpl +48 -0
- autosentry/templates/skills/AGENTS.md +276 -0
- autosentry/templates/skills/aider.conf.yml +14 -0
- autosentry/templates/skills/aider.md +34 -0
- autosentry/templates/skills/autosentry.md +147 -0
- autosentry/templates/skills/claude.md +31 -0
- autosentry/templates/skills/claude_init.md +60 -0
- autosentry/templates/skills/codex.md +37 -0
- autosentry/templates/skills/codex_init.md +27 -0
- autosentry/templates/skills/continue.config.json +13 -0
- autosentry/templates/skills/cursor.md +32 -0
- autosentry/templates/skills/cursor_init.md +25 -0
- autosentry/templates/skills/gemini.toml +45 -0
- autosentry/templates/skills/gemini_init.toml +37 -0
- autosentry/templates/skills/init.md +70 -0
- autosentry/templates/skills/opencode.md +25 -0
- autosentry/templates/skills/opencode_init.md +24 -0
- autosentry/templates/skills/windsurfrules.md +46 -0
- autosentry/templates/skills/zed.md +27 -0
- autosentry/templates/skills/zed_init.md +28 -0
- autosentry/tui.py +258 -0
- autosentry/updater.py +177 -0
- autosentry/web/__init__.py +5 -0
- autosentry/web/server.py +374 -0
- autosentry-0.7.1.dist-info/METADATA +972 -0
- autosentry-0.7.1.dist-info/RECORD +90 -0
- autosentry-0.7.1.dist-info/WHEEL +4 -0
- autosentry-0.7.1.dist-info/entry_points.txt +2 -0
- autosentry-0.7.1.dist-info/licenses/LICENSE +201 -0
- autosentry-0.7.1.dist-info/licenses/NOTICE +5 -0
autosentry/__init__.py
ADDED
autosentry/__main__.py
ADDED
autosentry/_tail.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared "tail -F"-style file follower.
|
|
2
|
+
|
|
3
|
+
Used by the SLURM and Attach supervisors. Both follow a single file
|
|
4
|
+
that may not exist yet (SLURM creates it after the job starts; an
|
|
5
|
+
attached process may rotate its log out from under us). The only
|
|
6
|
+
behavioral difference is whether they want to *replay history* on
|
|
7
|
+
first open:
|
|
8
|
+
|
|
9
|
+
- SLURM wants the historical contents (the job may have been running
|
|
10
|
+
before autosentry attached its tail thread).
|
|
11
|
+
- Attach wants to skip history (the watched service may have GB of
|
|
12
|
+
pre-existing log).
|
|
13
|
+
|
|
14
|
+
The ``seek_to_end`` flag controls that choice.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from autosentry.logger import log
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def follow_file(
|
|
29
|
+
path_provider: Callable[[], Path | None],
|
|
30
|
+
*,
|
|
31
|
+
on_line: Callable[[str], None],
|
|
32
|
+
stop_event: threading.Event,
|
|
33
|
+
ready_event: threading.Event | None = None,
|
|
34
|
+
seek_to_end: bool,
|
|
35
|
+
error_tag: str,
|
|
36
|
+
missing_backoff_cap: float = 5.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Follow a file, calling ``on_line`` for each new line.
|
|
39
|
+
|
|
40
|
+
``path_provider`` is a callable so callers can defer path resolution
|
|
41
|
+
(SLURM, for instance, only knows the path once the job has a job id).
|
|
42
|
+
Returns when ``stop_event`` is set.
|
|
43
|
+
"""
|
|
44
|
+
backoff = 0.5
|
|
45
|
+
last_inode: int | None = None
|
|
46
|
+
pos = 0
|
|
47
|
+
while not stop_event.is_set():
|
|
48
|
+
path = path_provider()
|
|
49
|
+
if path is None or not path.exists():
|
|
50
|
+
time.sleep(min(backoff, missing_backoff_cap))
|
|
51
|
+
backoff = min(backoff * 1.5, missing_backoff_cap)
|
|
52
|
+
continue
|
|
53
|
+
backoff = 0.5
|
|
54
|
+
try:
|
|
55
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
56
|
+
inode = os.fstat(f.fileno()).st_ino
|
|
57
|
+
if last_inode != inode:
|
|
58
|
+
last_inode = inode
|
|
59
|
+
if seek_to_end:
|
|
60
|
+
f.seek(0, 2) # SEEK_END
|
|
61
|
+
pos = f.tell()
|
|
62
|
+
else:
|
|
63
|
+
pos = 0
|
|
64
|
+
f.seek(pos)
|
|
65
|
+
if ready_event is not None:
|
|
66
|
+
ready_event.set()
|
|
67
|
+
while not stop_event.is_set():
|
|
68
|
+
line = f.readline()
|
|
69
|
+
if not line:
|
|
70
|
+
pos = f.tell()
|
|
71
|
+
try:
|
|
72
|
+
st = path.stat()
|
|
73
|
+
if st.st_ino != last_inode:
|
|
74
|
+
# File rotated; reopen.
|
|
75
|
+
break
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
break
|
|
78
|
+
time.sleep(0.5)
|
|
79
|
+
continue
|
|
80
|
+
on_line(line.rstrip("\n"))
|
|
81
|
+
pos = f.tell()
|
|
82
|
+
except OSError as e:
|
|
83
|
+
log().error(f"{error_tag} log tail error: {e}")
|
|
84
|
+
time.sleep(1)
|
autosentry/analyze.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""``autosentry analyze`` — flat-text summary of the attempts ledger.
|
|
2
|
+
|
|
3
|
+
Mirrors the spirit of autoresearch's ``analysis.ipynb`` without dragging
|
|
4
|
+
Jupyter into the dep tree. Reads ``attempts.tsv`` (and optionally
|
|
5
|
+
filters by ``--since``), then prints:
|
|
6
|
+
|
|
7
|
+
- Top failing detectors in the window
|
|
8
|
+
- Per-rule success rate (kept / total terminal attempts)
|
|
9
|
+
- Current regression streaks per detector
|
|
10
|
+
- Overall stats (totals, in-progress)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections import Counter, defaultdict
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
|
|
21
|
+
from autosentry.ledger import Attempt
|
|
22
|
+
|
|
23
|
+
# ----- time-window parsing -------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SINCE_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_since(value: str | None) -> timedelta | None:
|
|
30
|
+
"""Parse a window specifier like ``24h``, ``30m``, ``7d``, ``600s``.
|
|
31
|
+
|
|
32
|
+
Returns ``None`` for an empty/missing value (which means "no filter").
|
|
33
|
+
Raises :class:`ValueError` on malformed input.
|
|
34
|
+
"""
|
|
35
|
+
if not value:
|
|
36
|
+
return None
|
|
37
|
+
m = _SINCE_RE.match(value)
|
|
38
|
+
if not m:
|
|
39
|
+
msg = f"bad --since value {value!r} (expected like '24h', '30m', '7d', '600s')"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
qty = int(m.group(1))
|
|
42
|
+
unit = m.group(2).lower()
|
|
43
|
+
return {
|
|
44
|
+
"s": timedelta(seconds=qty),
|
|
45
|
+
"m": timedelta(minutes=qty),
|
|
46
|
+
"h": timedelta(hours=qty),
|
|
47
|
+
"d": timedelta(days=qty),
|
|
48
|
+
}[unit]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def filter_window(rows: Iterable[Attempt], window: timedelta | None) -> list[Attempt]:
|
|
52
|
+
if window is None:
|
|
53
|
+
return list(rows)
|
|
54
|
+
cutoff = datetime.now(tz=timezone.utc) - window
|
|
55
|
+
out: list[Attempt] = []
|
|
56
|
+
for r in rows:
|
|
57
|
+
try:
|
|
58
|
+
ts = datetime.fromisoformat(r.timestamp)
|
|
59
|
+
except ValueError:
|
|
60
|
+
# Tolerate bad timestamps — include them rather than drop them.
|
|
61
|
+
out.append(r)
|
|
62
|
+
continue
|
|
63
|
+
if ts.tzinfo is None:
|
|
64
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
65
|
+
if ts >= cutoff:
|
|
66
|
+
out.append(r)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ----- summary stats -------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class RuleStat:
|
|
75
|
+
source: str
|
|
76
|
+
total: int
|
|
77
|
+
kept: int
|
|
78
|
+
regressed: int
|
|
79
|
+
crashed: int
|
|
80
|
+
discarded: int
|
|
81
|
+
pending: int
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def success_rate(self) -> float:
|
|
85
|
+
terminal = self.kept + self.regressed + self.crashed + self.discarded
|
|
86
|
+
if terminal == 0:
|
|
87
|
+
return 0.0
|
|
88
|
+
return self.kept / terminal
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class DetectorStat:
|
|
93
|
+
detector: str
|
|
94
|
+
total: int
|
|
95
|
+
streak_status: str # most recent terminal status
|
|
96
|
+
streak_len: int # consecutive run of same terminal status (most recent first)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Summary:
|
|
101
|
+
total: int
|
|
102
|
+
by_status: dict[str, int]
|
|
103
|
+
top_detectors: list[tuple[str, int]]
|
|
104
|
+
rules: list[RuleStat]
|
|
105
|
+
detector_streaks: list[DetectorStat]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def summarize(rows: Iterable[Attempt], *, top_n: int = 10) -> Summary:
|
|
109
|
+
rows = list(rows)
|
|
110
|
+
# Counter[AttemptStatus] → plain dict[str, int]; the str(...) cast keeps
|
|
111
|
+
# pyrefly happy and matches the Summary annotation.
|
|
112
|
+
by_status: dict[str, int] = {str(k): v for k, v in Counter(r.status for r in rows).items()}
|
|
113
|
+
detector_counts = Counter(r.detector for r in rows)
|
|
114
|
+
|
|
115
|
+
# Per-rule stats.
|
|
116
|
+
by_source: dict[str, Counter[str]] = defaultdict(Counter)
|
|
117
|
+
for r in rows:
|
|
118
|
+
by_source[r.source][r.status] += 1
|
|
119
|
+
rules = [
|
|
120
|
+
RuleStat(
|
|
121
|
+
source=source,
|
|
122
|
+
total=sum(statuses.values()),
|
|
123
|
+
kept=statuses.get("kept", 0),
|
|
124
|
+
regressed=statuses.get("regressed", 0),
|
|
125
|
+
crashed=statuses.get("crashed", 0),
|
|
126
|
+
discarded=statuses.get("discarded", 0),
|
|
127
|
+
pending=statuses.get("pending", 0),
|
|
128
|
+
)
|
|
129
|
+
for source, statuses in by_source.items()
|
|
130
|
+
]
|
|
131
|
+
rules.sort(key=lambda r: r.total, reverse=True)
|
|
132
|
+
|
|
133
|
+
# Detector streaks — walk backward through rows-by-detector, count the
|
|
134
|
+
# longest run of identical terminal status at the tail.
|
|
135
|
+
by_detector: dict[str, list[Attempt]] = defaultdict(list)
|
|
136
|
+
for r in rows:
|
|
137
|
+
by_detector[r.detector].append(r)
|
|
138
|
+
streaks: list[DetectorStat] = []
|
|
139
|
+
for detector, drows in by_detector.items():
|
|
140
|
+
terminal = [r for r in drows if r.status != "pending"]
|
|
141
|
+
if not terminal:
|
|
142
|
+
streaks.append(
|
|
143
|
+
DetectorStat(
|
|
144
|
+
detector=detector,
|
|
145
|
+
total=len(drows),
|
|
146
|
+
streak_status="pending",
|
|
147
|
+
streak_len=0,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
tail_status = terminal[-1].status
|
|
152
|
+
streak_len = 0
|
|
153
|
+
for r in reversed(terminal):
|
|
154
|
+
if r.status == tail_status:
|
|
155
|
+
streak_len += 1
|
|
156
|
+
else:
|
|
157
|
+
break
|
|
158
|
+
streaks.append(
|
|
159
|
+
DetectorStat(
|
|
160
|
+
detector=detector,
|
|
161
|
+
total=len(drows),
|
|
162
|
+
streak_status=tail_status,
|
|
163
|
+
streak_len=streak_len,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
streaks.sort(key=lambda s: s.total, reverse=True)
|
|
167
|
+
|
|
168
|
+
return Summary(
|
|
169
|
+
total=len(rows),
|
|
170
|
+
by_status=by_status,
|
|
171
|
+
top_detectors=detector_counts.most_common(top_n),
|
|
172
|
+
rules=rules,
|
|
173
|
+
detector_streaks=streaks,
|
|
174
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""autosentry CLI.
|
|
2
|
+
|
|
3
|
+
The CLI is composed out of small per-command modules under
|
|
4
|
+
``autosentry.cli.commands``. This file owns the top-level Typer app
|
|
5
|
+
and the eager ``--version`` callback; everything else is registered
|
|
6
|
+
via :func:`_register_commands` so we don't grow a god-file again.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from autosentry import __version__
|
|
14
|
+
from autosentry.cli.style import banner, console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_version(value: bool) -> None:
|
|
18
|
+
if not value:
|
|
19
|
+
return
|
|
20
|
+
console.print(banner(__version__))
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="autosentry",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
help=(
|
|
29
|
+
"Self-healing sentry for long-running processes.\n\n"
|
|
30
|
+
"autosentry supervises a command, watches its log stream for known "
|
|
31
|
+
"failure modes and anomalies, applies deterministic YAML rules when "
|
|
32
|
+
"it can, and escalates to a Claude Code subagent when it can't. "
|
|
33
|
+
"Each event becomes a folder under .autosentry/incidents/ with "
|
|
34
|
+
"exploded stack frames, config snapshots, and the fix that was "
|
|
35
|
+
"applied. Start with `autosentry init`, then `autosentry doctor`, "
|
|
36
|
+
"then `autosentry run`."
|
|
37
|
+
),
|
|
38
|
+
rich_markup_mode="rich",
|
|
39
|
+
pretty_exceptions_show_locals=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def _main(
|
|
45
|
+
version: bool = typer.Option( # noqa: B008 — Typer idiom
|
|
46
|
+
False,
|
|
47
|
+
"--version",
|
|
48
|
+
"-V",
|
|
49
|
+
help="Print version and exit.",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
callback=_print_version,
|
|
52
|
+
is_eager=True,
|
|
53
|
+
),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""autosentry — self-healing sentry for long-running processes."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _register_commands() -> None:
|
|
59
|
+
"""Import each command module so it can attach itself to ``app``.
|
|
60
|
+
|
|
61
|
+
Lazy attachment via import keeps the top-level entry fast (no
|
|
62
|
+
network, no file IO) until the user actually runs a subcommand.
|
|
63
|
+
"""
|
|
64
|
+
# Import order is for help output ordering only; otherwise no constraint.
|
|
65
|
+
from autosentry.cli.commands import ( # noqa: F401
|
|
66
|
+
analyze,
|
|
67
|
+
dispatcher,
|
|
68
|
+
doctor,
|
|
69
|
+
healer,
|
|
70
|
+
incidents,
|
|
71
|
+
init,
|
|
72
|
+
onboard,
|
|
73
|
+
run,
|
|
74
|
+
skills,
|
|
75
|
+
status,
|
|
76
|
+
update,
|
|
77
|
+
watch,
|
|
78
|
+
web,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_register_commands()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Each module here registers itself onto ``autosentry.cli.app``."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""``autosentry analyze`` — attempts ledger summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from autosentry.cli import app
|
|
12
|
+
from autosentry.cli.style import ACCENT, ERR, INFO, console
|
|
13
|
+
from autosentry.config import load_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def analyze(
|
|
18
|
+
config: Path = typer.Option( # noqa: B008
|
|
19
|
+
Path("autosentry.yaml"),
|
|
20
|
+
"--config",
|
|
21
|
+
"-c",
|
|
22
|
+
help="Path to autosentry.yaml. Defaults to ./autosentry.yaml.",
|
|
23
|
+
),
|
|
24
|
+
since: str = typer.Option(
|
|
25
|
+
"",
|
|
26
|
+
"--since",
|
|
27
|
+
help=(
|
|
28
|
+
"Filter window — e.g. 24h, 30m, 7d, 600s. Empty (the default) = "
|
|
29
|
+
"all-time. Useful before deciding to codify a new rule."
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
json_out: bool = typer.Option(
|
|
33
|
+
False,
|
|
34
|
+
"--json",
|
|
35
|
+
help="Print as JSON instead of rich tables. Pipe to jq for scripting.",
|
|
36
|
+
),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Summarize the attempts ledger (`.autosentry/attempts.tsv`).
|
|
39
|
+
|
|
40
|
+
Three sections: top-failing detectors (sorted by attempt count
|
|
41
|
+
inside the window), per-rule success rate (kept vs regressed vs
|
|
42
|
+
crashed vs pending), and a regression-streak callout when a
|
|
43
|
+
detector has been failing repeatedly.
|
|
44
|
+
|
|
45
|
+
Use this to spot patterns worth codifying as YAML rules: when
|
|
46
|
+
three+ Claude-driven fixes for the same detector all `kept`,
|
|
47
|
+
that's a strong signal the pattern deserves a deterministic
|
|
48
|
+
rule.
|
|
49
|
+
"""
|
|
50
|
+
from autosentry.analyze import filter_window, parse_since, summarize
|
|
51
|
+
from autosentry.ledger import AttemptsLedger
|
|
52
|
+
|
|
53
|
+
cfg = load_config(config)
|
|
54
|
+
ledger_path = cfg.resolve(".autosentry/attempts.tsv")
|
|
55
|
+
ledger = AttemptsLedger.load(ledger_path)
|
|
56
|
+
if not ledger.rows:
|
|
57
|
+
console.print(f"[dim]no attempts yet ({ledger_path})[/dim]")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
window = parse_since(since)
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
console.print(f"[{ERR}]{e}[/{ERR}]")
|
|
64
|
+
raise typer.Exit(code=2) from e
|
|
65
|
+
|
|
66
|
+
rows = filter_window(ledger.rows, window)
|
|
67
|
+
summary = summarize(rows)
|
|
68
|
+
|
|
69
|
+
if json_out:
|
|
70
|
+
typer.echo(_json.dumps(_summary_to_dict(summary), indent=2))
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
title = f"attempts — {summary.total} total"
|
|
74
|
+
if window is not None:
|
|
75
|
+
title += f" (last {since})"
|
|
76
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
77
|
+
status_str = " ".join(
|
|
78
|
+
f"[{INFO}]{k}[/{INFO}]={v}" for k, v in sorted(summary.by_status.items())
|
|
79
|
+
)
|
|
80
|
+
console.print(status_str + "\n")
|
|
81
|
+
|
|
82
|
+
if summary.top_detectors:
|
|
83
|
+
t = Table(title="top failing detectors", show_header=True, header_style="bold")
|
|
84
|
+
t.add_column("detector")
|
|
85
|
+
t.add_column("attempts", justify="right")
|
|
86
|
+
for name, count in summary.top_detectors:
|
|
87
|
+
t.add_row(name, str(count))
|
|
88
|
+
console.print(t)
|
|
89
|
+
|
|
90
|
+
if summary.rules:
|
|
91
|
+
t = Table(title="per-rule success", show_header=True, header_style="bold")
|
|
92
|
+
t.add_column("source (rule or claude)")
|
|
93
|
+
t.add_column("total", justify="right")
|
|
94
|
+
t.add_column("kept", justify="right")
|
|
95
|
+
t.add_column("regressed", justify="right")
|
|
96
|
+
t.add_column("crashed", justify="right")
|
|
97
|
+
t.add_column("pending", justify="right")
|
|
98
|
+
t.add_column("success", justify="right")
|
|
99
|
+
for r in summary.rules:
|
|
100
|
+
t.add_row(
|
|
101
|
+
r.source,
|
|
102
|
+
str(r.total),
|
|
103
|
+
str(r.kept),
|
|
104
|
+
str(r.regressed),
|
|
105
|
+
str(r.crashed),
|
|
106
|
+
str(r.pending),
|
|
107
|
+
f"{r.success_rate * 100:.0f}%",
|
|
108
|
+
)
|
|
109
|
+
console.print(t)
|
|
110
|
+
|
|
111
|
+
streaks_with_regression = [
|
|
112
|
+
s for s in summary.detector_streaks if s.streak_status == "regressed" and s.streak_len >= 2
|
|
113
|
+
]
|
|
114
|
+
if streaks_with_regression:
|
|
115
|
+
t = Table(
|
|
116
|
+
title=f"[{ACCENT}]regression streaks (heads up)[/{ACCENT}]",
|
|
117
|
+
show_header=True,
|
|
118
|
+
header_style="bold",
|
|
119
|
+
)
|
|
120
|
+
t.add_column("detector")
|
|
121
|
+
t.add_column("streak", justify="right")
|
|
122
|
+
for s in streaks_with_regression:
|
|
123
|
+
t.add_row(s.detector, f"{s.streak_len}× regressed")
|
|
124
|
+
console.print(t)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _summary_to_dict(summary) -> dict: # noqa: ANN001 — Summary is internal
|
|
128
|
+
return {
|
|
129
|
+
"total": summary.total,
|
|
130
|
+
"by_status": summary.by_status,
|
|
131
|
+
"top_detectors": summary.top_detectors,
|
|
132
|
+
"rules": [
|
|
133
|
+
{
|
|
134
|
+
"source": r.source,
|
|
135
|
+
"total": r.total,
|
|
136
|
+
"kept": r.kept,
|
|
137
|
+
"regressed": r.regressed,
|
|
138
|
+
"crashed": r.crashed,
|
|
139
|
+
"discarded": r.discarded,
|
|
140
|
+
"pending": r.pending,
|
|
141
|
+
"success_rate": r.success_rate,
|
|
142
|
+
}
|
|
143
|
+
for r in summary.rules
|
|
144
|
+
],
|
|
145
|
+
"detector_streaks": [
|
|
146
|
+
{
|
|
147
|
+
"detector": s.detector,
|
|
148
|
+
"total": s.total,
|
|
149
|
+
"streak_status": s.streak_status,
|
|
150
|
+
"streak_len": s.streak_len,
|
|
151
|
+
}
|
|
152
|
+
for s in summary.detector_streaks
|
|
153
|
+
],
|
|
154
|
+
}
|