fleetproof 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.
- fleetproof/__init__.py +46 -0
- fleetproof/__main__.py +6 -0
- fleetproof/checker.py +280 -0
- fleetproof/checks.py +218 -0
- fleetproof/cli.py +293 -0
- fleetproof/hookgate.py +166 -0
- fleetproof/report.py +384 -0
- fleetproof/runlog.py +500 -0
- fleetproof-0.1.0.dist-info/METADATA +231 -0
- fleetproof-0.1.0.dist-info/RECORD +14 -0
- fleetproof-0.1.0.dist-info/WHEEL +5 -0
- fleetproof-0.1.0.dist-info/entry_points.txt +2 -0
- fleetproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleetproof-0.1.0.dist-info/top_level.txt +1 -0
fleetproof/runlog.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Run-id propagation and durable run recording.
|
|
2
|
+
|
|
3
|
+
FleetProof records every recorded invocation to a run log that a *different*
|
|
4
|
+
process reads back. That separation is the point: the process that does the
|
|
5
|
+
work writes the record; the process that verifies the work (the checker, the
|
|
6
|
+
report) reads it. Nothing grades its own homework.
|
|
7
|
+
|
|
8
|
+
Records are written under ``<project-root>/.fleetproof/runs/<run-id>/<tool>-<timestamp>/``.
|
|
9
|
+
Per invocation:
|
|
10
|
+
_root.json — root invocation summary (one per top-level run)
|
|
11
|
+
invocation.json — {tool, subcmd, args, env_filtered, cwd, parent_run, started_at}
|
|
12
|
+
result.json — {exit_code, duration_ms, ended_at, exception}
|
|
13
|
+
output.json — structured output if the function returned a dict
|
|
14
|
+
|
|
15
|
+
Public API:
|
|
16
|
+
current_run_id() -> str
|
|
17
|
+
child_run_id() -> str
|
|
18
|
+
@recorded decorator for any function
|
|
19
|
+
record(tool, subcmd, args) context manager for ad-hoc recording
|
|
20
|
+
project_root() -> Path nearest .fleetproof/ or .git/ ancestor
|
|
21
|
+
runs_dir() -> Path the .fleetproof/runs/ directory
|
|
22
|
+
set_runs_dir(path) override (tests; hook init)
|
|
23
|
+
list_run_records() returns RunRecord summaries
|
|
24
|
+
load_run(run_id) returns RunRecord with sub-invocations
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import functools
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import socket
|
|
36
|
+
import time
|
|
37
|
+
import traceback
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Callable, Iterator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# === Constants ===
|
|
45
|
+
|
|
46
|
+
RUN_ID_ENV = "FLEETPROOF_RUN_ID"
|
|
47
|
+
RUNS_DIR_ENV = "FLEETPROOF_RUNS_DIR" # tests + hook init override
|
|
48
|
+
PARENT_RUN_ID_ENV = "FLEETPROOF_PARENT_RUN_ID"
|
|
49
|
+
NO_RECORD_ENV = "FLEETPROOF_NO_RECORD"
|
|
50
|
+
# Claude Code session identifier, threaded in from the hook stdin payload's
|
|
51
|
+
# ``session_id`` field so every root record created within one Claude Code
|
|
52
|
+
# session shares a key the report can group on. Env-carried like PARENT_RUN_ID
|
|
53
|
+
# because each hook fires as its own OS process.
|
|
54
|
+
SESSION_ID_ENV = "FLEETPROOF_SESSION_ID"
|
|
55
|
+
|
|
56
|
+
# Directory the tool owns inside the user's repo.
|
|
57
|
+
PROJECT_MARKER = ".fleetproof"
|
|
58
|
+
|
|
59
|
+
# Env-var name patterns redacted from invocation.env_filtered
|
|
60
|
+
SENSITIVE_ENV_PATTERNS = [
|
|
61
|
+
re.compile(p, re.IGNORECASE) for p in (
|
|
62
|
+
r"token", r"secret", r"password", r"passwd", r"api[_-]?key",
|
|
63
|
+
r"auth", r"credential", r"private[_-]?key", r"session",
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# Env vars always passed through (whitelist)
|
|
68
|
+
SAFE_ENV_KEYS = frozenset({
|
|
69
|
+
"PATH", "PYTHONPATH", "PWD", "HOME", "USER", "USERNAME",
|
|
70
|
+
RUN_ID_ENV, RUNS_DIR_ENV, "OS", "LANG", "LC_ALL", "TZ",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# === Run-id machinery ===
|
|
75
|
+
|
|
76
|
+
def _generate_run_id() -> str:
|
|
77
|
+
"""Generate <YYYYMMDD>-<HHMMSS>-<short-hash> (sortable, readable, unique)."""
|
|
78
|
+
now = datetime.now(timezone.utc)
|
|
79
|
+
stamp = now.strftime("%Y%m%d-%H%M%S")
|
|
80
|
+
seed = f"{stamp}-{os.getpid()}-{time.perf_counter_ns()}"
|
|
81
|
+
short = hashlib.sha256(seed.encode()).hexdigest()[:6]
|
|
82
|
+
return f"{stamp}-{short}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def current_run_id() -> str:
|
|
86
|
+
"""Return the run-id from env, generating + setting one if absent."""
|
|
87
|
+
rid = os.environ.get(RUN_ID_ENV)
|
|
88
|
+
if rid:
|
|
89
|
+
return rid
|
|
90
|
+
rid = _generate_run_id()
|
|
91
|
+
os.environ[RUN_ID_ENV] = rid
|
|
92
|
+
return rid
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def child_run_id() -> str:
|
|
96
|
+
"""Generate a child run-id extending the current one with a sub-index.
|
|
97
|
+
|
|
98
|
+
First child becomes <parent>.1, then <parent>.2, etc. Tracks index per
|
|
99
|
+
parent process using a side file in the run directory.
|
|
100
|
+
"""
|
|
101
|
+
parent = current_run_id()
|
|
102
|
+
parent_dir = runs_dir() / parent
|
|
103
|
+
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
counter_file = parent_dir / ".child_counter"
|
|
105
|
+
n = 1
|
|
106
|
+
if counter_file.exists():
|
|
107
|
+
try:
|
|
108
|
+
n = int(counter_file.read_text(encoding="utf-8").strip()) + 1
|
|
109
|
+
except ValueError:
|
|
110
|
+
n = 1
|
|
111
|
+
counter_file.write_text(str(n), encoding="utf-8")
|
|
112
|
+
return f"{parent}.{n}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# === Where records live ===
|
|
116
|
+
|
|
117
|
+
_runs_dir_override: Path | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def set_runs_dir(path: Path | None) -> None:
|
|
121
|
+
"""Override the runs directory (tests + hook init)."""
|
|
122
|
+
global _runs_dir_override
|
|
123
|
+
_runs_dir_override = path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def project_root(start: Path | None = None) -> Path:
|
|
127
|
+
"""Return the nearest ancestor that looks like a project root.
|
|
128
|
+
|
|
129
|
+
Walks up from ``start`` (default: cwd) and returns the first directory
|
|
130
|
+
containing a ``.fleetproof/`` marker directory or a ``.git`` entry. Falls
|
|
131
|
+
back to ``start`` itself when no marker is found (orphan runs).
|
|
132
|
+
|
|
133
|
+
This is the whole point of the resolver: FleetProof has to work in *any*
|
|
134
|
+
repo, so it anchors on ordinary version-control markers rather than on any
|
|
135
|
+
project-specific convention.
|
|
136
|
+
"""
|
|
137
|
+
here = (start or Path.cwd()).resolve()
|
|
138
|
+
for candidate in (here, *here.parents):
|
|
139
|
+
if (candidate / PROJECT_MARKER).is_dir() or (candidate / ".git").exists():
|
|
140
|
+
return candidate
|
|
141
|
+
return here
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def runs_dir() -> Path:
|
|
145
|
+
"""Return the ``.fleetproof/runs/`` directory.
|
|
146
|
+
|
|
147
|
+
Resolution order:
|
|
148
|
+
1. Programmatic override (set_runs_dir)
|
|
149
|
+
2. FLEETPROOF_RUNS_DIR env var
|
|
150
|
+
3. <project-root>/.fleetproof/runs/ (walk up for .fleetproof/ or .git)
|
|
151
|
+
4. <cwd>/.fleetproof/runs/ fallback (orphan runs)
|
|
152
|
+
"""
|
|
153
|
+
if _runs_dir_override is not None:
|
|
154
|
+
return _runs_dir_override
|
|
155
|
+
env_val = os.environ.get(RUNS_DIR_ENV)
|
|
156
|
+
if env_val:
|
|
157
|
+
return Path(env_val)
|
|
158
|
+
return project_root() / PROJECT_MARKER / "runs"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# === Env filtering ===
|
|
162
|
+
|
|
163
|
+
def filter_env(env: dict[str, str]) -> dict[str, str]:
|
|
164
|
+
"""Return env with sensitive values redacted.
|
|
165
|
+
|
|
166
|
+
Keys matching SENSITIVE_ENV_PATTERNS are replaced with '<redacted>'.
|
|
167
|
+
Keys in SAFE_ENV_KEYS pass through unchanged regardless of name.
|
|
168
|
+
Everything else passes through (no redaction).
|
|
169
|
+
"""
|
|
170
|
+
out: dict[str, str] = {}
|
|
171
|
+
for k, v in env.items():
|
|
172
|
+
if k in SAFE_ENV_KEYS:
|
|
173
|
+
out[k] = v
|
|
174
|
+
continue
|
|
175
|
+
if any(p.search(k) for p in SENSITIVE_ENV_PATTERNS):
|
|
176
|
+
out[k] = "<redacted>"
|
|
177
|
+
else:
|
|
178
|
+
out[k] = v
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# === Record writing ===
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class RunHandle:
|
|
186
|
+
"""Live handle to an in-progress recording."""
|
|
187
|
+
run_id: str
|
|
188
|
+
tool: str
|
|
189
|
+
subcmd: str
|
|
190
|
+
record_dir: Path
|
|
191
|
+
started_at: datetime
|
|
192
|
+
started_perf: float
|
|
193
|
+
_result_written: bool = field(default=False)
|
|
194
|
+
|
|
195
|
+
def set_output(self, payload: Any) -> None:
|
|
196
|
+
"""Write structured output (must be JSON-serializable)."""
|
|
197
|
+
try:
|
|
198
|
+
(self.record_dir / "output.json").write_text(
|
|
199
|
+
json.dumps(payload, default=str, indent=2),
|
|
200
|
+
encoding="utf-8",
|
|
201
|
+
)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
# Non-serializable output is logged but doesn't fail the run
|
|
204
|
+
(self.record_dir / "output.txt").write_text(
|
|
205
|
+
repr(payload), encoding="utf-8",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def write_result(self, exit_code: int = 0, exception: BaseException | None = None) -> None:
|
|
209
|
+
"""Write result.json. Called automatically by @recorded and record()."""
|
|
210
|
+
if self._result_written:
|
|
211
|
+
return
|
|
212
|
+
duration_ms = (time.perf_counter() - self.started_perf) * 1000.0
|
|
213
|
+
ended_at = datetime.now(timezone.utc)
|
|
214
|
+
result = {
|
|
215
|
+
"exit_code": exit_code,
|
|
216
|
+
"duration_ms": round(duration_ms, 3),
|
|
217
|
+
"ended_at": ended_at.isoformat(),
|
|
218
|
+
}
|
|
219
|
+
if exception is not None:
|
|
220
|
+
result["exception"] = {
|
|
221
|
+
"type": type(exception).__name__,
|
|
222
|
+
"message": str(exception),
|
|
223
|
+
"traceback": traceback.format_exception(
|
|
224
|
+
type(exception), exception, exception.__traceback__
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
(self.record_dir / "result.json").write_text(
|
|
228
|
+
json.dumps(result, indent=2), encoding="utf-8",
|
|
229
|
+
)
|
|
230
|
+
self._result_written = True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _write_invocation(
|
|
234
|
+
record_dir: Path,
|
|
235
|
+
run_id: str,
|
|
236
|
+
tool: str,
|
|
237
|
+
subcmd: str,
|
|
238
|
+
args: dict | None,
|
|
239
|
+
parent_run: str | None,
|
|
240
|
+
started_at: datetime,
|
|
241
|
+
) -> None:
|
|
242
|
+
inv = {
|
|
243
|
+
"run_id": run_id,
|
|
244
|
+
"parent_run_id": parent_run,
|
|
245
|
+
"tool": tool,
|
|
246
|
+
"subcmd": subcmd,
|
|
247
|
+
"args": args or {},
|
|
248
|
+
"cwd": str(Path.cwd()),
|
|
249
|
+
"env_filtered": filter_env(dict(os.environ)),
|
|
250
|
+
"started_at": started_at.isoformat(),
|
|
251
|
+
"host": socket.gethostname(),
|
|
252
|
+
"pid": os.getpid(),
|
|
253
|
+
}
|
|
254
|
+
(record_dir / "invocation.json").write_text(
|
|
255
|
+
json.dumps(inv, indent=2), encoding="utf-8",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _ensure_root_record(run_id: str, tool: str) -> None:
|
|
260
|
+
"""Write _root.json if this is a brand-new top-level run."""
|
|
261
|
+
parent_dir = runs_dir() / run_id
|
|
262
|
+
root_file = parent_dir / "_root.json"
|
|
263
|
+
if root_file.exists():
|
|
264
|
+
return
|
|
265
|
+
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
parent_run = os.environ.get(PARENT_RUN_ID_ENV)
|
|
267
|
+
# Additive field: None for runs not created from a hook (and absent entirely
|
|
268
|
+
# from records written before this field existed) — both read back as None.
|
|
269
|
+
session_id = os.environ.get(SESSION_ID_ENV) or None
|
|
270
|
+
root = {
|
|
271
|
+
"run_id": run_id,
|
|
272
|
+
"parent_run_id": parent_run,
|
|
273
|
+
"session_id": session_id,
|
|
274
|
+
"root_tool": tool,
|
|
275
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
276
|
+
"host": socket.gethostname(),
|
|
277
|
+
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "unknown",
|
|
278
|
+
"pid": os.getpid(),
|
|
279
|
+
}
|
|
280
|
+
root_file.write_text(json.dumps(root, indent=2), encoding="utf-8")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@contextlib.contextmanager
|
|
284
|
+
def record(
|
|
285
|
+
tool: str,
|
|
286
|
+
subcmd: str = "",
|
|
287
|
+
args: dict | None = None,
|
|
288
|
+
) -> Iterator[RunHandle]:
|
|
289
|
+
"""Context manager for recording an arbitrary block of work.
|
|
290
|
+
|
|
291
|
+
Usage:
|
|
292
|
+
with record("my-tool", "do") as h:
|
|
293
|
+
...work...
|
|
294
|
+
h.set_output({"result": "ok"})
|
|
295
|
+
# result.json written automatically on exit
|
|
296
|
+
"""
|
|
297
|
+
run_id = current_run_id()
|
|
298
|
+
_ensure_root_record(run_id, tool)
|
|
299
|
+
started = datetime.now(timezone.utc)
|
|
300
|
+
started_perf = time.perf_counter()
|
|
301
|
+
stamp = started.strftime("%Y%m%d-%H%M%S-%f")
|
|
302
|
+
suffix = subcmd or "_"
|
|
303
|
+
record_dir = runs_dir() / run_id / f"{tool}-{suffix}-{stamp}"
|
|
304
|
+
record_dir.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
parent_run = os.environ.get(PARENT_RUN_ID_ENV)
|
|
306
|
+
_write_invocation(record_dir, run_id, tool, subcmd, args, parent_run, started)
|
|
307
|
+
handle = RunHandle(
|
|
308
|
+
run_id=run_id,
|
|
309
|
+
tool=tool,
|
|
310
|
+
subcmd=subcmd,
|
|
311
|
+
record_dir=record_dir,
|
|
312
|
+
started_at=started,
|
|
313
|
+
started_perf=started_perf,
|
|
314
|
+
)
|
|
315
|
+
try:
|
|
316
|
+
yield handle
|
|
317
|
+
handle.write_result(exit_code=0)
|
|
318
|
+
except BaseException as e:
|
|
319
|
+
handle.write_result(exit_code=1, exception=e)
|
|
320
|
+
raise
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# === Decorator ===
|
|
324
|
+
|
|
325
|
+
def recorded(func: Callable | None = None, *, tool: str | None = None, subcmd: str | None = None):
|
|
326
|
+
"""Decorator that records the invocation, output, and result of a function.
|
|
327
|
+
|
|
328
|
+
Usage:
|
|
329
|
+
@recorded
|
|
330
|
+
def do_thing(...):
|
|
331
|
+
...
|
|
332
|
+
|
|
333
|
+
@recorded(tool="my-tool", subcmd="do")
|
|
334
|
+
def do_thing(...):
|
|
335
|
+
...
|
|
336
|
+
|
|
337
|
+
If `tool` is omitted, the function's module-derived name is used.
|
|
338
|
+
If `subcmd` is omitted, the function's name is used.
|
|
339
|
+
The decorated function's return value (if a dict) is written as output.json.
|
|
340
|
+
"""
|
|
341
|
+
def decorate(f: Callable) -> Callable:
|
|
342
|
+
resolved_tool = tool or _infer_tool_name(f)
|
|
343
|
+
resolved_subcmd = subcmd or f.__name__
|
|
344
|
+
|
|
345
|
+
@functools.wraps(f)
|
|
346
|
+
def wrapper(*args, **kwargs):
|
|
347
|
+
# Skip recording if explicitly disabled (e.g., from inside the CLI itself)
|
|
348
|
+
if os.environ.get(NO_RECORD_ENV) == "1":
|
|
349
|
+
return f(*args, **kwargs)
|
|
350
|
+
with record(resolved_tool, resolved_subcmd, _safe_args(args, kwargs)) as h:
|
|
351
|
+
result = f(*args, **kwargs)
|
|
352
|
+
if isinstance(result, dict):
|
|
353
|
+
h.set_output(result)
|
|
354
|
+
return result
|
|
355
|
+
return wrapper
|
|
356
|
+
|
|
357
|
+
# Allow both @recorded and @recorded(tool=..., subcmd=...)
|
|
358
|
+
if func is not None and callable(func):
|
|
359
|
+
return decorate(func)
|
|
360
|
+
return decorate
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _infer_tool_name(f: Callable) -> str:
|
|
364
|
+
mod = getattr(f, "__module__", "") or ""
|
|
365
|
+
parts = mod.split(".")
|
|
366
|
+
return parts[-1] if parts else "unknown"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _safe_args(args: tuple, kwargs: dict) -> dict:
|
|
370
|
+
"""Reduce args/kwargs to a JSON-serializable shape; drop unserializable."""
|
|
371
|
+
out: dict[str, Any] = {}
|
|
372
|
+
if args:
|
|
373
|
+
out["positional"] = [_safe_repr(a) for a in args]
|
|
374
|
+
if kwargs:
|
|
375
|
+
out["keyword"] = {k: _safe_repr(v) for k, v in kwargs.items()}
|
|
376
|
+
return out
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _safe_repr(v: Any) -> Any:
|
|
380
|
+
try:
|
|
381
|
+
json.dumps(v)
|
|
382
|
+
return v
|
|
383
|
+
except (TypeError, ValueError):
|
|
384
|
+
return f"<{type(v).__name__}>"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# === Reading runs back ===
|
|
388
|
+
|
|
389
|
+
@dataclass
|
|
390
|
+
class SubInvocation:
|
|
391
|
+
record_dir: Path
|
|
392
|
+
tool: str
|
|
393
|
+
subcmd: str
|
|
394
|
+
started_at: str | None
|
|
395
|
+
exit_code: int | None
|
|
396
|
+
duration_ms: float | None
|
|
397
|
+
exception_type: str | None
|
|
398
|
+
|
|
399
|
+
def load_output(self) -> Any | None:
|
|
400
|
+
"""Load this invocation's output.json, or None if absent/unreadable."""
|
|
401
|
+
out_file = self.record_dir / "output.json"
|
|
402
|
+
if not out_file.exists():
|
|
403
|
+
return None
|
|
404
|
+
try:
|
|
405
|
+
return json.loads(out_file.read_text(encoding="utf-8"))
|
|
406
|
+
except (json.JSONDecodeError, OSError):
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass
|
|
411
|
+
class RunRecord:
|
|
412
|
+
run_id: str
|
|
413
|
+
root_dir: Path
|
|
414
|
+
root_tool: str | None
|
|
415
|
+
started_at: str | None
|
|
416
|
+
session_id: str | None = None
|
|
417
|
+
sub_invocations: list[SubInvocation] = field(default_factory=list)
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def failed_count(self) -> int:
|
|
421
|
+
"""Sub-invocations that exited non-zero or raised — the 'what actually failed' count."""
|
|
422
|
+
return sum(
|
|
423
|
+
1 for s in self.sub_invocations
|
|
424
|
+
if (s.exit_code is not None and s.exit_code != 0) or s.exception_type
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def list_run_records() -> list[RunRecord]:
|
|
429
|
+
"""Return all run records in the runs directory, newest first."""
|
|
430
|
+
rd = runs_dir()
|
|
431
|
+
if not rd.exists():
|
|
432
|
+
return []
|
|
433
|
+
out: list[RunRecord] = []
|
|
434
|
+
for run_dir in sorted(rd.iterdir(), reverse=True):
|
|
435
|
+
if not run_dir.is_dir() or run_dir.name.startswith("."):
|
|
436
|
+
continue
|
|
437
|
+
record_obj = _load_run_record(run_dir)
|
|
438
|
+
if record_obj is not None:
|
|
439
|
+
out.append(record_obj)
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def load_run(run_id: str) -> RunRecord | None:
|
|
444
|
+
"""Load a specific run by id, or None if not found."""
|
|
445
|
+
rd = runs_dir() / run_id
|
|
446
|
+
if not rd.exists():
|
|
447
|
+
return None
|
|
448
|
+
return _load_run_record(rd)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _load_run_record(run_dir: Path) -> RunRecord | None:
|
|
452
|
+
root_file = run_dir / "_root.json"
|
|
453
|
+
root_data: dict[str, Any] = {}
|
|
454
|
+
if root_file.exists():
|
|
455
|
+
try:
|
|
456
|
+
root_data = json.loads(root_file.read_text(encoding="utf-8"))
|
|
457
|
+
except (json.JSONDecodeError, OSError):
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
subs: list[SubInvocation] = []
|
|
461
|
+
for sub_dir in sorted(run_dir.iterdir()):
|
|
462
|
+
if not sub_dir.is_dir() or sub_dir.name.startswith("."):
|
|
463
|
+
continue
|
|
464
|
+
subs.append(_load_sub_invocation(sub_dir))
|
|
465
|
+
|
|
466
|
+
return RunRecord(
|
|
467
|
+
run_id=run_dir.name,
|
|
468
|
+
root_dir=run_dir,
|
|
469
|
+
root_tool=root_data.get("root_tool"),
|
|
470
|
+
started_at=root_data.get("started_at"),
|
|
471
|
+
session_id=root_data.get("session_id"),
|
|
472
|
+
sub_invocations=subs,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _load_sub_invocation(sub_dir: Path) -> SubInvocation:
|
|
477
|
+
inv: dict[str, Any] = {}
|
|
478
|
+
res: dict[str, Any] = {}
|
|
479
|
+
inv_file = sub_dir / "invocation.json"
|
|
480
|
+
res_file = sub_dir / "result.json"
|
|
481
|
+
if inv_file.exists():
|
|
482
|
+
try:
|
|
483
|
+
inv = json.loads(inv_file.read_text(encoding="utf-8"))
|
|
484
|
+
except (json.JSONDecodeError, OSError):
|
|
485
|
+
pass
|
|
486
|
+
if res_file.exists():
|
|
487
|
+
try:
|
|
488
|
+
res = json.loads(res_file.read_text(encoding="utf-8"))
|
|
489
|
+
except (json.JSONDecodeError, OSError):
|
|
490
|
+
pass
|
|
491
|
+
exc = res.get("exception") or {}
|
|
492
|
+
return SubInvocation(
|
|
493
|
+
record_dir=sub_dir,
|
|
494
|
+
tool=inv.get("tool", "?"),
|
|
495
|
+
subcmd=inv.get("subcmd", ""),
|
|
496
|
+
started_at=inv.get("started_at"),
|
|
497
|
+
exit_code=res.get("exit_code"),
|
|
498
|
+
duration_ms=res.get("duration_ms"),
|
|
499
|
+
exception_type=exc.get("type") if isinstance(exc, dict) else None,
|
|
500
|
+
)
|