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.
Files changed (26) hide show
  1. flightrecorder_ai-1.0.0/LICENSE +18 -0
  2. flightrecorder_ai-1.0.0/PKG-INFO +152 -0
  3. flightrecorder_ai-1.0.0/README.md +121 -0
  4. flightrecorder_ai-1.0.0/setup.cfg +4 -0
  5. flightrecorder_ai-1.0.0/setup.py +35 -0
  6. flightrecorder_ai-1.0.0/src/flightrecorder/__init__.py +15 -0
  7. flightrecorder_ai-1.0.0/src/flightrecorder/__main__.py +6 -0
  8. flightrecorder_ai-1.0.0/src/flightrecorder/cli.py +244 -0
  9. flightrecorder_ai-1.0.0/src/flightrecorder/compare.py +49 -0
  10. flightrecorder_ai-1.0.0/src/flightrecorder/demo_agent.py +90 -0
  11. flightrecorder_ai-1.0.0/src/flightrecorder/export.py +72 -0
  12. flightrecorder_ai-1.0.0/src/flightrecorder/files.py +53 -0
  13. flightrecorder_ai-1.0.0/src/flightrecorder/git.py +38 -0
  14. flightrecorder_ai-1.0.0/src/flightrecorder/investigate.py +78 -0
  15. flightrecorder_ai-1.0.0/src/flightrecorder/recorder.py +233 -0
  16. flightrecorder_ai-1.0.0/src/flightrecorder/redact.py +59 -0
  17. flightrecorder_ai-1.0.0/src/flightrecorder/replay.py +38 -0
  18. flightrecorder_ai-1.0.0/src/flightrecorder/store.py +182 -0
  19. flightrecorder_ai-1.0.0/src/flightrecorder/web.py +91 -0
  20. flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/PKG-INFO +152 -0
  21. flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/SOURCES.txt +24 -0
  22. flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/dependency_links.txt +1 -0
  23. flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/entry_points.txt +2 -0
  24. flightrecorder_ai-1.0.0/src/flightrecorder_ai.egg-info/top_level.txt +1 -0
  25. flightrecorder_ai-1.0.0/tests/test_advanced.py +109 -0
  26. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -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
+