holt-cli 0.1.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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/session.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Running an analysis, and getting told about it.
|
|
2
|
+
|
|
3
|
+
This is the only file in the TUI that touches `holt.agent`, `holt.model` or the
|
|
4
|
+
evidence layer. Screens read a `Session`; they never build a provider, never
|
|
5
|
+
choose a model, and never call the pipeline. Moving the engine's entry point
|
|
6
|
+
therefore breaks one file rather than every screen.
|
|
7
|
+
|
|
8
|
+
The run happens on a worker thread and reports through a queue, so the interface
|
|
9
|
+
stays responsive while a live run spends a minute on the network, and needs no
|
|
10
|
+
cooperation from the engine to do it.
|
|
11
|
+
|
|
12
|
+
A session can also be *restored* from a stored assessment, in which case no
|
|
13
|
+
thread starts and nothing is spent. That path exists so that opening this
|
|
14
|
+
morning's result and running a new one are the same object to every screen.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from holt import model as model_module
|
|
29
|
+
from holt.agent import entry, pipeline
|
|
30
|
+
from holt.evidence.fixtures import FixtureProvider, redact_records
|
|
31
|
+
from holt.evidence.provider import EvidenceProvider
|
|
32
|
+
from holt.report import EntryPoint
|
|
33
|
+
from holt.tui import events, store
|
|
34
|
+
from holt.tui.observe import ObservingModel, ObservingProvider, RunCancelled
|
|
35
|
+
from holt.types import Window
|
|
36
|
+
|
|
37
|
+
# Mirrors `holt.cli`. Imported from there rather than restated so that the TUI
|
|
38
|
+
# reads the same fixtures the CLI and the eval harness read.
|
|
39
|
+
from holt.cli import ISSUE_ROOT, PATHFINDER_TRAJECTORIES, normalise
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RunOptions:
|
|
44
|
+
repo: str
|
|
45
|
+
#: Which evidence this run reads, stated at every call site rather than
|
|
46
|
+
#: defaulted. The default used to be `True`: build a `RunOptions` without
|
|
47
|
+
#: thinking about the question and you got committed fixtures, rendered as
|
|
48
|
+
#: an assessment with no sign that is what they were. Reading a recording
|
|
49
|
+
#: is a choice. It is never what you get for not making one.
|
|
50
|
+
replay: bool
|
|
51
|
+
live: bool = False
|
|
52
|
+
#: Off by default, matching `holt analyze`. The ranking does not beat
|
|
53
|
+
#: GitHub's `good first issue` label, and the engine demoted it to opt-in;
|
|
54
|
+
#: an interface that turned it back on would be quietly overriding a
|
|
55
|
+
#: measured decision.
|
|
56
|
+
entry_points: bool = False
|
|
57
|
+
contributor_days: int = 7
|
|
58
|
+
#: Where a live run records its trajectory.
|
|
59
|
+
#:
|
|
60
|
+
#: Deliberately **not** `fixtures/trajectories/`. `OpenAIModel` appends every
|
|
61
|
+
#: call to the path it is given, so pointing it at the committed fixtures
|
|
62
|
+
#: would have the interface rewrite the evidence the eval harness replays.
|
|
63
|
+
run_root: Path = Path("runs")
|
|
64
|
+
#: Stamped once so every call in a run lands in the same file.
|
|
65
|
+
started: str = field(
|
|
66
|
+
default_factory=lambda: datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def mode(self) -> str:
|
|
71
|
+
"""What this run *is*, in one word, for display and for cache keys.
|
|
72
|
+
|
|
73
|
+
`replay` reads a recording. `recorded` makes real model calls over the
|
|
74
|
+
committed fixtures. `live` additionally reads GitHub.
|
|
75
|
+
"""
|
|
76
|
+
if self.replay:
|
|
77
|
+
return "replay"
|
|
78
|
+
return "live" if self.live else "recorded"
|
|
79
|
+
|
|
80
|
+
def recording(self, repo: str, kind: str) -> Path:
|
|
81
|
+
slug = repo.replace("/", "__")
|
|
82
|
+
return self.run_root / slug / self.started / f"{kind}.jsonl"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Session:
|
|
87
|
+
"""One analysis: its options, its event stream, and its result."""
|
|
88
|
+
|
|
89
|
+
options: RunOptions
|
|
90
|
+
log: list[events.Event] = field(default_factory=list)
|
|
91
|
+
assessment: Any = None
|
|
92
|
+
trace: Any = None
|
|
93
|
+
error: str | None = None
|
|
94
|
+
#: Set when this session is a stored assessment rather than a fresh run.
|
|
95
|
+
#: Every screen showing a result checks it, because a reader must never have
|
|
96
|
+
#: to wonder whether what they are looking at was just computed.
|
|
97
|
+
restored_from: Any = None
|
|
98
|
+
started_at: float = 0.0
|
|
99
|
+
finished_at: float = 0.0
|
|
100
|
+
cost_usd: float = 0.0
|
|
101
|
+
input_tokens: int = 0
|
|
102
|
+
output_tokens: int = 0
|
|
103
|
+
#: The provider the run used. Evidence lookups from the interface go
|
|
104
|
+
#: through it rather than through a fresh one, so a reader checking a claim
|
|
105
|
+
#: sees exactly what Stage D saw — a new provider has fetched nothing and
|
|
106
|
+
#: would report every id as unresolvable.
|
|
107
|
+
provider: Any = None
|
|
108
|
+
#: The provider a reading order read, when one ran. Kept for the same
|
|
109
|
+
#: reason as `provider`: the ids under the reading order come from here and
|
|
110
|
+
#: nowhere else, so storing the records behind them needs this too.
|
|
111
|
+
issue_provider: Any = None
|
|
112
|
+
#: Built on demand for a stored assessment, which has no run behind it.
|
|
113
|
+
#: See `StoredEvidence` and `ReopenedEvidence`.
|
|
114
|
+
_reopened: Any = None
|
|
115
|
+
_reopen_tried: bool = False
|
|
116
|
+
#: The stage the run is in, for anything showing progress without showing
|
|
117
|
+
#: the stream. Set from the events, so it cannot disagree with them.
|
|
118
|
+
stage: str = ""
|
|
119
|
+
#: Set when the run was stopped on purpose. Distinct from `error`: a stop
|
|
120
|
+
#: is not a failure, and nothing about it should read like one.
|
|
121
|
+
cancelled: bool = False
|
|
122
|
+
_queue: queue.Queue = field(default_factory=queue.Queue)
|
|
123
|
+
_thread: threading.Thread | None = None
|
|
124
|
+
_cancel: threading.Event = field(default_factory=threading.Event)
|
|
125
|
+
#: Stages that finished before a stop landed. Recorded on the worker thread,
|
|
126
|
+
#: which is the only thread that writes it.
|
|
127
|
+
_completed: list[str] = field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
# ─── construction ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def restored(cls, stored: Any) -> "Session":
|
|
133
|
+
"""A finished session backed by a stored assessment. Nothing runs."""
|
|
134
|
+
options = RunOptions(
|
|
135
|
+
repo=stored.repo,
|
|
136
|
+
replay=stored.mode == "replay",
|
|
137
|
+
live=stored.mode == "live",
|
|
138
|
+
contributor_days=stored.contributor_days,
|
|
139
|
+
)
|
|
140
|
+
session = cls(options=options)
|
|
141
|
+
session.assessment = stored.assessment
|
|
142
|
+
session.restored_from = stored
|
|
143
|
+
session.started_at = stored.created_at
|
|
144
|
+
session.finished_at = stored.created_at + stored.duration_seconds
|
|
145
|
+
session.cost_usd = stored.cost_usd
|
|
146
|
+
# The run's own stream, as it was stored. Every screen that renders a
|
|
147
|
+
# trace renders it from the log, so a stored run and a live one are the
|
|
148
|
+
# same thing to look at — a restored session simply has its log already
|
|
149
|
+
# full instead of filling as the events arrive.
|
|
150
|
+
session.log = list(getattr(stored, "events", []) or [])
|
|
151
|
+
return session
|
|
152
|
+
|
|
153
|
+
# ─── lifecycle ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def start(self) -> None:
|
|
156
|
+
if self._thread is not None:
|
|
157
|
+
raise RuntimeError("session already started")
|
|
158
|
+
if self.restored_from is not None:
|
|
159
|
+
raise RuntimeError("a restored session has nothing to run")
|
|
160
|
+
self.started_at = time.time()
|
|
161
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
162
|
+
self._thread.start()
|
|
163
|
+
|
|
164
|
+
def cancel(self) -> None:
|
|
165
|
+
"""Ask the run to stop. It stops at the next model call or evidence read.
|
|
166
|
+
|
|
167
|
+
Cannot be undone, and deliberately does not join: the caller is a key
|
|
168
|
+
binding, and blocking the interface until a live model call returns
|
|
169
|
+
would freeze the very screen that has to say `stopping`.
|
|
170
|
+
"""
|
|
171
|
+
self._cancel.set()
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def running(self) -> bool:
|
|
175
|
+
"""A worker is alive and has not yet produced an outcome."""
|
|
176
|
+
return self._thread is not None and self._thread.is_alive()
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def stopping(self) -> bool:
|
|
180
|
+
"""Stopped by the reader, but the worker has not wound down yet."""
|
|
181
|
+
return self._cancel.is_set() and self.running
|
|
182
|
+
|
|
183
|
+
def drain(self) -> list[events.Event]:
|
|
184
|
+
"""Every event that arrived since the last call. Never blocks."""
|
|
185
|
+
out: list[events.Event] = []
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
event = self._queue.get_nowait()
|
|
189
|
+
except queue.Empty:
|
|
190
|
+
break
|
|
191
|
+
self.log.append(event)
|
|
192
|
+
if isinstance(event, events.RunFinished):
|
|
193
|
+
self.assessment, self.trace = event.assessment, event.trace
|
|
194
|
+
self.finished_at = time.time()
|
|
195
|
+
elif isinstance(event, events.RunFailed):
|
|
196
|
+
self.error = event.error
|
|
197
|
+
self.finished_at = time.time()
|
|
198
|
+
elif isinstance(event, events.RunCancelled):
|
|
199
|
+
self.cancelled = True
|
|
200
|
+
self.finished_at = time.time()
|
|
201
|
+
elif isinstance(event, events.StageStarted):
|
|
202
|
+
self.stage = event.stage
|
|
203
|
+
elif isinstance(event, events.UsageUpdated):
|
|
204
|
+
self.input_tokens = event.input_tokens
|
|
205
|
+
self.output_tokens = event.output_tokens
|
|
206
|
+
# Cost, but only when a call was actually paid for. A replay
|
|
207
|
+
# reports the token counts the *original* run recorded, and
|
|
208
|
+
# those price out to a real number; carrying it here would let
|
|
209
|
+
# any screen render spend for a run that bought nothing.
|
|
210
|
+
if not self.options.replay:
|
|
211
|
+
self.cost_usd = event.cost_usd
|
|
212
|
+
out.append(event)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def finished(self) -> bool:
|
|
217
|
+
"""Nothing more will arrive: it produced a report, failed, or was stopped."""
|
|
218
|
+
return self.assessment is not None or self.error is not None or self.cancelled
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def duration(self) -> float:
|
|
222
|
+
if not self.started_at:
|
|
223
|
+
return 0.0
|
|
224
|
+
return (self.finished_at or time.time()) - self.started_at
|
|
225
|
+
|
|
226
|
+
def evidence(self) -> Any:
|
|
227
|
+
"""Where evidence lookups from the interface go, or None.
|
|
228
|
+
|
|
229
|
+
A run hands over the provider it used, so the inspector shows exactly
|
|
230
|
+
what Stage D saw. A stored assessment has no run behind it and reads its
|
|
231
|
+
evidence back on first use — see `StoredEvidence`, which is why
|
|
232
|
+
reopening this morning's report and pressing enter on a claim reads the
|
|
233
|
+
record rather than apologising for not having one.
|
|
234
|
+
"""
|
|
235
|
+
if self.provider is not None:
|
|
236
|
+
return self.provider
|
|
237
|
+
if self.restored_from is None:
|
|
238
|
+
return None
|
|
239
|
+
if not self._reopen_tried:
|
|
240
|
+
self._reopen_tried = True
|
|
241
|
+
self._reopened = reopen_evidence(self.restored_from)
|
|
242
|
+
return self._reopened
|
|
243
|
+
|
|
244
|
+
def resolve(self, evidence_id: str):
|
|
245
|
+
"""The record behind an id, or None. Read-only, and never raises."""
|
|
246
|
+
source = self.evidence()
|
|
247
|
+
if source is None:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
return source.resolve(evidence_id)
|
|
251
|
+
except Exception: # noqa: BLE001 - the inspector reports it, not this
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def evidence_note(self) -> str:
|
|
256
|
+
"""Why nothing could be looked up, or "" when a lookup is possible.
|
|
257
|
+
|
|
258
|
+
An id that was asked about and did not resolve is a statement about the
|
|
259
|
+
evidence, and the inspector says so in its own words. This is the other
|
|
260
|
+
case: nobody asked, and the reason is worth a sentence rather than a
|
|
261
|
+
shrug — every one of them ends in something the reader can do.
|
|
262
|
+
"""
|
|
263
|
+
source = self.evidence()
|
|
264
|
+
if source is None:
|
|
265
|
+
if self.restored_from is not None:
|
|
266
|
+
return (
|
|
267
|
+
"This assessment read GitHub live and was stored before "
|
|
268
|
+
"holt kept the records behind its claims, which exist "
|
|
269
|
+
"nowhere else. Re-run it — ctrl+r on the report — to read "
|
|
270
|
+
"the record behind this id."
|
|
271
|
+
)
|
|
272
|
+
return (
|
|
273
|
+
"No evidence was loaded for this session, so there is nothing "
|
|
274
|
+
"to look this id up in."
|
|
275
|
+
)
|
|
276
|
+
return getattr(source, "unavailable", "")
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def evidence_provenance(self) -> str:
|
|
280
|
+
"""Where a record shown to the reader came from, when that is not the
|
|
281
|
+
run itself. Empty for a session that still has its own provider."""
|
|
282
|
+
return getattr(self.evidence(), "provenance", "")
|
|
283
|
+
|
|
284
|
+
def wait(self, timeout: float | None = None) -> None:
|
|
285
|
+
"""Block until the worker exits. Used by tests."""
|
|
286
|
+
if self._thread is not None:
|
|
287
|
+
self._thread.join(timeout)
|
|
288
|
+
self.drain()
|
|
289
|
+
|
|
290
|
+
def to_entry(self) -> Any:
|
|
291
|
+
"""This session as something the store can keep, if it produced anything.
|
|
292
|
+
|
|
293
|
+
A restored session returns nothing: re-saving it would move its
|
|
294
|
+
timestamp forward and make a stored answer look freshly computed.
|
|
295
|
+
"""
|
|
296
|
+
if self.assessment is None or self.restored_from is not None:
|
|
297
|
+
return None
|
|
298
|
+
return store.Entry(
|
|
299
|
+
repo=self.options.repo,
|
|
300
|
+
mode=self.options.mode,
|
|
301
|
+
created_at=self.started_at or time.time(),
|
|
302
|
+
assessment=self.assessment,
|
|
303
|
+
contributor_days=self.options.contributor_days,
|
|
304
|
+
duration_seconds=self.duration,
|
|
305
|
+
cost_usd=self.cost_usd,
|
|
306
|
+
events=list(self.log),
|
|
307
|
+
evidence=self.evidence_for_storage(),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def evidence_for_storage(self) -> list[Any]:
|
|
311
|
+
"""The records behind this assessment's ids, to be stored with it.
|
|
312
|
+
|
|
313
|
+
Only the ids the report itself carries — every claim, and every row of
|
|
314
|
+
the reading order. A run reads thousands of records and storing all of
|
|
315
|
+
them would put megabytes next to every report, while the interface can
|
|
316
|
+
only ever look up an id that is printed on the page. What it stores is
|
|
317
|
+
therefore bounded by the report, not by the repository.
|
|
318
|
+
|
|
319
|
+
Read back out of the providers the run is still holding, which keep
|
|
320
|
+
every record they fetched. That costs no network even in live mode, and
|
|
321
|
+
it means the record stored is the record the run had rather than a
|
|
322
|
+
fresh read of a window that has since moved.
|
|
323
|
+
|
|
324
|
+
Credentials are stripped on the way out, the same way `write_fixture`
|
|
325
|
+
strips them: this is evidence landing on disk, and it does not matter
|
|
326
|
+
that the directory is a local one.
|
|
327
|
+
"""
|
|
328
|
+
assessment = self.assessment
|
|
329
|
+
if assessment is None:
|
|
330
|
+
return []
|
|
331
|
+
wanted = [c.evidence_id for c in assessment.claims if c.evidence_id]
|
|
332
|
+
wanted += [
|
|
333
|
+
point.evidence_id
|
|
334
|
+
for point in (getattr(assessment, "entry_points", None) or [])
|
|
335
|
+
if point.evidence_id
|
|
336
|
+
]
|
|
337
|
+
found: dict[str, Any] = {}
|
|
338
|
+
for evidence_id in wanted:
|
|
339
|
+
if evidence_id in found:
|
|
340
|
+
continue
|
|
341
|
+
record = self._record_behind(evidence_id)
|
|
342
|
+
if record is not None:
|
|
343
|
+
found[evidence_id] = record
|
|
344
|
+
records, _ = redact_records(found.values())
|
|
345
|
+
return records
|
|
346
|
+
|
|
347
|
+
def _record_behind(self, evidence_id: str) -> Any:
|
|
348
|
+
"""One record out of the run's own providers, or None. Never raises.
|
|
349
|
+
|
|
350
|
+
Deliberately not through the observing wrapper: see
|
|
351
|
+
`ObservingProvider.inner`. An id that cannot be looked up is stored as
|
|
352
|
+
nothing at all, which the inspector already has a sentence for — better
|
|
353
|
+
than taking the whole write down over one record.
|
|
354
|
+
"""
|
|
355
|
+
for source in (self.provider, self.issue_provider):
|
|
356
|
+
if source is None:
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
record = getattr(source, "inner", source).resolve(evidence_id)
|
|
360
|
+
except Exception: # noqa: BLE001 - a report is worth storing regardless
|
|
361
|
+
continue
|
|
362
|
+
if record is not None:
|
|
363
|
+
return record
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# ─── the run ────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def _emit(self, event: events.Event) -> None:
|
|
369
|
+
if isinstance(event, events.StageFinished):
|
|
370
|
+
self._completed.append(event.stage)
|
|
371
|
+
self._queue.put(event)
|
|
372
|
+
|
|
373
|
+
def _run(self) -> None:
|
|
374
|
+
opts = self.options
|
|
375
|
+
repo = normalise(opts.repo)
|
|
376
|
+
try:
|
|
377
|
+
provider = ObservingProvider(
|
|
378
|
+
_provider(opts.live), self._emit, self._cancel.is_set
|
|
379
|
+
)
|
|
380
|
+
self.provider = provider
|
|
381
|
+
client = ObservingModel(
|
|
382
|
+
_client(repo, opts, "verdict"), self._emit, repo, self._cancel.is_set
|
|
383
|
+
)
|
|
384
|
+
self._emit(events.RunStarted(repo=repo, replayed=opts.replay))
|
|
385
|
+
|
|
386
|
+
assessment, trace = pipeline.analyze(
|
|
387
|
+
repo, provider, client, contributor_days=opts.contributor_days
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Stage D's drops are recovered from the trace rather than guessed
|
|
391
|
+
# from the resolution stream: the engine already decided, and the
|
|
392
|
+
# view must show what it decided, not a reconstruction of it.
|
|
393
|
+
for dropped in trace.dropped:
|
|
394
|
+
self._emit(
|
|
395
|
+
events.FindingDropped(
|
|
396
|
+
field=dropped.field,
|
|
397
|
+
value=dropped.value,
|
|
398
|
+
cited=tuple(dropped.evidence_ids),
|
|
399
|
+
reason="unresolved",
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
for invented in trace.invented:
|
|
403
|
+
self._emit(
|
|
404
|
+
events.FindingDropped(
|
|
405
|
+
field=invented.field,
|
|
406
|
+
value=invented.value,
|
|
407
|
+
cited=tuple(invented.evidence_ids),
|
|
408
|
+
reason="unquoted",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
self._emit(
|
|
412
|
+
events.StageFinished(
|
|
413
|
+
stage="verify",
|
|
414
|
+
seconds=0.0,
|
|
415
|
+
summary=(
|
|
416
|
+
f"{trace.before_verification} findings → "
|
|
417
|
+
f"{trace.after_verification} kept, {len(trace.dropped)} dropped"
|
|
418
|
+
+ (f", {len(trace.invented)} unquoted" if trace.invented else "")
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
self._emit(
|
|
423
|
+
events.StageFinished(
|
|
424
|
+
stage="verdict", seconds=0.0, summary=assessment.verdict.value
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if opts.entry_points:
|
|
429
|
+
self._rank(assessment, repo, provider, opts)
|
|
430
|
+
|
|
431
|
+
self._emit(events.RunFinished(assessment=assessment, trace=trace))
|
|
432
|
+
except RunCancelled:
|
|
433
|
+
# Caught ahead of `Exception`: a stop the reader asked for must not
|
|
434
|
+
# be reported as a defect, and nothing partial is stored.
|
|
435
|
+
self._emit(events.RunCancelled(completed_stages=tuple(self._completed)))
|
|
436
|
+
except Exception as exc: # noqa: BLE001 - surfaced in the UI, not swallowed
|
|
437
|
+
self._emit(events.RunFailed(error=readable(exc)))
|
|
438
|
+
|
|
439
|
+
def _rank(self, assessment, repo: str, provider, opts: RunOptions) -> None:
|
|
440
|
+
"""Attach a reading order, or say nothing.
|
|
441
|
+
|
|
442
|
+
Silent on a missing fixture for the same reason `holt.cli` is silent:
|
|
443
|
+
a tool that invents an entry point when it has no issues is worse than
|
|
444
|
+
one that omits the section.
|
|
445
|
+
"""
|
|
446
|
+
issue_provider = _issue_provider(opts.live)
|
|
447
|
+
try:
|
|
448
|
+
issues = issue_provider.fetch(repo)
|
|
449
|
+
except FileNotFoundError:
|
|
450
|
+
return
|
|
451
|
+
# Held on the session, not just locally: the reading order's ids resolve
|
|
452
|
+
# against this and nothing else, and they are stored with the report.
|
|
453
|
+
self.issue_provider = issue_provider
|
|
454
|
+
try:
|
|
455
|
+
client = _client(repo, opts, PATHFINDER_TRAJECTORIES)
|
|
456
|
+
self._emit(
|
|
457
|
+
events.StageStarted(
|
|
458
|
+
stage="pathfinder", model=model_module.model_for("pathfinder")
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
ranked = entry.rank(repo, list(issues), list(provider.fetch(repo)), client)
|
|
462
|
+
except FileNotFoundError:
|
|
463
|
+
return
|
|
464
|
+
assessment.entry_points = [
|
|
465
|
+
EntryPoint(r["evidence_id"], r["first_step"], r.get("why", ""))
|
|
466
|
+
for r in ranked
|
|
467
|
+
]
|
|
468
|
+
# Same reason as `holt.cli.add_entry_points`: the ranker has its own
|
|
469
|
+
# client and can be pointed at its own model, and the footer names every
|
|
470
|
+
# model that wrote something on the page.
|
|
471
|
+
for name in client.usage.models:
|
|
472
|
+
if name not in assessment.models:
|
|
473
|
+
assessment.models.append(name)
|
|
474
|
+
self._emit(
|
|
475
|
+
events.StageFinished(
|
|
476
|
+
stage="pathfinder", seconds=0.0, summary=f"{len(ranked)} issues ranked"
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class StoredEvidence:
|
|
482
|
+
"""The records stored with an assessment, as something to look ids up in.
|
|
483
|
+
|
|
484
|
+
A report is only checkable while the records under it can be read, and for
|
|
485
|
+
a live run those records exist nowhere but the process that fetched them.
|
|
486
|
+
So the ids the report prints are stored with it — see
|
|
487
|
+
`Session.evidence_for_storage` — and this is what reads them back. Opening
|
|
488
|
+
this morning's live report and pressing enter on a claim shows the record
|
|
489
|
+
Stage D saw, which is the whole point of citing one.
|
|
490
|
+
|
|
491
|
+
A fixture-backed run additionally keeps its original source, used only for
|
|
492
|
+
an id the stored set does not have. `provenance` follows whichever answered,
|
|
493
|
+
because "the run held this" and "this was read off disk just now" are
|
|
494
|
+
different statements and the reader is owed the true one.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
#: What the inspector prints under a record that came from the stored set.
|
|
498
|
+
STORED = "stored with this assessment, as the run read it"
|
|
499
|
+
|
|
500
|
+
def __init__(self, records: list[Any], fallback: Any = None) -> None:
|
|
501
|
+
self._records = {
|
|
502
|
+
r.evidence_id: r for r in records if getattr(r, "evidence_id", "")
|
|
503
|
+
}
|
|
504
|
+
#: Consulted only for an id the stored records do not cover.
|
|
505
|
+
self._fallback = fallback
|
|
506
|
+
#: Nothing to report: there are records, so lookups are possible.
|
|
507
|
+
self.unavailable = ""
|
|
508
|
+
self.provenance = self.STORED
|
|
509
|
+
|
|
510
|
+
def resolve(self, evidence_id: str):
|
|
511
|
+
record = self._records.get(evidence_id)
|
|
512
|
+
if record is not None:
|
|
513
|
+
self.provenance = self.STORED
|
|
514
|
+
return record
|
|
515
|
+
if self._fallback is None:
|
|
516
|
+
return None
|
|
517
|
+
record = self._fallback.resolve(evidence_id)
|
|
518
|
+
# Set after the lookup, and read by the inspector after it too, so the
|
|
519
|
+
# line under the record names the source that actually produced it.
|
|
520
|
+
self.provenance = getattr(self._fallback, "provenance", "")
|
|
521
|
+
return record
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class ReopenedEvidence:
|
|
525
|
+
"""The source a stored assessment was built from, opened again.
|
|
526
|
+
|
|
527
|
+
Second in line behind `StoredEvidence`, and reached only for an id the
|
|
528
|
+
records stored with the report do not cover. For a replay or a recorded run
|
|
529
|
+
that source is a committed fixture — reading it again reads the same bytes
|
|
530
|
+
Stage D read, so the record the inspector shows is still the record the
|
|
531
|
+
claim was drawn from.
|
|
532
|
+
|
|
533
|
+
Two things this is careful about:
|
|
534
|
+
|
|
535
|
+
* **Nothing is read until a claim is opened.** Reopening a report stays
|
|
536
|
+
instant; the fixture load happens on the first `resolve`.
|
|
537
|
+
* **A live run is not re-crawled.** Its records came off GitHub, and a
|
|
538
|
+
remote crawl on the interface's own thread against a window that has
|
|
539
|
+
moved since would not be the run's evidence however it were labelled.
|
|
540
|
+
A live report is checked against the records stored with it, and an old
|
|
541
|
+
one that has none gets no source at all and says so.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
def __init__(self, repo: str, providers: list[Any]) -> None:
|
|
545
|
+
self.repo = repo
|
|
546
|
+
#: In lookup order. The first is where the report's claims came from;
|
|
547
|
+
#: any others (issue evidence, behind the reading order) are extra, and
|
|
548
|
+
#: their absence is ordinary rather than something to report.
|
|
549
|
+
self._providers = providers
|
|
550
|
+
self._loaded = False
|
|
551
|
+
#: Set when the primary source could not be read at all. Shown by the
|
|
552
|
+
#: inspector in place of "does not resolve", which would blame the
|
|
553
|
+
#: evidence for a file that is simply not there any more.
|
|
554
|
+
self.unavailable = ""
|
|
555
|
+
#: Where a record shown from here actually came from. The inspector
|
|
556
|
+
#: prints it under the record, because "the run held this" and "this
|
|
557
|
+
#: was read back off disk just now" are different statements and only
|
|
558
|
+
#: one of them is true here.
|
|
559
|
+
self.provenance = ""
|
|
560
|
+
|
|
561
|
+
def _load(self) -> None:
|
|
562
|
+
if self._loaded:
|
|
563
|
+
return
|
|
564
|
+
self._loaded = True
|
|
565
|
+
for index, provider in enumerate(self._providers):
|
|
566
|
+
try:
|
|
567
|
+
provider.fetch(self.repo)
|
|
568
|
+
except Exception as exc: # noqa: BLE001 - reported, never raised
|
|
569
|
+
if index == 0:
|
|
570
|
+
self.unavailable = (
|
|
571
|
+
f"The evidence this assessment was built from cannot be "
|
|
572
|
+
f"read back: {readable(exc)}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def resolve(self, evidence_id: str):
|
|
576
|
+
self._load()
|
|
577
|
+
for provider in self._providers:
|
|
578
|
+
try:
|
|
579
|
+
record = provider.resolve(evidence_id)
|
|
580
|
+
except Exception: # noqa: BLE001 - one dead source is not the end
|
|
581
|
+
continue
|
|
582
|
+
if record is not None:
|
|
583
|
+
return record
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def reopen_evidence(stored: Any) -> Any:
|
|
588
|
+
"""The sources a stored assessment can be checked against, or None.
|
|
589
|
+
|
|
590
|
+
The records the report cites are stored with it, so they come first and are
|
|
591
|
+
all a live assessment ever has — its crawl is on no disk anywhere else. A
|
|
592
|
+
fixture-backed one keeps its original source behind them, for an id the
|
|
593
|
+
stored set happens not to cover.
|
|
594
|
+
|
|
595
|
+
None is returned only for an assessment written before records were kept
|
|
596
|
+
*and* produced live. That is the one case with nothing to read, and the
|
|
597
|
+
session says which rather than blaming the evidence.
|
|
598
|
+
"""
|
|
599
|
+
records = list(getattr(stored, "evidence", None) or [])
|
|
600
|
+
fallback = _original_source(stored)
|
|
601
|
+
if records:
|
|
602
|
+
return StoredEvidence(records, fallback)
|
|
603
|
+
return fallback
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _original_source(stored: Any) -> ReopenedEvidence | None:
|
|
607
|
+
"""The source the run itself read, opened again, or None if it cannot be.
|
|
608
|
+
|
|
609
|
+
Keyed off the mode the assessment was produced in, because that is what
|
|
610
|
+
decides where its ids came from. A live run is not re-crawled: see
|
|
611
|
+
`ReopenedEvidence`.
|
|
612
|
+
"""
|
|
613
|
+
if getattr(stored, "mode", "") == "live":
|
|
614
|
+
return None
|
|
615
|
+
repo = normalise(stored.repo)
|
|
616
|
+
primary = FixtureProvider(Window.PRE_T)
|
|
617
|
+
source = ReopenedEvidence(
|
|
618
|
+
repo,
|
|
619
|
+
# Issue evidence second: it is where the reading order's ids live, and
|
|
620
|
+
# a repository without an issue fixture is ordinary rather than broken.
|
|
621
|
+
[primary, FixtureProvider(Window.PRE_T, root=Path(ISSUE_ROOT))],
|
|
622
|
+
)
|
|
623
|
+
source.provenance = (
|
|
624
|
+
f"read back from {primary.path_for(repo)}, the fixture this assessment "
|
|
625
|
+
"was built from"
|
|
626
|
+
)
|
|
627
|
+
return source
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def readable(exc: BaseException) -> str:
|
|
631
|
+
"""Turn an exception into something a person can act on.
|
|
632
|
+
|
|
633
|
+
The failures that actually happen get a sentence saying what to do next.
|
|
634
|
+
Everything else is reported as itself rather than dressed up, because a
|
|
635
|
+
confident wrong guess about a cause is worse than the raw error.
|
|
636
|
+
"""
|
|
637
|
+
text = str(exc)
|
|
638
|
+
if isinstance(exc, FileNotFoundError):
|
|
639
|
+
return (
|
|
640
|
+
"No recording for this repository, so it cannot be replayed. "
|
|
641
|
+
"Run it live instead."
|
|
642
|
+
)
|
|
643
|
+
if "not found or not public" in text:
|
|
644
|
+
return f"{text}. Check the owner and name, and that the repository is public."
|
|
645
|
+
if "GITHUB_TOKEN" in text or "OPENAI_API_KEY" in text:
|
|
646
|
+
return text
|
|
647
|
+
if "rate limit" in text.lower():
|
|
648
|
+
return f"{text}. GitHub is rate limiting; wait a few minutes."
|
|
649
|
+
return f"{type(exc).__name__}: {text}"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _client(repo: str, opts: RunOptions, kind: str):
|
|
653
|
+
"""The model client for one part of a run.
|
|
654
|
+
|
|
655
|
+
Replay reads the committed trajectory, exactly as the CLI does. A live run
|
|
656
|
+
records to `runs/` instead, so the interface never appends to the fixtures
|
|
657
|
+
the eval harness replays.
|
|
658
|
+
"""
|
|
659
|
+
if opts.replay:
|
|
660
|
+
directory = (
|
|
661
|
+
model_module.TRAJECTORY_DIR
|
|
662
|
+
if kind == "verdict"
|
|
663
|
+
else model_module.TRAJECTORY_DIR / kind
|
|
664
|
+
)
|
|
665
|
+
return model_module.ReplayModel(directory / (repo.replace("/", "__") + ".jsonl"))
|
|
666
|
+
return model_module.OpenAIModel(opts.recording(repo, kind))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def missing_credentials(opts: RunOptions) -> list[str]:
|
|
670
|
+
"""What a run needs before it is worth starting.
|
|
671
|
+
|
|
672
|
+
Checked up front so the answer arrives as a sentence rather than a traceback
|
|
673
|
+
part-way through a full-screen interface.
|
|
674
|
+
"""
|
|
675
|
+
missing = []
|
|
676
|
+
if not opts.replay and not os.environ.get("OPENAI_API_KEY"):
|
|
677
|
+
missing.append(
|
|
678
|
+
"OPENAI_API_KEY — the stages call a model. Without it, use replay."
|
|
679
|
+
)
|
|
680
|
+
if opts.live and not os.environ.get("GITHUB_TOKEN"):
|
|
681
|
+
missing.append(
|
|
682
|
+
"GITHUB_TOKEN — live mode reads GitHub directly. Without it, only "
|
|
683
|
+
"repositories with committed fixtures can be assessed."
|
|
684
|
+
)
|
|
685
|
+
return missing
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def has_recording(repo: str) -> bool:
|
|
689
|
+
"""Whether this repository can be replayed for free."""
|
|
690
|
+
try:
|
|
691
|
+
return (
|
|
692
|
+
model_module.TRAJECTORY_DIR / (repo.replace("/", "__") + ".jsonl")
|
|
693
|
+
).is_file()
|
|
694
|
+
except OSError:
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _provider(live: bool) -> EvidenceProvider:
|
|
699
|
+
if not live:
|
|
700
|
+
return FixtureProvider(Window.PRE_T)
|
|
701
|
+
from holt.evidence.github_graphql import LiveGitHubProvider
|
|
702
|
+
|
|
703
|
+
return LiveGitHubProvider(Window.PRE_T)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _issue_provider(live: bool) -> EvidenceProvider:
|
|
707
|
+
if not live:
|
|
708
|
+
return FixtureProvider(Window.PRE_T, root=Path(ISSUE_ROOT))
|
|
709
|
+
from holt.evidence.github_graphql import LiveGitHubIssueProvider
|
|
710
|
+
|
|
711
|
+
return LiveGitHubIssueProvider(Window.PRE_T)
|