flightrecorder-ai 1.0.0__tar.gz
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.
- flightrecorder_ai-1.0.0/LICENSE +18 -0
- flightrecorder_ai-1.0.0/PKG-INFO +152 -0
- flightrecorder_ai-1.0.0/README.md +121 -0
- flightrecorder_ai-1.0.0/setup.cfg +4 -0
- flightrecorder_ai-1.0.0/setup.py +35 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/__init__.py +15 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/__main__.py +6 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/cli.py +244 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/compare.py +49 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/demo_agent.py +90 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/export.py +72 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/files.py +53 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/git.py +38 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/investigate.py +78 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/recorder.py +233 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/redact.py +59 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/replay.py +38 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/store.py +182 -0
- flightrecorder_ai-1.0.0/src/flightrecorder/web.py +91 -0
- flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/PKG-INFO +152 -0
- flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/SOURCES.txt +24 -0
- flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/dependency_links.txt +1 -0
- flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/entry_points.txt +2 -0
- flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/top_level.txt +1 -0
- flightrecorder_ai-1.0.0/tests/test_advanced.py +109 -0
- flightrecorder_ai-1.0.0/tests/test_flightrecorder.py +110 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Mohamed Ansar
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
18
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flightrecorder-ai
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A local black-box recorder and failure investigator for AI agents
|
|
5
|
+
Home-page: https://github.com/kmsmohamedansar/agent-flight-recorder
|
|
6
|
+
Author: Mohamed Ansar
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Project-URL: Source, https://github.com/kmsmohamedansar/agent-flight-recorder
|
|
9
|
+
Project-URL: Issues, https://github.com/kmsmohamedansar/agent-flight-recorder/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
Dynamic: project-url
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
# Agent Flight Recorder
|
|
33
|
+
|
|
34
|
+
A local black-box recorder for AI agents. It captures tool calls, results,
|
|
35
|
+
exceptions, file changes and Git context in SQLite, then connects failures to the
|
|
36
|
+
recorded evidence that most likely caused them.
|
|
37
|
+
|
|
38
|
+
The runtime has no third-party dependencies and supports Python 3.9+.
|
|
39
|
+
|
|
40
|
+
The PyPI distribution is named `flightrecorder-ai`; the Python import and command
|
|
41
|
+
remain `flightrecorder`.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python3 -m venv .venv
|
|
47
|
+
. .venv/bin/activate
|
|
48
|
+
python -m pip install flightrecorder-ai
|
|
49
|
+
|
|
50
|
+
# Synthetic example from the product brief
|
|
51
|
+
flightrecorder demo
|
|
52
|
+
flightrecorder investigate run-1042
|
|
53
|
+
|
|
54
|
+
# Real files, wrapped tools and a subprocess test
|
|
55
|
+
flightrecorder demo-agent
|
|
56
|
+
|
|
57
|
+
# Local searchable timeline
|
|
58
|
+
flightrecorder serve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Open <http://127.0.0.1:8765> after starting the server. The project supports the
|
|
62
|
+
older pip bundled with some system Python installations; upgrading pip is optional.
|
|
63
|
+
|
|
64
|
+
## Instrument an agent
|
|
65
|
+
|
|
66
|
+
Wrap existing synchronous or asynchronous tools:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from flightrecorder import FlightRecorder
|
|
70
|
+
|
|
71
|
+
recorder = FlightRecorder(".flightrecorder.sqlite")
|
|
72
|
+
|
|
73
|
+
with recorder.run("Fix payment bug", metadata={"workspace": "."}) as run:
|
|
74
|
+
search = run.wrap_tool(search_code)
|
|
75
|
+
edit = run.wrap_tool(edit_file, watch_paths=["src"])
|
|
76
|
+
test = run.wrap_tool(run_tests, kind="test")
|
|
77
|
+
|
|
78
|
+
matches = search("payment validation")
|
|
79
|
+
edit("src/payment.py", replacement)
|
|
80
|
+
test("tests/test_payment.py")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Arguments, return values and exceptions are captured automatically. Watched text
|
|
84
|
+
files are snapshotted before and after a call. Exceptions are recorded and then
|
|
85
|
+
re-raised, so instrumentation does not change control flow.
|
|
86
|
+
|
|
87
|
+
For lower-level integrations, `run.step(...)` and `run.record(...)` are also
|
|
88
|
+
available. `ReplayTape` feeds recorded outputs back to deterministic tests without
|
|
89
|
+
repeating side effects.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
| Command | Purpose |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `init` | Initialize the SQLite database |
|
|
96
|
+
| `demo` | Record the synthetic payment failure |
|
|
97
|
+
| `demo-agent` | Execute a real file-edit and subprocess-test workflow |
|
|
98
|
+
| `runs` | List recent recordings |
|
|
99
|
+
| `show [run-id]` | Display a timeline |
|
|
100
|
+
| `investigate [run-id]` | Rank the first suspect change and show its evidence |
|
|
101
|
+
| `replay [run-id]` | Display the side-effect-free replay tape |
|
|
102
|
+
| `compare LEFT RIGHT` | Find the first meaningful divergence between runs |
|
|
103
|
+
| `export [run-id]` | Export native JSON or OTLP/JSON spans |
|
|
104
|
+
| `serve` | Start the local searchable timeline UI |
|
|
105
|
+
| `prune` | Preview or apply the retention policy |
|
|
106
|
+
|
|
107
|
+
All commands accept `--db PATH` before the command name. Commands accepting an
|
|
108
|
+
optional run ID use the latest run when it is omitted.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
flightrecorder compare run-yesterday run-today --json
|
|
112
|
+
flightrecorder export run-today --format otlp --output trace.json
|
|
113
|
+
flightrecorder prune --older-than-days 30 --keep-latest 100
|
|
114
|
+
flightrecorder prune --older-than-days 30 --keep-latest 100 --yes
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Retention is always a dry-run unless `--yes` is supplied.
|
|
118
|
+
|
|
119
|
+
## Investigation model
|
|
120
|
+
|
|
121
|
+
The investigator is deliberately explainable. It finds the first failed step,
|
|
122
|
+
ranks earlier file changes using explicit implicated paths, filenames mentioned in
|
|
123
|
+
the failure, removed-code vocabulary and temporal proximity, then returns its diff
|
|
124
|
+
and evidence. It reports a suspect—not unsupported certainty.
|
|
125
|
+
|
|
126
|
+
Exact model reproduction is not promised. Replay returns previously recorded tool
|
|
127
|
+
outputs and errors without calling live services; nondeterministic model behavior
|
|
128
|
+
and changed external systems remain outside that guarantee.
|
|
129
|
+
|
|
130
|
+
## Security and privacy
|
|
131
|
+
|
|
132
|
+
Common credential keys, inline passwords and bearer tokens are redacted before
|
|
133
|
+
SQLite persistence, including exception text and captured file content. Git and
|
|
134
|
+
file capture are bounded to one megabyte per value; binary files and common build
|
|
135
|
+
directories are skipped.
|
|
136
|
+
|
|
137
|
+
No heuristic filter is perfect. Test the redaction policy for your environment
|
|
138
|
+
before recording production or personal data. The web server binds to
|
|
139
|
+
`127.0.0.1` by default and has no authentication; do not expose it publicly.
|
|
140
|
+
|
|
141
|
+
See [SECURITY.md](SECURITY.md) for responsible disclosure and deployment notes.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python -m unittest discover -s tests -v
|
|
147
|
+
python -m flightrecorder demo-agent
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
CI runs the suite on Python 3.9, 3.12 and 3.13. Architecture and integration
|
|
151
|
+
boundaries are described in [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
152
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Agent Flight Recorder
|
|
2
|
+
|
|
3
|
+
A local black-box recorder for AI agents. It captures tool calls, results,
|
|
4
|
+
exceptions, file changes and Git context in SQLite, then connects failures to the
|
|
5
|
+
recorded evidence that most likely caused them.
|
|
6
|
+
|
|
7
|
+
The runtime has no third-party dependencies and supports Python 3.9+.
|
|
8
|
+
|
|
9
|
+
The PyPI distribution is named `flightrecorder-ai`; the Python import and command
|
|
10
|
+
remain `flightrecorder`.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python3 -m venv .venv
|
|
16
|
+
. .venv/bin/activate
|
|
17
|
+
python -m pip install flightrecorder-ai
|
|
18
|
+
|
|
19
|
+
# Synthetic example from the product brief
|
|
20
|
+
flightrecorder demo
|
|
21
|
+
flightrecorder investigate run-1042
|
|
22
|
+
|
|
23
|
+
# Real files, wrapped tools and a subprocess test
|
|
24
|
+
flightrecorder demo-agent
|
|
25
|
+
|
|
26
|
+
# Local searchable timeline
|
|
27
|
+
flightrecorder serve
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Open <http://127.0.0.1:8765> after starting the server. The project supports the
|
|
31
|
+
older pip bundled with some system Python installations; upgrading pip is optional.
|
|
32
|
+
|
|
33
|
+
## Instrument an agent
|
|
34
|
+
|
|
35
|
+
Wrap existing synchronous or asynchronous tools:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from flightrecorder import FlightRecorder
|
|
39
|
+
|
|
40
|
+
recorder = FlightRecorder(".flightrecorder.sqlite")
|
|
41
|
+
|
|
42
|
+
with recorder.run("Fix payment bug", metadata={"workspace": "."}) as run:
|
|
43
|
+
search = run.wrap_tool(search_code)
|
|
44
|
+
edit = run.wrap_tool(edit_file, watch_paths=["src"])
|
|
45
|
+
test = run.wrap_tool(run_tests, kind="test")
|
|
46
|
+
|
|
47
|
+
matches = search("payment validation")
|
|
48
|
+
edit("src/payment.py", replacement)
|
|
49
|
+
test("tests/test_payment.py")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Arguments, return values and exceptions are captured automatically. Watched text
|
|
53
|
+
files are snapshotted before and after a call. Exceptions are recorded and then
|
|
54
|
+
re-raised, so instrumentation does not change control flow.
|
|
55
|
+
|
|
56
|
+
For lower-level integrations, `run.step(...)` and `run.record(...)` are also
|
|
57
|
+
available. `ReplayTape` feeds recorded outputs back to deterministic tests without
|
|
58
|
+
repeating side effects.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Purpose |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `init` | Initialize the SQLite database |
|
|
65
|
+
| `demo` | Record the synthetic payment failure |
|
|
66
|
+
| `demo-agent` | Execute a real file-edit and subprocess-test workflow |
|
|
67
|
+
| `runs` | List recent recordings |
|
|
68
|
+
| `show [run-id]` | Display a timeline |
|
|
69
|
+
| `investigate [run-id]` | Rank the first suspect change and show its evidence |
|
|
70
|
+
| `replay [run-id]` | Display the side-effect-free replay tape |
|
|
71
|
+
| `compare LEFT RIGHT` | Find the first meaningful divergence between runs |
|
|
72
|
+
| `export [run-id]` | Export native JSON or OTLP/JSON spans |
|
|
73
|
+
| `serve` | Start the local searchable timeline UI |
|
|
74
|
+
| `prune` | Preview or apply the retention policy |
|
|
75
|
+
|
|
76
|
+
All commands accept `--db PATH` before the command name. Commands accepting an
|
|
77
|
+
optional run ID use the latest run when it is omitted.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
flightrecorder compare run-yesterday run-today --json
|
|
81
|
+
flightrecorder export run-today --format otlp --output trace.json
|
|
82
|
+
flightrecorder prune --older-than-days 30 --keep-latest 100
|
|
83
|
+
flightrecorder prune --older-than-days 30 --keep-latest 100 --yes
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Retention is always a dry-run unless `--yes` is supplied.
|
|
87
|
+
|
|
88
|
+
## Investigation model
|
|
89
|
+
|
|
90
|
+
The investigator is deliberately explainable. It finds the first failed step,
|
|
91
|
+
ranks earlier file changes using explicit implicated paths, filenames mentioned in
|
|
92
|
+
the failure, removed-code vocabulary and temporal proximity, then returns its diff
|
|
93
|
+
and evidence. It reports a suspect—not unsupported certainty.
|
|
94
|
+
|
|
95
|
+
Exact model reproduction is not promised. Replay returns previously recorded tool
|
|
96
|
+
outputs and errors without calling live services; nondeterministic model behavior
|
|
97
|
+
and changed external systems remain outside that guarantee.
|
|
98
|
+
|
|
99
|
+
## Security and privacy
|
|
100
|
+
|
|
101
|
+
Common credential keys, inline passwords and bearer tokens are redacted before
|
|
102
|
+
SQLite persistence, including exception text and captured file content. Git and
|
|
103
|
+
file capture are bounded to one megabyte per value; binary files and common build
|
|
104
|
+
directories are skipped.
|
|
105
|
+
|
|
106
|
+
No heuristic filter is perfect. Test the redaction policy for your environment
|
|
107
|
+
before recording production or personal data. The web server binds to
|
|
108
|
+
`127.0.0.1` by default and has no authentication; do not expose it publicly.
|
|
109
|
+
|
|
110
|
+
See [SECURITY.md](SECURITY.md) for responsible disclosure and deployment notes.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
python -m unittest discover -s tests -v
|
|
116
|
+
python -m flightrecorder demo-agent
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
CI runs the suite on Python 3.9, 3.12 and 3.13. Architecture and integration
|
|
120
|
+
boundaries are described in [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
121
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Compatibility installer for older pip versions without PEP 660 support."""
|
|
2
|
+
|
|
3
|
+
from setuptools import find_packages, setup
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
setup(
|
|
7
|
+
# The shorter agent-flight-recorder distribution name is owned by an
|
|
8
|
+
# unrelated project on PyPI. The import and CLI names remain flightrecorder.
|
|
9
|
+
name="flightrecorder-ai",
|
|
10
|
+
version="1.0.0",
|
|
11
|
+
description="A local black-box recorder and failure investigator for AI agents",
|
|
12
|
+
long_description=open("README.md", encoding="utf-8").read(),
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
author="Mohamed Ansar",
|
|
15
|
+
license="Apache-2.0",
|
|
16
|
+
url="https://github.com/kmsmohamedansar/agent-flight-recorder",
|
|
17
|
+
project_urls={
|
|
18
|
+
"Source": "https://github.com/kmsmohamedansar/agent-flight-recorder",
|
|
19
|
+
"Issues": "https://github.com/kmsmohamedansar/agent-flight-recorder/issues",
|
|
20
|
+
},
|
|
21
|
+
python_requires=">=3.9",
|
|
22
|
+
classifiers=[
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Software Development :: Debuggers",
|
|
31
|
+
],
|
|
32
|
+
package_dir={"": "src"},
|
|
33
|
+
packages=find_packages(where="src"),
|
|
34
|
+
entry_points={"console_scripts": ["flightrecorder=flightrecorder.cli:main"]},
|
|
35
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Agent Flight Recorder public API."""
|
|
2
|
+
|
|
3
|
+
from .recorder import FlightRecorder, RunSession, StepCapture
|
|
4
|
+
from .replay import RecordedToolError, ReplayMismatch, ReplayTape
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FlightRecorder",
|
|
8
|
+
"RecordedToolError",
|
|
9
|
+
"ReplayMismatch",
|
|
10
|
+
"ReplayTape",
|
|
11
|
+
"RunSession",
|
|
12
|
+
"StepCapture",
|
|
13
|
+
]
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from .compare import compare_runs
|
|
14
|
+
from .demo_agent import run_demo as run_agent_demo
|
|
15
|
+
from .export import to_json, to_otlp
|
|
16
|
+
from .investigate import investigate
|
|
17
|
+
from .recorder import FlightRecorder
|
|
18
|
+
from .store import SQLiteStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_DB = ".flightrecorder.sqlite"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load(store: SQLiteStore, run_id: Optional[str]) -> Dict[str, Any]:
|
|
25
|
+
run = store.get_run(run_id)
|
|
26
|
+
if run is None:
|
|
27
|
+
target = run_id or "latest run"
|
|
28
|
+
raise SystemExit("No recording found for %s." % target)
|
|
29
|
+
return run
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _demo(recorder: FlightRecorder) -> str:
|
|
33
|
+
before = """def charge(order):
|
|
34
|
+
if order.already_paid:
|
|
35
|
+
raise DuplicatePayment(order.id)
|
|
36
|
+
return gateway.charge(order)
|
|
37
|
+
"""
|
|
38
|
+
after = """def charge(order):
|
|
39
|
+
return gateway.charge(order)
|
|
40
|
+
"""
|
|
41
|
+
preferred_id = "run-1042"
|
|
42
|
+
run_id = (
|
|
43
|
+
preferred_id
|
|
44
|
+
if recorder.store.get_run(preferred_id) is None
|
|
45
|
+
else "run-demo-" + uuid.uuid4().hex[:8]
|
|
46
|
+
)
|
|
47
|
+
with recorder.run("Payment bug fix", run_id=run_id) as run:
|
|
48
|
+
run.record(
|
|
49
|
+
"Read payment_service.py",
|
|
50
|
+
kind="file_read",
|
|
51
|
+
input={"path": "payment_service.py"},
|
|
52
|
+
output={"bytes": len(before)},
|
|
53
|
+
)
|
|
54
|
+
with run.step(
|
|
55
|
+
"Modified payment_service.py",
|
|
56
|
+
kind="file_write",
|
|
57
|
+
input={"path": "payment_service.py"},
|
|
58
|
+
) as step:
|
|
59
|
+
step.add_change("payment_service.py", before, after)
|
|
60
|
+
step.set_output({"written": True})
|
|
61
|
+
run.record(
|
|
62
|
+
"Updated database query",
|
|
63
|
+
kind="database",
|
|
64
|
+
input={"operation": "share transaction"},
|
|
65
|
+
output={"rows": 2},
|
|
66
|
+
)
|
|
67
|
+
run.record(
|
|
68
|
+
"Ran unit tests",
|
|
69
|
+
kind="test",
|
|
70
|
+
input={"suite": "unit"},
|
|
71
|
+
output={"passed": 42, "failed": 0},
|
|
72
|
+
)
|
|
73
|
+
run.record(
|
|
74
|
+
"Ran duplicate-payment regression test",
|
|
75
|
+
kind="test",
|
|
76
|
+
input={"test": "test_duplicate_payment_rejected"},
|
|
77
|
+
output={"passed": 0, "failed": 1},
|
|
78
|
+
status="failed",
|
|
79
|
+
error="AssertionError: duplicate payment was accepted",
|
|
80
|
+
metadata={"implicated_paths": ["payment_service.py"]},
|
|
81
|
+
)
|
|
82
|
+
run.finish("failed")
|
|
83
|
+
return run.id
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_timeline(run: Dict[str, Any]) -> None:
|
|
87
|
+
print("%s %s [%s]" % (run["id"], run["name"], run["status"]))
|
|
88
|
+
for step in run["steps"]:
|
|
89
|
+
marker = "x" if step["status"] == "failed" else "+"
|
|
90
|
+
print(" %s #%d %-12s %s" % (marker, step["sequence"], step["kind"], step["name"]))
|
|
91
|
+
if step["error"]:
|
|
92
|
+
print(" %s" % step["error"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_investigation(run: Dict[str, Any]) -> None:
|
|
96
|
+
result = investigate(run)
|
|
97
|
+
print("Run: %s — %s" % (run["id"], run["name"]))
|
|
98
|
+
if result["failure"] is None:
|
|
99
|
+
print("Failure: none")
|
|
100
|
+
return
|
|
101
|
+
failure = result["failure"]
|
|
102
|
+
print("Failure: %s" % (failure["error"] or failure["name"]))
|
|
103
|
+
suspect = result.get("suspect")
|
|
104
|
+
if suspect is None:
|
|
105
|
+
print("First suspect: unavailable")
|
|
106
|
+
else:
|
|
107
|
+
print("First suspect: #%d %s" % (suspect["sequence"], suspect["name"]))
|
|
108
|
+
print("Changed: %s" % result["change"]["path"])
|
|
109
|
+
print("Evidence:")
|
|
110
|
+
for line in result["evidence"]:
|
|
111
|
+
print(" - %s" % line)
|
|
112
|
+
if result["diff"]:
|
|
113
|
+
print("Relevant diff:")
|
|
114
|
+
for line in result["diff"]:
|
|
115
|
+
print(" %s" % line)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
119
|
+
parser = argparse.ArgumentParser(prog="flightrecorder")
|
|
120
|
+
parser.add_argument("--db", default=DEFAULT_DB, help="SQLite recording path")
|
|
121
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
122
|
+
commands.add_parser("init", help="initialize the recording database")
|
|
123
|
+
commands.add_parser("demo", help="record the built-in failing-agent demo")
|
|
124
|
+
agent_demo = commands.add_parser(
|
|
125
|
+
"demo-agent", help="run a real file-edit and subprocess-test workflow"
|
|
126
|
+
)
|
|
127
|
+
agent_demo.add_argument("--workspace", default=".flightrecorder-demo")
|
|
128
|
+
runs = commands.add_parser("runs", help="list recent runs")
|
|
129
|
+
runs.add_argument("--limit", type=int, default=20)
|
|
130
|
+
show = commands.add_parser("show", help="show a run timeline")
|
|
131
|
+
show.add_argument("run_id", nargs="?")
|
|
132
|
+
inv = commands.add_parser("investigate", help="find the first suspect change")
|
|
133
|
+
inv.add_argument("run_id", nargs="?")
|
|
134
|
+
replay = commands.add_parser("replay", help="replay recorded outputs without side effects")
|
|
135
|
+
replay.add_argument("run_id", nargs="?")
|
|
136
|
+
replay.add_argument("--json", action="store_true")
|
|
137
|
+
compare = commands.add_parser("compare", help="compare two recorded runs")
|
|
138
|
+
compare.add_argument("left_run_id")
|
|
139
|
+
compare.add_argument("right_run_id")
|
|
140
|
+
compare.add_argument("--json", action="store_true")
|
|
141
|
+
export = commands.add_parser("export", help="export a run as JSON or OTLP/JSON")
|
|
142
|
+
export.add_argument("run_id", nargs="?")
|
|
143
|
+
export.add_argument("--format", choices=("json", "otlp"), default="json")
|
|
144
|
+
export.add_argument("--output", default="-", help="output file, or - for stdout")
|
|
145
|
+
serve = commands.add_parser("serve", help="start the local timeline UI")
|
|
146
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
147
|
+
serve.add_argument("--port", type=int, default=8765)
|
|
148
|
+
prune = commands.add_parser("prune", help="remove recordings outside retention limits")
|
|
149
|
+
prune.add_argument("--older-than-days", type=int, default=30)
|
|
150
|
+
prune.add_argument("--keep-latest", type=int, default=100)
|
|
151
|
+
prune.add_argument(
|
|
152
|
+
"--yes", action="store_true", help="perform deletion; default is dry-run"
|
|
153
|
+
)
|
|
154
|
+
return parser
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
158
|
+
args = build_parser().parse_args(argv)
|
|
159
|
+
store = SQLiteStore(args.db)
|
|
160
|
+
if args.command == "init":
|
|
161
|
+
print("Initialized %s" % Path(args.db).resolve())
|
|
162
|
+
elif args.command == "demo":
|
|
163
|
+
run_id = _demo(FlightRecorder(args.db))
|
|
164
|
+
print("Recorded demo run %s" % run_id)
|
|
165
|
+
print("Next: flightrecorder --db %s investigate %s" % (args.db, run_id))
|
|
166
|
+
elif args.command == "demo-agent":
|
|
167
|
+
workspace = Path(args.workspace).resolve()
|
|
168
|
+
run_id = run_agent_demo(FlightRecorder(args.db), workspace)
|
|
169
|
+
print("Recorded real workflow %s" % run_id)
|
|
170
|
+
print("Workspace: %s" % workspace)
|
|
171
|
+
_print_investigation(_load(store, run_id))
|
|
172
|
+
elif args.command == "runs":
|
|
173
|
+
for run in store.list_runs(args.limit):
|
|
174
|
+
print("%-14s %-8s %s" % (run["id"], run["status"], run["name"]))
|
|
175
|
+
elif args.command == "show":
|
|
176
|
+
_print_timeline(_load(store, args.run_id))
|
|
177
|
+
elif args.command == "investigate":
|
|
178
|
+
_print_investigation(_load(store, args.run_id))
|
|
179
|
+
elif args.command == "replay":
|
|
180
|
+
run = _load(store, args.run_id)
|
|
181
|
+
tape = [
|
|
182
|
+
{
|
|
183
|
+
"sequence": step["sequence"],
|
|
184
|
+
"kind": step["kind"],
|
|
185
|
+
"name": step["name"],
|
|
186
|
+
"input": step["input"],
|
|
187
|
+
"recorded_output": step["output"],
|
|
188
|
+
"status": step["status"],
|
|
189
|
+
"error": step["error"],
|
|
190
|
+
}
|
|
191
|
+
for step in run["steps"]
|
|
192
|
+
]
|
|
193
|
+
if args.json:
|
|
194
|
+
print(json.dumps(tape, indent=2))
|
|
195
|
+
else:
|
|
196
|
+
print("Controlled replay of %s (side effects disabled)" % run["id"])
|
|
197
|
+
for item in tape:
|
|
198
|
+
print(" #%d %s -> %s" % (item["sequence"], item["name"], item["status"]))
|
|
199
|
+
elif args.command == "compare":
|
|
200
|
+
result = compare_runs(
|
|
201
|
+
_load(store, args.left_run_id), _load(store, args.right_run_id)
|
|
202
|
+
)
|
|
203
|
+
if args.json:
|
|
204
|
+
print(json.dumps(result, indent=2))
|
|
205
|
+
elif result["first_divergence"] is None:
|
|
206
|
+
print("No meaningful divergence found.")
|
|
207
|
+
else:
|
|
208
|
+
first = result["first_divergence"]
|
|
209
|
+
print("First divergence: position #%d" % first["position"])
|
|
210
|
+
print("Reasons: %s" % ", ".join(first["reasons"]))
|
|
211
|
+
elif args.command == "export":
|
|
212
|
+
run = _load(store, args.run_id)
|
|
213
|
+
payload = to_otlp(run) if args.format == "otlp" else to_json(run)
|
|
214
|
+
rendered = json.dumps(payload, indent=2) + "\n"
|
|
215
|
+
if args.output == "-":
|
|
216
|
+
print(rendered, end="")
|
|
217
|
+
else:
|
|
218
|
+
Path(args.output).write_text(rendered, encoding="utf-8")
|
|
219
|
+
print("Exported %s" % Path(args.output).resolve())
|
|
220
|
+
elif args.command == "serve":
|
|
221
|
+
from .web import serve
|
|
222
|
+
|
|
223
|
+
serve(store, args.host, args.port)
|
|
224
|
+
elif args.command == "prune":
|
|
225
|
+
cutoff = datetime.now(timezone.utc) - timedelta(
|
|
226
|
+
days=max(args.older_than_days, 0)
|
|
227
|
+
)
|
|
228
|
+
run_ids = store.prune_runs(
|
|
229
|
+
cutoff.isoformat(),
|
|
230
|
+
keep_latest=max(args.keep_latest, 0),
|
|
231
|
+
dry_run=not args.yes,
|
|
232
|
+
)
|
|
233
|
+
action = "Deleted" if args.yes else "Would delete"
|
|
234
|
+
print("%s %d run(s)." % (action, len(run_ids)))
|
|
235
|
+
for run_id in run_ids:
|
|
236
|
+
print(" %s" % run_id)
|
|
237
|
+
if run_ids and not args.yes:
|
|
238
|
+
print("Re-run with --yes to apply.")
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
sys.exit(main())
|
|
244
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Run-to-run comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _signature(step: Dict[str, Any]) -> Tuple[str, str]:
|
|
9
|
+
return step["kind"], step["name"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compare_runs(left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]:
|
|
13
|
+
"""Compare two runs and identify their first meaningful divergence."""
|
|
14
|
+
differences: List[Dict[str, Any]] = []
|
|
15
|
+
maximum = max(len(left["steps"]), len(right["steps"]))
|
|
16
|
+
for index in range(maximum):
|
|
17
|
+
left_step = left["steps"][index] if index < len(left["steps"]) else None
|
|
18
|
+
right_step = right["steps"][index] if index < len(right["steps"]) else None
|
|
19
|
+
reasons: List[str] = []
|
|
20
|
+
if left_step is None or right_step is None:
|
|
21
|
+
reasons.append("step_missing")
|
|
22
|
+
else:
|
|
23
|
+
if _signature(left_step) != _signature(right_step):
|
|
24
|
+
reasons.append("operation_changed")
|
|
25
|
+
if left_step["status"] != right_step["status"]:
|
|
26
|
+
reasons.append("status_changed")
|
|
27
|
+
if left_step["input"] != right_step["input"]:
|
|
28
|
+
reasons.append("input_changed")
|
|
29
|
+
if left_step["output"] != right_step["output"]:
|
|
30
|
+
reasons.append("output_changed")
|
|
31
|
+
if left_step["metadata"].get("changes") != right_step["metadata"].get("changes"):
|
|
32
|
+
reasons.append("file_changes_changed")
|
|
33
|
+
if reasons:
|
|
34
|
+
differences.append(
|
|
35
|
+
{
|
|
36
|
+
"position": index + 1,
|
|
37
|
+
"reasons": reasons,
|
|
38
|
+
"left": left_step,
|
|
39
|
+
"right": right_step,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
"left_run_id": left["id"],
|
|
44
|
+
"right_run_id": right["id"],
|
|
45
|
+
"status_changed": left["status"] != right["status"],
|
|
46
|
+
"first_divergence": differences[0] if differences else None,
|
|
47
|
+
"differences": differences,
|
|
48
|
+
}
|
|
49
|
+
|