agentloop-profiler 0.4.0__tar.gz → 0.5.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.

Potentially problematic release.


This version of agentloop-profiler might be problematic. Click here for more details.

Files changed (94) hide show
  1. {agentloop_profiler-0.4.0/agentloop_profiler.egg-info → agentloop_profiler-0.5.0}/PKG-INFO +2 -2
  2. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/README.md +1 -1
  3. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/cli.py +76 -5
  4. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/client.py +33 -4
  5. agentloop_profiler-0.5.0/agentloop/migrations.py +241 -0
  6. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/server.py +82 -3
  7. agentloop_profiler-0.5.0/agentloop/store.py +1256 -0
  8. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/version.py +1 -1
  9. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0/agentloop_profiler.egg-info}/PKG-INFO +2 -2
  10. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/SOURCES.txt +5 -0
  11. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/app.py +59 -2
  12. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/pyproject.toml +1 -1
  13. agentloop_profiler-0.5.0/tests/test_bump_version.py +173 -0
  14. agentloop_profiler-0.5.0/tests/test_cli_store_commands.py +95 -0
  15. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_client.py +31 -0
  16. agentloop_profiler-0.5.0/tests/test_migrations.py +151 -0
  17. agentloop_profiler-0.5.0/tests/test_release_workflows.py +91 -0
  18. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_server.py +104 -0
  19. agentloop_profiler-0.5.0/tests/test_store_contract.py +466 -0
  20. agentloop_profiler-0.4.0/agentloop/store.py +0 -840
  21. agentloop_profiler-0.4.0/tests/test_release_workflows.py +0 -34
  22. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/LICENSE +0 -0
  23. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/THIRD_PARTY_LICENSES.md +0 -0
  24. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/__init__.py +0 -0
  25. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/audit.py +0 -0
  26. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/autoinstrument.py +0 -0
  27. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/ci.py +0 -0
  28. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/config.py +0 -0
  29. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/costs.py +0 -0
  30. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/decorators.py +0 -0
  31. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/demo.py +0 -0
  32. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/doctor.py +0 -0
  33. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/events.py +0 -0
  34. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/exporters.py +0 -0
  35. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/findings.py +0 -0
  36. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/graph.py +0 -0
  37. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/__init__.py +0 -0
  38. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/crewai.py +0 -0
  39. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/langgraph.py +0 -0
  40. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/openai.py +0 -0
  41. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/openai_agents.py +0 -0
  42. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/sdk.py +0 -0
  43. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/vercel_ai.py +0 -0
  44. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/issues.py +0 -0
  45. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/markdown.py +0 -0
  46. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/metrics.py +0 -0
  47. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/optimizer.py +0 -0
  48. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/otel.py +0 -0
  49. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/otel_ids.py +0 -0
  50. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/patches.py +0 -0
  51. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/plan_export.py +0 -0
  52. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/quality.py +0 -0
  53. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/replay.py +0 -0
  54. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/runtime.py +0 -0
  55. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/savings.py +0 -0
  56. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/timing.py +0 -0
  57. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/tracer.py +0 -0
  58. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/value.py +0 -0
  59. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/dependency_links.txt +0 -0
  60. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/entry_points.txt +0 -0
  61. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/requires.txt +0 -0
  62. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/top_level.txt +0 -0
  63. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/__init__.py +0 -0
  64. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/value_view.py +0 -0
  65. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/setup.cfg +0 -0
  66. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_ci_command.py +0 -0
  67. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_cli_trace_inputs.py +0 -0
  68. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_crewai_integration.py +0 -0
  69. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_dashboard_value_view.py +0 -0
  70. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_decorators.py +0 -0
  71. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_demo_cli.py +0 -0
  72. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_distribution_metadata.py +0 -0
  73. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_doctor_autoinstrument.py +0 -0
  74. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_exporters.py +0 -0
  75. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_issues.py +0 -0
  76. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_langgraph_integration.py +0 -0
  77. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_markdown.py +0 -0
  78. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_markdown_exporters.py +0 -0
  79. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_metrics.py +0 -0
  80. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_openai_agents_integration.py +0 -0
  81. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_openai_integration.py +0 -0
  82. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_optimizer.py +0 -0
  83. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_otel_ids.py +0 -0
  84. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_otel_interop.py +0 -0
  85. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_patch_plan.py +0 -0
  86. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_quality.py +0 -0
  87. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_remote_cli_credentials.py +0 -0
  88. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_replay.py +0 -0
  89. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_runtime.py +0 -0
  90. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_savings_aggregation.py +0 -0
  91. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_store.py +0 -0
  92. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_timing.py +0 -0
  93. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_value.py +0 -0
  94. {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_vercel_ai_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentloop-profiler
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Profiler and optimization layer for AI agent execution graphs
5
5
  Author: Dipesh Babu
6
6
  License-Expression: Apache-2.0
@@ -49,7 +49,7 @@ Dynamic: license-file
49
49
  # AgentLoop
50
50
 
51
51
  [![CI](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml/badge.svg)](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml)
52
- [![PyPI](https://img.shields.io/pypi/v/agentloop-profiler.svg)](https://pypi.org/project/agentloop-profiler/)
52
+ [![PyPI](https://img.shields.io/pypi/v/agentloop-profiler.svg?cacheSeconds=300)](https://pypi.org/project/agentloop-profiler/)
53
53
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB.svg)](https://www.python.org/downloads/)
54
54
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
55
55
 
@@ -1,7 +1,7 @@
1
1
  # AgentLoop
2
2
 
3
3
  [![CI](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml/badge.svg)](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/agentloop-profiler.svg)](https://pypi.org/project/agentloop-profiler/)
4
+ [![PyPI](https://img.shields.io/pypi/v/agentloop-profiler.svg?cacheSeconds=300)](https://pypi.org/project/agentloop-profiler/)
5
5
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB.svg)](https://www.python.org/downloads/)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
7
7
 
@@ -27,7 +27,12 @@ from agentloop.quality import (
27
27
  quality_report_to_markdown,
28
28
  )
29
29
  from agentloop.replay import ReplayGates, build_replay_report, replay_report_to_markdown
30
- from agentloop.store import get_store
30
+ from agentloop.store import (
31
+ DEFAULT_PAGE_SIZE,
32
+ FindingNotFoundError,
33
+ FindingTransitionError,
34
+ get_store,
35
+ )
31
36
  from agentloop.tracer import AgentTrace
32
37
  from agentloop.value import build_value_report
33
38
 
@@ -581,9 +586,23 @@ def store_trace(
581
586
 
582
587
 
583
588
  @app.command("list-stored-traces")
584
- def list_stored_traces(project_id: str | None = None) -> None:
589
+ def list_stored_traces(
590
+ project_id: str | None = None,
591
+ limit: int | None = typer.Option(None, help="Page size; omit to list every stored trace."),
592
+ cursor: str | None = typer.Option(
593
+ None, help="Continuation cursor from a previous --limit call's printed next-cursor."
594
+ ),
595
+ ) -> None:
585
596
  db = get_store()
586
- traces = db.list_traces(project_id=project_id)
597
+ if limit is None and cursor is None:
598
+ traces = db.list_traces(project_id=project_id)
599
+ next_cursor = None
600
+ else:
601
+ page = db.list_traces_page(
602
+ project_id=project_id, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
603
+ )
604
+ traces = page["items"]
605
+ next_cursor = page["next_cursor"]
587
606
  table = Table(title="Stored AgentLoop Traces")
588
607
  table.add_column("Project")
589
608
  table.add_column("Run ID")
@@ -599,12 +618,29 @@ def list_stored_traces(project_id: str | None = None) -> None:
599
618
  f"${float(item.get('estimated_cost_usd', 0)):.4f}",
600
619
  )
601
620
  console.print(table)
621
+ if next_cursor:
622
+ console.print(f"More results available. Next cursor: {next_cursor}")
602
623
 
603
624
 
604
625
  @app.command("list-findings")
605
- def list_findings(project_id: str | None = None, status: str | None = None) -> None:
626
+ def list_findings(
627
+ project_id: str | None = None,
628
+ status: str | None = None,
629
+ limit: int | None = typer.Option(None, help="Page size; omit to list every matching finding."),
630
+ cursor: str | None = typer.Option(
631
+ None, help="Continuation cursor from a previous --limit call's printed next-cursor."
632
+ ),
633
+ ) -> None:
606
634
  db = get_store()
607
- findings = db.list_findings(project_id=project_id, status=status)
635
+ if limit is None and cursor is None:
636
+ findings = db.list_findings(project_id=project_id, status=status)
637
+ next_cursor = None
638
+ else:
639
+ page = db.list_findings_page(
640
+ project_id=project_id, status=status, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
641
+ )
642
+ findings = page["items"]
643
+ next_cursor = page["next_cursor"]
608
644
  table = Table(title="AgentLoop Findings")
609
645
  table.add_column("Severity")
610
646
  table.add_column("Type")
@@ -624,6 +660,41 @@ def list_findings(project_id: str | None = None, status: str | None = None) -> N
624
660
  f"{finding['estimated_latency_savings_ms'] / 1000:.2f}s / ${finding['estimated_cost_savings_usd']:.4f}",
625
661
  )
626
662
  console.print(table)
663
+ if next_cursor:
664
+ console.print(f"More results available. Next cursor: {next_cursor}")
665
+
666
+
667
+ @app.command("update-finding-status")
668
+ def update_finding_status_command(
669
+ run_id: str,
670
+ finding_id: str,
671
+ status: str,
672
+ project_id: str = "default",
673
+ ) -> None:
674
+ """Transition a stored finding's lifecycle status (detected/accepted/resolved/dismissed)."""
675
+ db = get_store()
676
+ try:
677
+ updated = db.update_finding_status(project_id, run_id, finding_id, status)
678
+ except FindingNotFoundError as exc:
679
+ raise typer.BadParameter(str(exc), param_hint="finding_id") from exc
680
+ except (FindingTransitionError, ValueError) as exc:
681
+ raise typer.BadParameter(str(exc), param_hint="status") from exc
682
+ console.print(f"Finding {finding_id} is now [bold]{updated['status']}[/bold]")
683
+
684
+
685
+ @app.command("remote-update-finding-status")
686
+ def remote_update_finding_status(
687
+ run_id: str,
688
+ finding_id: str,
689
+ status: str,
690
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
691
+ api_key: str | None = typer.Option(
692
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
693
+ ),
694
+ ) -> None:
695
+ client = _remote_client(api_url, api_key)
696
+ updated = client.update_finding_status(run_id, finding_id, status)
697
+ console.print_json(data=updated)
627
698
 
628
699
 
629
700
  @app.command("optimization-queue")
@@ -49,10 +49,23 @@ class AgentLoopClient:
49
49
  payload = json.loads(Path(trace).read_text(encoding="utf-8"))
50
50
  return self._request("POST", "/traces", payload)
51
51
 
52
- def list_traces(self, project_id: str | None = None) -> dict[str, Any]:
53
- route = "/traces"
52
+ def list_traces(
53
+ self,
54
+ project_id: str | None = None,
55
+ *,
56
+ page_size: int | None = None,
57
+ cursor: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ params: dict[str, Any] = {}
54
60
  if project_id:
55
- route += "?" + urllib.parse.urlencode({"project_id": project_id})
61
+ params["project_id"] = project_id
62
+ if page_size is not None:
63
+ params["page_size"] = page_size
64
+ if cursor:
65
+ params["cursor"] = cursor
66
+ route = "/traces"
67
+ if params:
68
+ route += "?" + urllib.parse.urlencode(params)
56
69
  return self._request("GET", route)
57
70
 
58
71
  def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
@@ -74,18 +87,34 @@ class AgentLoopClient:
74
87
  return self._request("GET", f"/traces/{encoded_run_id}/diagnose")
75
88
 
76
89
  def list_findings(
77
- self, project_id: str | None = None, status: str | None = None
90
+ self,
91
+ project_id: str | None = None,
92
+ status: str | None = None,
93
+ *,
94
+ page_size: int | None = None,
95
+ cursor: str | None = None,
78
96
  ) -> dict[str, Any]:
79
97
  params: dict[str, Any] = {}
80
98
  if project_id:
81
99
  params["project_id"] = project_id
82
100
  if status:
83
101
  params["status"] = status
102
+ if page_size is not None:
103
+ params["page_size"] = page_size
104
+ if cursor:
105
+ params["cursor"] = cursor
84
106
  route = "/findings"
85
107
  if params:
86
108
  route += "?" + urllib.parse.urlencode(params)
87
109
  return self._request("GET", route)
88
110
 
111
+ def update_finding_status(self, run_id: str, finding_id: str, status: str) -> dict[str, Any]:
112
+ encoded_run_id = urllib.parse.quote(run_id, safe="")
113
+ encoded_finding_id = urllib.parse.quote(finding_id, safe="")
114
+ return self._request(
115
+ "POST", f"/findings/{encoded_run_id}/{encoded_finding_id}/status", {"status": status}
116
+ )
117
+
89
118
  def optimization_queue(self, project_id: str | None = None) -> dict[str, Any]:
90
119
  route = "/optimization-queue"
91
120
  if project_id:
@@ -0,0 +1,241 @@
1
+ """Versioned schema migrations shared by :mod:`agentloop.store`'s two backends.
2
+
3
+ Each :class:`Migration` carries dialect-specific, idempotent SQL statements.
4
+ Idempotency (``IF NOT EXISTS`` / ``ON CONFLICT ... DO NOTHING``) lets concurrent
5
+ ``init()`` calls apply the same pending migration twice without corrupting state
6
+ or racing on `schema_migrations` bookkeeping, which stands in for a real
7
+ distributed lock that neither backend otherwise provides here.
8
+
9
+ A fresh install and an existing pre-migration database both start with no rows
10
+ in `schema_migrations`; migration 0001 reproduces the exact schema `init()`
11
+ used to create inline, so both converge on the same structure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ class MigrationError(RuntimeError):
20
+ """Raised when a migration statement fails to apply."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Migration:
25
+ version: int
26
+ name: str
27
+ sqlite_statements: tuple[str, ...]
28
+ postgres_statements: tuple[str, ...]
29
+
30
+
31
+ _BASELINE_SQLITE = (
32
+ """
33
+ CREATE TABLE IF NOT EXISTS projects (
34
+ project_id TEXT PRIMARY KEY,
35
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
36
+ )
37
+ """,
38
+ """
39
+ CREATE TABLE IF NOT EXISTS api_keys (
40
+ key_hash TEXT PRIMARY KEY,
41
+ project_id TEXT NOT NULL,
42
+ name TEXT NOT NULL,
43
+ prefix TEXT NOT NULL,
44
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
45
+ last_used_at TEXT,
46
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
47
+ )
48
+ """,
49
+ """
50
+ CREATE TABLE IF NOT EXISTS traces (
51
+ run_id TEXT PRIMARY KEY,
52
+ project_id TEXT NOT NULL,
53
+ name TEXT NOT NULL,
54
+ started_at TEXT,
55
+ payload_json TEXT NOT NULL,
56
+ total_runtime_ms REAL DEFAULT 0,
57
+ estimated_cost_usd REAL DEFAULT 0,
58
+ model_call_count INTEGER DEFAULT 0,
59
+ tool_call_count INTEGER DEFAULT 0,
60
+ retry_count INTEGER DEFAULT 0,
61
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
63
+ )
64
+ """,
65
+ """
66
+ CREATE TABLE IF NOT EXISTS usage_events (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ project_id TEXT NOT NULL,
69
+ run_id TEXT NOT NULL,
70
+ total_runtime_ms REAL DEFAULT 0,
71
+ estimated_cost_usd REAL DEFAULT 0,
72
+ input_tokens INTEGER DEFAULT 0,
73
+ output_tokens INTEGER DEFAULT 0,
74
+ model_call_count INTEGER DEFAULT 0,
75
+ tool_call_count INTEGER DEFAULT 0,
76
+ retry_count INTEGER DEFAULT 0,
77
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
78
+ )
79
+ """,
80
+ """
81
+ CREATE TABLE IF NOT EXISTS trace_findings (
82
+ project_id TEXT NOT NULL,
83
+ run_id TEXT NOT NULL,
84
+ finding_id TEXT NOT NULL,
85
+ type TEXT NOT NULL,
86
+ severity TEXT NOT NULL,
87
+ title TEXT NOT NULL,
88
+ confidence TEXT NOT NULL,
89
+ patchable INTEGER DEFAULT 0,
90
+ status TEXT DEFAULT 'detected',
91
+ estimated_latency_savings_ms REAL DEFAULT 0,
92
+ estimated_cost_savings_usd REAL DEFAULT 0,
93
+ payload_json TEXT NOT NULL,
94
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
95
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
96
+ PRIMARY KEY(project_id, run_id, finding_id),
97
+ FOREIGN KEY(project_id) REFERENCES projects(project_id),
98
+ FOREIGN KEY(run_id) REFERENCES traces(run_id)
99
+ )
100
+ """,
101
+ "INSERT OR IGNORE INTO projects(project_id) VALUES ('default')",
102
+ )
103
+
104
+ _BASELINE_POSTGRES = (
105
+ "CREATE TABLE IF NOT EXISTS projects ("
106
+ "project_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())",
107
+ "CREATE TABLE IF NOT EXISTS api_keys ("
108
+ "key_hash TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
109
+ "name TEXT NOT NULL, prefix TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), "
110
+ "last_used_at TIMESTAMPTZ)",
111
+ "CREATE TABLE IF NOT EXISTS traces ("
112
+ "run_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
113
+ "name TEXT NOT NULL, started_at TEXT, payload_json JSONB NOT NULL, "
114
+ "total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
115
+ "model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
116
+ "retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
117
+ "CREATE TABLE IF NOT EXISTS usage_events ("
118
+ "id BIGSERIAL PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, "
119
+ "total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
120
+ "input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, "
121
+ "model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
122
+ "retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
123
+ "CREATE TABLE IF NOT EXISTS trace_findings ("
124
+ "project_id TEXT NOT NULL REFERENCES projects(project_id), "
125
+ "run_id TEXT NOT NULL REFERENCES traces(run_id), finding_id TEXT NOT NULL, "
126
+ "type TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, confidence TEXT NOT NULL, "
127
+ "patchable BOOLEAN DEFAULT false, status TEXT DEFAULT 'detected', "
128
+ "estimated_latency_savings_ms DOUBLE PRECISION DEFAULT 0, "
129
+ "estimated_cost_savings_usd DOUBLE PRECISION DEFAULT 0, payload_json JSONB NOT NULL, "
130
+ "created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), "
131
+ "PRIMARY KEY(project_id, run_id, finding_id))",
132
+ "INSERT INTO projects(project_id) VALUES ('default') ON CONFLICT DO NOTHING",
133
+ )
134
+
135
+ # usage_events had no uniqueness constraint, so retried save_trace() calls appended
136
+ # a new row per retry instead of updating one. De-duplicate existing data (keep the
137
+ # most recent row per project/run) before the constraint can be added, or the
138
+ # index creation fails on any store that already has duplicates.
139
+ _DEDUPE_AND_CONSTRAIN_USAGE_EVENTS = (
140
+ "DELETE FROM usage_events WHERE id NOT IN "
141
+ "(SELECT MAX(id) FROM usage_events GROUP BY project_id, run_id)",
142
+ "CREATE UNIQUE INDEX IF NOT EXISTS ux_usage_events_project_run "
143
+ "ON usage_events(project_id, run_id)",
144
+ )
145
+
146
+ _PAGINATION_INDEXES = (
147
+ "CREATE INDEX IF NOT EXISTS ix_traces_project_created "
148
+ "ON traces(project_id, created_at, run_id)",
149
+ "CREATE INDEX IF NOT EXISTS ix_trace_findings_project_status_updated "
150
+ "ON trace_findings(project_id, status, updated_at, run_id, finding_id)",
151
+ )
152
+
153
+ MIGRATIONS: tuple[Migration, ...] = (
154
+ Migration(
155
+ version=1,
156
+ name="baseline",
157
+ sqlite_statements=_BASELINE_SQLITE,
158
+ postgres_statements=_BASELINE_POSTGRES,
159
+ ),
160
+ Migration(
161
+ version=2,
162
+ name="idempotent_usage_events",
163
+ sqlite_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
164
+ postgres_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
165
+ ),
166
+ Migration(
167
+ version=3,
168
+ name="pagination_indexes",
169
+ sqlite_statements=_PAGINATION_INDEXES,
170
+ postgres_statements=_PAGINATION_INDEXES,
171
+ ),
172
+ )
173
+
174
+
175
+ def _ensure_migrations_table_sqlite(conn) -> None:
176
+ conn.execute(
177
+ """
178
+ CREATE TABLE IF NOT EXISTS schema_migrations (
179
+ version INTEGER PRIMARY KEY,
180
+ name TEXT NOT NULL,
181
+ applied_at TEXT DEFAULT CURRENT_TIMESTAMP
182
+ )
183
+ """
184
+ )
185
+
186
+
187
+ def _ensure_migrations_table_postgres(conn) -> None:
188
+ conn.execute(
189
+ "CREATE TABLE IF NOT EXISTS schema_migrations ("
190
+ "version INTEGER PRIMARY KEY, name TEXT NOT NULL, "
191
+ "applied_at TIMESTAMPTZ DEFAULT now())"
192
+ )
193
+
194
+
195
+ def apply_sqlite_migrations(conn) -> None:
196
+ """Apply pending migrations to a SQLite connection inside its open transaction."""
197
+ import sqlite3
198
+
199
+ _ensure_migrations_table_sqlite(conn)
200
+ applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
201
+ for migration in MIGRATIONS:
202
+ if migration.version in applied:
203
+ continue
204
+ try:
205
+ for statement in migration.sqlite_statements:
206
+ conn.execute(statement)
207
+ conn.execute(
208
+ "INSERT OR IGNORE INTO schema_migrations(version, name) VALUES (?, ?)",
209
+ (migration.version, migration.name),
210
+ )
211
+ except sqlite3.Error as exc:
212
+ raise MigrationError(
213
+ f"migration {migration.version} ({migration.name}) failed: {exc}. "
214
+ "The transaction was rolled back; the store is unchanged. Fix the "
215
+ "underlying issue (e.g. free disk space, resolve a lock) and retry."
216
+ ) from exc
217
+
218
+
219
+ def apply_postgres_migrations(conn) -> None:
220
+ """Apply pending migrations to a psycopg connection inside its open transaction."""
221
+ import psycopg
222
+
223
+ _ensure_migrations_table_postgres(conn)
224
+ applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
225
+ for migration in MIGRATIONS:
226
+ if migration.version in applied:
227
+ continue
228
+ try:
229
+ for statement in migration.postgres_statements:
230
+ conn.execute(statement)
231
+ conn.execute(
232
+ "INSERT INTO schema_migrations(version, name) VALUES (%s, %s) "
233
+ "ON CONFLICT (version) DO NOTHING",
234
+ (migration.version, migration.name),
235
+ )
236
+ except psycopg.Error as exc:
237
+ raise MigrationError(
238
+ f"migration {migration.version} ({migration.name}) failed: {exc}. "
239
+ "The transaction was rolled back; the store is unchanged. Fix the "
240
+ "underlying issue (e.g. permissions, a blocking lock) and retry."
241
+ ) from exc
@@ -12,7 +12,16 @@ from agentloop.findings import build_diagnosis
12
12
  from agentloop.issues import build_issue_drafts
13
13
  from agentloop.optimizer import build_optimization_plan
14
14
  from agentloop.quality import build_quality_report
15
- from agentloop.store import TraceProjectConflictError, TraceStore, get_store
15
+ from agentloop.store import (
16
+ DEFAULT_PAGE_SIZE,
17
+ MAX_PAGE_SIZE,
18
+ FindingNotFoundError,
19
+ FindingTransitionError,
20
+ InvalidCursorError,
21
+ TraceProjectConflictError,
22
+ TraceStore,
23
+ get_store,
24
+ )
16
25
  from agentloop.tracer import AgentTrace
17
26
  from agentloop.value import build_value_report
18
27
  from agentloop.version import __version__
@@ -140,11 +149,36 @@ def _selected_project(project_id_filter: str | None, authenticated_project: str)
140
149
  @app.get("/traces")
141
150
  def list_traces(
142
151
  project_id_filter: str | None = Query(default=None, alias="project_id"),
152
+ page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
153
+ cursor: str | None = Query(default=None),
143
154
  project_id: str = Depends(resolve_project),
144
155
  db: TraceStore = Depends(store),
145
156
  ) -> dict[str, Any]:
157
+ """List traces for a project.
158
+
159
+ Without `page_size`/`cursor`, returns the full (bounded) list for
160
+ backward compatibility. Pass `page_size` to opt into keyset pagination:
161
+ the response then includes `next_cursor` — pass it back as `cursor` to
162
+ fetch the next page; `next_cursor: null` means there are no more rows.
163
+ """
146
164
  selected_project = _selected_project(project_id_filter, project_id)
147
- return {"project_id": selected_project, "traces": db.list_traces(project_id=selected_project)}
165
+ if page_size is None and cursor is None:
166
+ return {
167
+ "project_id": selected_project,
168
+ "traces": db.list_traces(project_id=selected_project),
169
+ "next_cursor": None,
170
+ }
171
+ try:
172
+ page = db.list_traces_page(
173
+ project_id=selected_project, limit=page_size or DEFAULT_PAGE_SIZE, cursor=cursor
174
+ )
175
+ except InvalidCursorError as exc:
176
+ raise HTTPException(status_code=400, detail=str(exc)) from None
177
+ return {
178
+ "project_id": selected_project,
179
+ "traces": page["items"],
180
+ "next_cursor": page["next_cursor"],
181
+ }
148
182
 
149
183
 
150
184
  def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
@@ -187,16 +221,61 @@ def diagnose_trace(
187
221
  def list_findings(
188
222
  status: str | None = Query(default=None),
189
223
  project_id_filter: str | None = Query(default=None, alias="project_id"),
224
+ page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
225
+ cursor: str | None = Query(default=None),
190
226
  project_id: str = Depends(resolve_project),
191
227
  db: TraceStore = Depends(store),
192
228
  ) -> dict[str, Any]:
229
+ """List findings for a project. See `list_traces` for the pagination contract.
230
+
231
+ Paginated results are ordered by recency (most recently updated first),
232
+ not by the priority order `optimization-queue` uses.
233
+ """
193
234
  selected_project = _selected_project(project_id_filter, project_id)
235
+ if page_size is None and cursor is None:
236
+ return {
237
+ "project_id": selected_project,
238
+ "findings": db.list_findings(project_id=selected_project, status=status),
239
+ "next_cursor": None,
240
+ }
241
+ try:
242
+ page = db.list_findings_page(
243
+ project_id=selected_project,
244
+ status=status,
245
+ limit=page_size or DEFAULT_PAGE_SIZE,
246
+ cursor=cursor,
247
+ )
248
+ except InvalidCursorError as exc:
249
+ raise HTTPException(status_code=400, detail=str(exc)) from None
194
250
  return {
195
251
  "project_id": selected_project,
196
- "findings": db.list_findings(project_id=selected_project, status=status),
252
+ "findings": page["items"],
253
+ "next_cursor": page["next_cursor"],
197
254
  }
198
255
 
199
256
 
257
+ class FindingStatusPayload(BaseModel):
258
+ status: str
259
+
260
+
261
+ @app.post("/findings/{run_id}/{finding_id}/status")
262
+ def update_finding_status(
263
+ run_id: str,
264
+ finding_id: str,
265
+ payload: FindingStatusPayload,
266
+ project_id: str = Depends(resolve_project),
267
+ db: TraceStore = Depends(store),
268
+ ) -> dict[str, Any]:
269
+ try:
270
+ return db.update_finding_status(project_id, run_id, finding_id, payload.status)
271
+ except FindingNotFoundError as exc:
272
+ raise HTTPException(status_code=404, detail=str(exc)) from None
273
+ except FindingTransitionError as exc:
274
+ raise HTTPException(status_code=409, detail=str(exc)) from None
275
+ except ValueError as exc:
276
+ raise HTTPException(status_code=400, detail=str(exc)) from None
277
+
278
+
200
279
  @app.get("/optimization-queue")
201
280
  def optimization_queue(
202
281
  project_id_filter: str | None = Query(default=None, alias="project_id"),