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/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
+ )