diff --git a/.gitignore b/.gitignore index c246a05..635f818 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ server/ *.sqlite *.sqlite-wal *.sqlite-shm +!.dprovenance/baseline.sqlite +!.dprovenance/baselines/*.sqlite # Generated by examples/end_to_end_demo.py examples/demo-report.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1620b84..30a52c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ public API may still change between minor versions. ## [Unreleased] +### Added + +- **A zero-configuration local regression workflow.** The new `dpk` executable is a short alias + for `dprovenancekit`; `dpk record` atomically pins the newest known-good run to + `.dprovenance/baseline.sqlite`, `dpk compare` prints the latest candidate diff without failing + on drift, and `dpk gate` applies the same comparison with a CI-safe exit code. Calling + `traced_run(context_id="...")` without an explicit store now owns and deterministically closes + a SQLite store at `DPROV_DB` or `.dprovenance/traces.sqlite`, while the existing explicit-store + and run-id/context CLI interfaces remain compatible. + ### Security - **The standalone HTML visualizer now escapes trace data.** `render_trace_html` diff --git a/README.md b/README.md index a649771..e95cdab 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,46 @@ dprovenancekit demo The demo writes `demo-traces.sqlite`, `demo-rules.json`, and `demo-report.html` to the current directory (or the directory you select) and prints the exact commands to gate and inspect them. +### The shortest real-project workflow + +Instrument the steps whose presence and order matter, then let `traced_run` use the local default +store: + +```python +from dprovenancekit import traced, traced_run + +@traced +def retrieve(): ... + +@traced +def verify(): ... + +with traced_run(context_id="research-agent"): + retrieve() + verify() +``` + +Run the known-good version once and pin it: + +```bash +python agent.py +dpk record +``` + +After a code change, record another run and either inspect or enforce the result: + +```bash +python agent.py +dpk compare # prints the diff, but does not fail merely because one exists +dpk gate # same comparison; exits 1 when the candidate regresses +``` + +No run IDs or database flags are needed. The default candidate store is +`.dprovenance/traces.sqlite`; `dpk record` atomically pins its newest run to the committable +`.dprovenance/baseline.sqlite`. Use `--context` when one store contains several agents, or +`DPROV_DB` / `--db` / `--baseline` to override the paths. `dprovenancekit` remains the long-form +executable for every `dpk` command. + From a checkout (development): ```bash @@ -175,7 +215,7 @@ external benchmark or third-party evaluation. | Framework-agnostic instrumentation (decorators) | `instrument` | | Framework adapters | `integrations.langchain`, `integrations.openai_agents`, `integrations.llama_index`, `integrations.crewai`, `integrations.google_genai`, `integrations.fastapi`, `integrations.jupyter`, `integrations.mcp` | | Shareable HTML regression report | `report` | -| Headless CLI — `demo`, `gate`, `anomalies`, `runs`, `ui`, `evaluate` | `cli` | +| Headless CLI — `record`, `compare`, `gate`, `demo`, `anomalies`, `runs`, `ui`, `evaluate` | `cli` | The SwiftUI `DProvenanceUI` target is intentionally **not** ported (it is Apple-platform UI); its pure value-model layer (`SpanViewModel`, flattening) is ported in `viewmodel`. @@ -504,6 +544,7 @@ def search(query): ... @traced def answer(question, sources): ... +# Omit `store` to persist automatically to .dprovenance/traces.sqlite. store = InMemoryTraceStore() with traced_run(store, context_id="ticket-42"): sources = search(question) @@ -521,6 +562,10 @@ unchanged. Outside a `traced_run` the decorators are transparent, so instrumente call untraced. The trace it produces is identical in shape to the adapter-produced ones, so fingerprint / diff / align / the regression gate all apply. +For scripts and first use, `with traced_run(context_id="ticket-42"):` creates and closes a SQLite +store at `DPROV_DB` or `.dprovenance/traces.sqlite`. Pass an explicit store, as above, when the +application owns storage or needs a different backend. + --- ## Tests diff --git a/dprovenancekit/cli.py b/dprovenancekit/cli.py index 6c76541..ec177d1 100644 --- a/dprovenancekit/cli.py +++ b/dprovenancekit/cli.py @@ -2,7 +2,7 @@ Mirrors the Swift ``DProvenanceKitCLI``. Usage:: - dprovenancekit + dpk """ from __future__ import annotations @@ -20,6 +20,9 @@ from .benchmark import BenchmarkRunner, DeterministicBoundary from .corpus import DProvenanceCorpus +_DEFAULT_TRACE_DB = ".dprovenance/traces.sqlite" +_DEFAULT_BASELINE_DB = ".dprovenance/baseline.sqlite" + def _make_engine(callback) -> TraceAlignmentEngine: config = AlignmentConfiguration( @@ -46,41 +49,202 @@ def _print_case_line(c) -> None: ) -def _run_gate(argv) -> int: - """``dprovenancekit gate`` — fail when a candidate run regresses against a golden run. +def _select_run_id(store, run_id_text, context_id, role, db_path): + """Resolve an explicit id, newest context match, or newest run in a store.""" + import uuid + + if run_id_text: + try: + return uuid.UUID(run_id_text) + except ValueError: + print(f"error: --{role} must be a valid run id (UUID)", file=sys.stderr) + return None + + for row in store.list_run_metadata(): + if context_id is not None and row.context_id != context_id: + continue + try: + return uuid.UUID(row.run_id) + except ValueError: + continue # malformed row (foreign/corrupted db) — skip it + + if context_id is None: + print(f"error: no runs found in {db_path} ({role})", file=sys.stderr) + else: + print( + f"error: no run with context id '{context_id}' in {db_path} ({role})", + file=sys.stderr, + ) + return None + + +def _run_record(argv) -> int: + """``dpk record`` — pin the newest recorded run as the local golden baseline.""" + import argparse + import os + import sqlite3 + import tempfile + from pathlib import Path + + from .event import AnyTraceableEvent + from .sqlite_store import SQLiteTraceStore + + ap = argparse.ArgumentParser( + prog="dpk record", + description="Pin the newest recorded agent run as a local golden baseline.", + ) + ap.add_argument( + "--db", + default=os.environ.get("DPROV_DB") or _DEFAULT_TRACE_DB, + help=f"recorded-run database (default: {_DEFAULT_TRACE_DB})", + ) + ap.add_argument( + "--baseline", + default=_DEFAULT_BASELINE_DB, + help=f"baseline file to create or replace (default: {_DEFAULT_BASELINE_DB})", + ) + selector = ap.add_mutually_exclusive_group() + selector.add_argument("--run", help="specific known-good run id to pin") + selector.add_argument( + "--context", help="pin the newest run with this context id" + ) + args = ap.parse_args(argv) + + source_path = Path(args.db) + baseline_path = Path(args.baseline) + if not source_path.exists(): + print( + f"error: no recorded runs found at {source_path}. " + "Run code inside traced_run(...) first.", + file=sys.stderr, + ) + return 2 + if source_path.resolve() == baseline_path.resolve(): + print("error: --baseline must be different from --db", file=sys.stderr) + return 2 + + try: + store = SQLiteTraceStore(AnyTraceableEvent, str(source_path), start_writer=False) + except (sqlite3.Error, OSError) as exc: + print(f"error: could not open database {source_path}: {exc}", file=sys.stderr) + return 2 + try: + run_id = _select_run_id( + store, args.run, args.context, "run", str(source_path) + ) + run = store.get_run(run_id) if run_id is not None else None + finally: + store.close() + if run_id is None: + return 2 + if run is None: + print(f"error: run not found in {source_path}: {run_id}", file=sys.stderr) + return 2 + + baseline_path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{baseline_path.name}.", suffix=".tmp", dir=str(baseline_path.parent) + ) + os.close(fd) + temporary_path = Path(temporary_name) + + source_conn = None + baseline_conn = None + try: + source_uri = source_path.resolve().as_uri() + "?mode=ro" + source_conn = sqlite3.connect(source_uri, uri=True) + baseline_conn = sqlite3.connect(str(temporary_path)) + source_conn.backup(baseline_conn) + # Keep the atomically-replaced baseline self-contained in its main file. A later + # SQLiteTraceStore open switches it back to WAL mode as usual. + baseline_conn.execute("PRAGMA journal_mode=DELETE;") + with baseline_conn: + baseline_conn.execute( + "DELETE FROM trace_events WHERE run_id != ? OR run_id IS NULL", + (str(run_id),), + ) + baseline_conn.execute( + "DELETE FROM runs WHERE run_id != ? OR run_id IS NULL", + (str(run_id),), + ) + baseline_conn.execute( + "DELETE FROM trace_edges " + "WHERE source_id NOT IN (SELECT id FROM trace_events) " + "OR target_id NOT IN (SELECT id FROM trace_events)" + ) + baseline_conn.execute("VACUUM;") + baseline_conn.close() + baseline_conn = None + source_conn.close() + source_conn = None + + for suffix in ("-wal", "-shm"): + stale = Path(str(baseline_path) + suffix) + if stale.exists(): + stale.unlink() + os.replace(str(temporary_path), str(baseline_path)) + except (sqlite3.Error, OSError) as exc: + print(f"error: could not write baseline {baseline_path}: {exc}", file=sys.stderr) + return 2 + finally: + if baseline_conn is not None: + baseline_conn.close() + if source_conn is not None: + source_conn.close() + if temporary_path.exists(): + temporary_path.unlink() + + print( + f"Recorded baseline: {baseline_path}\n" + f" context={run.context_id} run={run.run_id} events={len(run.events)}\n" + "Run the agent again, then inspect with 'dpk compare' or enforce with 'dpk gate'." + ) + return 0 + - Server-less: loads the golden and candidate runs from local WAL SQLite database(s) — one - shared ``--db``, or separate ``--golden-db`` / ``--candidate-db`` (e.g. a restored baseline - vs. this PR's run) — and runs the library's own :class:`RegressionGate`. Exit codes mirror - ``server/dprov_gate.py``:: +def _run_gate(argv, fail_on_regression=True) -> int: + """Compare a candidate run with a golden run; optionally fail on regression. - 0 no regression (gate passed) - 1 regression detected - 2 usage / run-not-found error + The explicit database/run-id interface remains available for CI. With no arguments, + the quick workflow reads ``.dprovenance/baseline.sqlite`` and the newest run in + ``.dprovenance/traces.sqlite``. """ import argparse import json + import os import sqlite3 - import uuid from .alignment_models import RegressionLevel from .event import AnyTraceableEvent from .sqlite_store import SQLiteTraceStore from .testing import RegressionGate + command = "gate" if fail_on_regression else "compare" + description = ( + "Fail the build when a candidate run regresses against a golden run." + if fail_on_regression + else "Compare the latest agent run against the local golden baseline." + ) ap = argparse.ArgumentParser( - prog="dprovenancekit gate", - description="Fail the build when a candidate run regresses against a golden run.", + prog=f"dprovenancekit {command}", description=description ) ap.add_argument( "--db", help="SQLite db holding both runs (shorthand for --golden-db/--candidate-db)", ) ap.add_argument( - "--golden-db", help="SQLite db holding the golden run (default: --db)" + "--golden-db", + "--baseline", + dest="golden_db", + help=f"SQLite db holding the golden run (quick default: {_DEFAULT_BASELINE_DB})", ) ap.add_argument( - "--candidate-db", help="SQLite db holding the candidate run (default: --db)" + "--candidate-db", + help=f"SQLite db holding the candidate run (quick default: {_DEFAULT_TRACE_DB})", + ) + ap.add_argument( + "--context", + help="quick-workflow context id to select from both baseline and candidate files", ) ap.add_argument("--golden", help="golden (known-good) run id") ap.add_argument( @@ -121,6 +285,14 @@ def _run_gate(argv) -> int: ap.add_argument("--json", action="store_true", help="emit the report as JSON") args = ap.parse_args(argv) + if args.context and (args.golden_context or args.candidate_context): + print( + "error: --context cannot be combined with --golden-context or " + "--candidate-context", + file=sys.stderr, + ) + return 2 + if args.max_level in ("low", "medium"): print( f"warning: --max-level {args.max_level} currently behaves like 'none': " @@ -128,53 +300,75 @@ def _run_gate(argv) -> int: file=sys.stderr, ) - if bool(args.golden) == bool(args.golden_context): + if args.golden and args.golden_context: print( "error: provide exactly one of --golden or --golden-context", file=sys.stderr, ) return 2 - if bool(args.candidate) == bool(args.candidate_context): + if args.candidate and args.candidate_context: print( "error: provide exactly one of --candidate or --candidate-context", file=sys.stderr, ) return 2 - try: - golden_id = uuid.UUID(args.golden) if args.golden else None - candidate_id = uuid.UUID(args.candidate) if args.candidate else None - except ValueError: + legacy_selector = any( + ( + args.golden, + args.golden_context, + args.candidate, + args.candidate_context, + ) + ) + if legacy_selector and not any((args.db, args.golden_db, args.candidate_db)): print( - "error: --golden/--candidate must be valid run ids (UUIDs)", file=sys.stderr + "error: provide --db (or both --golden-db and --candidate-db)", + file=sys.stderr, ) return 2 - golden_db = args.golden_db or args.db - candidate_db = args.candidate_db or args.db - if not golden_db or not candidate_db: + golden_db = args.golden_db or args.db or _DEFAULT_BASELINE_DB + candidate_db = args.candidate_db or args.db or ( + os.environ.get("DPROV_DB") or _DEFAULT_TRACE_DB + ) + same_database = os.path.abspath(golden_db) == os.path.abspath(candidate_db) + golden_context = args.golden_context or args.context + candidate_context = args.candidate_context or args.context + + if same_database and not (args.golden or golden_context): print( - "error: provide --db (or both --golden-db and --candidate-db)", + "error: provide exactly one of --golden or --golden-context", file=sys.stderr, ) return 2 - - def _resolve_context(store, context_id, role, db_path): - # list_run_metadata is newest-first, so the first match is the latest run. - for row in store.list_run_metadata(): - if row.context_id != context_id: - continue - try: - return uuid.UUID(row.run_id) - except ValueError: - continue # malformed row (foreign/corrupted db) — skip it + if same_database and not (args.candidate or candidate_context): print( - f"error: no run with context id '{context_id}' in {db_path} ({role})", + "error: provide exactly one of --candidate or --candidate-context", file=sys.stderr, ) - return None + return 2 + if same_database and args.context: + print( + "error: --context requires separate baseline and candidate databases; " + "use --golden-context/--candidate-context for one shared --db", + file=sys.stderr, + ) + return 2 + + for path in {golden_db, candidate_db}: + if not os.path.exists(path): + hint = ( + " Run 'dpk record' after recording a known-good run first." + if path == golden_db + else " Run code inside traced_run(...) first." + ) + print(f"error: no such database: {path}.{hint}", file=sys.stderr) + return 2 opened = {} + golden = None + candidate = None try: for path in {golden_db, candidate_db}: try: @@ -184,14 +378,16 @@ def _resolve_context(store, context_id, role, db_path): except (sqlite3.Error, OSError) as exc: print(f"error: could not open database {path}: {exc}", file=sys.stderr) return 2 - if golden_id is None: - golden_id = _resolve_context( - opened[golden_db], args.golden_context, "golden", golden_db - ) - if candidate_id is None: - candidate_id = _resolve_context( - opened[candidate_db], args.candidate_context, "candidate", candidate_db - ) + golden_id = _select_run_id( + opened[golden_db], args.golden, golden_context, "golden", golden_db + ) + candidate_id = _select_run_id( + opened[candidate_db], + args.candidate, + candidate_context, + "candidate", + candidate_db, + ) if golden_id is None or candidate_id is None: return 2 golden = opened[golden_db].get_run(golden_id) @@ -240,6 +436,8 @@ def _resolve_context(store, context_id, role, db_path): else: print(report.summary()) + if not fail_on_regression: + return 0 return 0 if report.passed else 1 @@ -783,7 +981,7 @@ def _finite_only(value): _USAGE = ( - "Usage: dprovenancekit " ) @@ -792,8 +990,10 @@ def _finite_only(value): {usage} Commands: + record pin the latest recorded run as the local golden baseline + compare inspect the latest run against the baseline; always exit 0 on a diff + gate compare the latest run against the baseline; exit 1 on regression demo run the installed end-to-end regression demo - gate compare a run against a golden baseline; exit 1 on regression anomalies run anomaly rules over recorded runs runs list runs in a trace database ui serve the local trace viewer (binds 127.0.0.1) @@ -830,6 +1030,10 @@ def main(argv=None) -> int: return demo_main(argv[1:]) if argv and argv[0] == "export": return _run_export(argv[1:]) + if argv and argv[0] == "record": + return _run_record(argv[1:]) + if argv and argv[0] == "compare": + return _run_gate(argv[1:], fail_on_regression=False) if argv and argv[0] == "gate": return _run_gate(argv[1:]) if argv and argv[0] == "anomalies": diff --git a/dprovenancekit/instrument.py b/dprovenancekit/instrument.py index 3cf7e6f..37800d6 100644 --- a/dprovenancekit/instrument.py +++ b/dprovenancekit/instrument.py @@ -51,17 +51,20 @@ def answer(question, sources): ... import functools import inspect import json +import os import uuid from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass -from typing import Any, Callable, Dict, Iterator, Mapping, Optional +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Union from .context import TraceContext from .edge import TraceEdgeType from .event import TraceableEvent from .kit import ActiveTraceRun, DProvenanceKit from .priority import TracePriority +from .sqlite_store import SQLiteTraceStore # The enclosing decorated step's *start* event id, so a nested step can be INFORMED by it. _enclosing_step: ContextVar[Optional[uuid.UUID]] = ContextVar( @@ -170,16 +173,42 @@ def _summarize_call(args: tuple, kwargs: dict) -> Dict[str, Any]: @contextmanager def traced_run( - store: Any, context_id: str, *, schema_version: int = 1 + store: Optional[Any] = None, + context_id: str = "agent", + *, + schema_version: int = 1, + db_path: Optional[Union[str, Path]] = None, ) -> Iterator[ActiveTraceRun]: - """Open a recording run for instrumented code. Yields the active run; flushes on exit.""" - with _KIT.run( - context_id=context_id, store=store, schema_version=schema_version - ) as run: - try: - yield run - finally: - run.flush() + """Open a recording run for instrumented code. + + Passing a store preserves the explicit production API. When no store is supplied, + the run is persisted to ``DPROV_DB`` or ``.dprovenance/traces.sqlite`` so the + zero-configuration ``dpk record`` / ``compare`` / ``gate`` workflow can consume it. + The implicitly-created SQLite store is closed deterministically on exit. + """ + if store is not None and db_path is not None: + raise ValueError("db_path cannot be used when an explicit store is supplied") + + owned_store = None + if store is None: + selected_path = Path( + db_path or os.environ.get("DPROV_DB") or ".dprovenance/traces.sqlite" + ) + selected_path.parent.mkdir(parents=True, exist_ok=True) + owned_store = SQLiteTraceStore(TracedEvent, str(selected_path)) + store = owned_store + + try: + with _KIT.run( + context_id=context_id, store=store, schema_version=schema_version + ) as run: + try: + yield run + finally: + run.flush() + finally: + if owned_store is not None: + owned_store.close() def record_event( diff --git a/pyproject.toml b/pyproject.toml index 9983d93..78aa920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ mcp = ["mcp"] [project.scripts] dprovenancekit = "dprovenancekit.cli:main" +dpk = "dprovenancekit.cli:main" [project.entry-points.pytest11] dprovenancekit = "dprovenancekit.pytest_plugin" diff --git a/tests/test_cli_dispatch.py b/tests/test_cli_dispatch.py index 31bded6..7a5f529 100644 --- a/tests/test_cli_dispatch.py +++ b/tests/test_cli_dispatch.py @@ -23,7 +23,18 @@ def test_help_exits_0_and_lists_commands(capsys): code = main(["--help"]) captured = capsys.readouterr() assert code == 0 - for command in ("demo", "gate", "anomalies", "runs", "ui", "ingest", "export", "sync"): + for command in ( + "record", + "compare", + "gate", + "demo", + "anomalies", + "runs", + "ui", + "ingest", + "export", + "sync", + ): assert command in captured.out diff --git a/tests/test_cli_quick_gate.py b/tests/test_cli_quick_gate.py new file mode 100644 index 0000000..fbb5c74 --- /dev/null +++ b/tests/test_cli_quick_gate.py @@ -0,0 +1,107 @@ +"""Zero-configuration ``dpk record`` / ``compare`` / ``gate`` workflow.""" + +from __future__ import annotations + +import sqlite3 +import time + +from dprovenancekit import SQLiteTraceStore, TracedEvent, traced, traced_run +from dprovenancekit.cli import main + + +@traced +def _retrieve(): + return ["source"] + + +@traced +def _verify(): + return True + + +def _run_agent(include_verify=True, context_id="research-agent"): + with traced_run(context_id=context_id) as run: + _retrieve() + if include_verify: + _verify() + return run.run_id + + +def test_quick_workflow_records_compares_and_gates(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + golden_id = _run_agent() + assert main(["record"]) == 0 + recorded = capsys.readouterr().out + baseline = tmp_path / ".dprovenance" / "baseline.sqlite" + assert baseline.exists() + assert str(golden_id) in recorded + + # The pinned baseline is a single, self-contained run and keeps its provenance edges. + with SQLiteTraceStore(TracedEvent, str(baseline), start_writer=False) as store: + metadata = store.list_run_metadata() + assert len(metadata) == 1 + assert metadata[0].run_id == str(golden_id) + conn = sqlite3.connect(str(baseline)) + try: + assert conn.execute("SELECT COUNT(*) FROM trace_edges").fetchone()[0] == 2 + finally: + conn.close() + + time.sleep(0.002) + _run_agent() + assert main(["compare"]) == 0 + assert "PASS" in capsys.readouterr().out + assert main(["gate"]) == 0 + assert "PASS" in capsys.readouterr().out + + time.sleep(0.002) + _run_agent(include_verify=False) + # compare is exploratory: it reports the regression without failing the shell. + assert main(["compare"]) == 0 + compared = capsys.readouterr().out + assert "FAIL" in compared + assert "verify" in compared + # gate applies the same report as a CI-safe exit status. + assert main(["gate"]) == 1 + assert "FAIL" in capsys.readouterr().out + + +def test_record_context_selects_the_intended_run(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + wanted = _run_agent(context_id="wanted") + time.sleep(0.002) + _run_agent(context_id="newer-but-unrelated") + + assert main(["record", "--context", "wanted"]) == 0 + assert str(wanted) in capsys.readouterr().out + + baseline = tmp_path / ".dprovenance" / "baseline.sqlite" + with SQLiteTraceStore(TracedEvent, str(baseline), start_writer=False) as store: + metadata = store.list_run_metadata() + assert len(metadata) == 1 + assert metadata[0].context_id == "wanted" + + time.sleep(0.002) + _run_agent(context_id="wanted") + time.sleep(0.002) + _run_agent(include_verify=False, context_id="newer-but-unrelated") + assert main(["gate", "--context", "wanted"]) == 0 + assert "PASS" in capsys.readouterr().out + + +def test_record_without_a_trace_fails_closed(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert main(["record"]) == 2 + captured = capsys.readouterr() + assert "Run code inside traced_run" in captured.err + assert not (tmp_path / ".dprovenance" / "traces.sqlite").exists() + + +def test_quick_gate_without_a_baseline_fails_closed(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + _run_agent() + assert main(["gate"]) == 2 + captured = capsys.readouterr() + assert "dpk record" in captured.err + assert not (tmp_path / ".dprovenance" / "baseline.sqlite").exists() diff --git a/tests/test_instrument.py b/tests/test_instrument.py index eb3f129..aa55ebb 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -69,6 +69,48 @@ def search(query): assert by_type["search.end"].payload.attributes["result"] == "[1, 2, 3]" +def test_traced_run_without_store_persists_to_local_default(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + @traced + def search(query): + return [query] + + with traced_run(context_id="research-agent") as active: + search("evidence") + + db_path = tmp_path / ".dprovenance" / "traces.sqlite" + assert db_path.exists() + with SQLiteTraceStore(TracedEvent, str(db_path), start_writer=False) as store: + run = store.get_run(active.run_id) + assert run is not None + assert run.context_id == "research-agent" + assert [event.payload.type_identifier for event in run.events] == [ + "search.start", + "search.end", + ] + + +def test_traced_run_without_store_honors_db_path(tmp_path): + db_path = tmp_path / "custom" / "candidate.sqlite" + + @traced + def verify(): + return True + + with traced_run(context_id="agent", db_path=db_path): + verify() + + assert db_path.exists() + + +def test_traced_run_rejects_db_path_with_explicit_store(tmp_path): + store = InMemoryTraceStore() + with pytest.raises(ValueError, match="explicit store"): + with traced_run(store, context_id="agent", db_path=tmp_path / "unused.sqlite"): + pass + + def test_custom_name_and_capture_off(): store = InMemoryTraceStore()