runtrace-ai 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
runtrace/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .client import Run, RunTrace, configure, create_project, run, search
2
+
3
+ __all__ = ["Run", "RunTrace", "configure", "create_project", "run", "search"]
4
+
runtrace/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from .client import RunTrace
13
+ from .credentials import resolve_connection, save_credentials
14
+
15
+
16
+ app = typer.Typer(no_args_is_help=True, help="Track experiments and query RunTrace memory.")
17
+ integrations_app = typer.Typer(no_args_is_help=True, help="Install RunTrace in supported agent CLIs.")
18
+ app.add_typer(integrations_app, name="integrations")
19
+
20
+
21
+ def client(base_url: str | None, api_token: str | None) -> RunTrace:
22
+ resolved_base_url, resolved_api_token = resolve_connection(base_url, api_token)
23
+ return RunTrace(base_url=resolved_base_url, api_token=resolved_api_token, strict=True)
24
+
25
+
26
+ @app.command()
27
+ def auth(
28
+ api_key: Annotated[str, typer.Argument(help="Agent API key, or '-' to read it from stdin.")],
29
+ base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL", help="RunTrace API URL."),
30
+ ) -> None:
31
+ """Authenticate the CLI and MCP tool with an agent API key."""
32
+ if api_key == "-":
33
+ api_key = sys.stdin.readline().strip()
34
+ if not api_key:
35
+ raise typer.BadParameter("API key cannot be empty")
36
+
37
+ resolved_base_url, _ = resolve_connection(base_url, api_key)
38
+ status = RunTrace(
39
+ base_url=resolved_base_url,
40
+ api_token=api_key,
41
+ strict=True,
42
+ ).request("GET", "/api/v1/auth/status")
43
+ if not status.get("authenticated"):
44
+ raise typer.BadParameter("RunTrace rejected this API key")
45
+
46
+ path = save_credentials(resolved_base_url, api_key)
47
+ typer.echo(f"Authenticated with {resolved_base_url}. Credentials saved to {path}")
48
+
49
+
50
+ @app.command()
51
+ def search(
52
+ project: str,
53
+ query: str,
54
+ base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
55
+ api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
56
+ limit: int = 10,
57
+ ) -> None:
58
+ result = client(base_url, api_token).search(project, query, limit)
59
+ typer.echo(json.dumps(result, indent=2, default=str))
60
+
61
+
62
+ @app.command("context")
63
+ def context_command(
64
+ project: str,
65
+ base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
66
+ api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
67
+ ) -> None:
68
+ result = client(base_url, api_token).request("GET", f"/api/v1/projects/{project}/context")
69
+ typer.echo(json.dumps(result, indent=2, default=str))
70
+
71
+
72
+ @app.command(
73
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
74
+ help="Run a command and track structured RUNTRACE_METRIC/RUNTRACE_EVENT output.",
75
+ )
76
+ def exec(
77
+ ctx: typer.Context,
78
+ project: str = typer.Option(...),
79
+ name: str = typer.Option(...),
80
+ hypothesis: str = typer.Option(...),
81
+ reasoning: str = typer.Option(""),
82
+ base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
83
+ api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
84
+ ) -> None:
85
+ command = list(ctx.args)
86
+ if command and command[0] == "--":
87
+ command = command[1:]
88
+ if not command:
89
+ raise typer.BadParameter("Provide a command after --")
90
+ rt = client(base_url, api_token)
91
+ with rt.run(project, name, hypothesis, reasoning, command=" ".join(command)) as tracked:
92
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
93
+ assert process.stdout
94
+ metric_pattern = re.compile(r"^RUNTRACE_METRIC\s+(\S+)=([+-]?[\d.]+)(?:\s+step=(\d+))?")
95
+ event_pattern = re.compile(r'^RUNTRACE_EVENT(?:\s+level=(\w+))?\s+message=["\']?(.*?)["\']?$')
96
+ for line in process.stdout:
97
+ sys.stdout.write(line)
98
+ if match := metric_pattern.match(line.strip()):
99
+ tracked.log_metric(match.group(1), float(match.group(2)), int(match.group(3)) if match.group(3) else None)
100
+ elif match := event_pattern.match(line.strip()):
101
+ tracked.log_event(match.group(2), match.group(1) or "info")
102
+ code = process.wait()
103
+ if code == 0:
104
+ tracked.finish("undecided", result_summary="Command completed successfully")
105
+ else:
106
+ tracked.abort(f"Command exited {code}")
107
+ raise typer.Exit(code)
108
+
109
+
110
+ @integrations_app.command("install")
111
+ def install_integration(
112
+ host: Annotated[str, typer.Argument(help="Agent CLI to configure: codex or claude")],
113
+ ref: str = typer.Option("master", help="RunTrace Git ref to install."),
114
+ dry_run: bool = typer.Option(False, help="Print commands without changing host configuration."),
115
+ ) -> None:
116
+ """Install the RunTrace plugin from its public repository marketplace."""
117
+ host = host.lower()
118
+ if host not in {"codex", "claude"}:
119
+ raise typer.BadParameter("Host must be 'codex' or 'claude'")
120
+ if not shutil.which(host):
121
+ raise typer.BadParameter(f"{host} is not installed or is not on PATH")
122
+ if host == "codex":
123
+ commands = [
124
+ ["codex", "plugin", "marketplace", "add", "vano04/RunTrace", "--ref", ref],
125
+ ["codex", "plugin", "add", "runtrace@runtrace"],
126
+ ]
127
+ else:
128
+ commands = [
129
+ ["claude", "plugin", "marketplace", "add", "vano04/RunTrace"],
130
+ ["claude", "plugin", "install", "runtrace@runtrace", "--scope", "user"],
131
+ ]
132
+ for command in commands:
133
+ typer.echo("$ " + " ".join(command))
134
+ if not dry_run:
135
+ subprocess.run(command, check=True)
136
+ typer.echo("RunTrace plugin installed. Run 'runtrace auth API_KEY' to authenticate it.")
137
+
138
+
139
+ if __name__ == "__main__":
140
+ app()
runtrace/client.py ADDED
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import os
6
+ import platform
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ import warnings
14
+ from collections import deque
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import httpx
19
+ import psutil
20
+
21
+
22
+ def _git(args: list[str], cwd: str) -> str | None:
23
+ try:
24
+ return subprocess.check_output(["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL, timeout=2).strip()
25
+ except (OSError, subprocess.SubprocessError):
26
+ return None
27
+
28
+
29
+ def _metadata(cwd: str) -> dict[str, Any]:
30
+ return {
31
+ "host_metadata": {
32
+ "hostname": socket.gethostname(),
33
+ "platform": platform.platform(),
34
+ "cpu_count": psutil.cpu_count(),
35
+ "memory_bytes": psutil.virtual_memory().total,
36
+ },
37
+ "environment_metadata": {
38
+ "python": sys.version.split()[0],
39
+ "executable": sys.executable,
40
+ "argv": sys.argv,
41
+ },
42
+ "git_commit": _git(["rev-parse", "HEAD"], cwd),
43
+ "git_branch": _git(["branch", "--show-current"], cwd),
44
+ "git_dirty": bool(_git(["status", "--porcelain"], cwd)),
45
+ }
46
+
47
+
48
+ class RunTrace:
49
+ def __init__(
50
+ self,
51
+ base_url: str = "http://localhost:8000",
52
+ api_token: str | None = None,
53
+ strict: bool = False,
54
+ timeout: float = 5.0,
55
+ *,
56
+ api_key: str | None = None,
57
+ ):
58
+ self.base_url = base_url.rstrip("/")
59
+ self.api_token = api_token or api_key
60
+ self.strict = strict
61
+ self.client = httpx.Client(
62
+ base_url=self.base_url,
63
+ timeout=timeout,
64
+ headers={"Authorization": f"Bearer {self.api_token}"} if self.api_token else {},
65
+ )
66
+ self._buffer: deque[tuple[str, str, dict[str, Any], str]] = deque(maxlen=2000)
67
+ self._lock = threading.Lock()
68
+ atexit.register(self.flush)
69
+
70
+ def request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, buffer: bool = False, request_id: str | None = None) -> Any:
71
+ request_id = request_id or str(uuid.uuid4())
72
+ try:
73
+ response = self.client.request(method, path, json=payload, headers={"X-Request-ID": request_id})
74
+ response.raise_for_status()
75
+ return response.json() if response.content else None
76
+ except (httpx.HTTPError, OSError):
77
+ if buffer:
78
+ with self._lock:
79
+ self._buffer.append((method, path, payload or {}, request_id))
80
+ warnings.warn(f"RunTrace is unavailable; buffered {method} {path}", RuntimeWarning, stacklevel=2)
81
+ return None
82
+ if self.strict:
83
+ raise
84
+ return None
85
+
86
+ def flush(self) -> int:
87
+ sent = 0
88
+ with self._lock:
89
+ pending = list(self._buffer)
90
+ for method, path, payload, request_id in pending:
91
+ try:
92
+ response = self.client.request(method, path, json=payload, headers={"X-Request-ID": request_id})
93
+ response.raise_for_status()
94
+ except (httpx.HTTPError, OSError):
95
+ break
96
+ with self._lock:
97
+ if self._buffer and self._buffer[0][3] == request_id:
98
+ self._buffer.popleft()
99
+ sent += 1
100
+ return sent
101
+
102
+ def search(self, project: str, query: str, limit: int = 10, include_archived: bool = False) -> dict[str, Any] | None:
103
+ return self.request("POST", "/api/v1/search", {"project": project, "query": query, "limit": limit, "include_archived": include_archived})
104
+
105
+ def create_project(self, name: str, slug: str, description: str = "") -> dict[str, Any] | None:
106
+ return self.request("POST", "/api/v1/projects", {"name": name, "slug": slug, "description": description})
107
+
108
+ def run(self, project: str, name: str, hypothesis: str = "", reasoning: str = "", tags: list[str] | None = None, **kwargs: Any) -> "Run":
109
+ return Run(self, project, name, hypothesis, reasoning, tags or [], kwargs)
110
+
111
+
112
+ class Run:
113
+ def __init__(self, client: RunTrace, project: str, name: str, hypothesis: str, reasoning: str, tags: list[str], options: dict[str, Any]):
114
+ self.client = client
115
+ self.project = project
116
+ self.name = name
117
+ self.hypothesis = hypothesis
118
+ self.reasoning = reasoning
119
+ self.tags = tags
120
+ self.options = options
121
+ self.id: str | None = None
122
+ self.display_id: str | None = None
123
+ self._finished = False
124
+
125
+ def __enter__(self) -> "Run":
126
+ cwd = self.options.pop("working_directory", os.getcwd())
127
+ configuration = dict(self.options.pop("configuration", {}))
128
+ configuration["tags"] = self.tags
129
+ payload = {
130
+ "name": self.name,
131
+ "hypothesis": self.hypothesis,
132
+ "reasoning": self.reasoning,
133
+ "working_directory": cwd,
134
+ "command": " ".join(sys.argv),
135
+ "configuration": configuration,
136
+ **_metadata(cwd),
137
+ **self.options,
138
+ }
139
+ created = self.client.request("POST", f"/api/v1/projects/{self.project}/runs", payload)
140
+ if not created:
141
+ if self.client.strict:
142
+ raise RuntimeError("RunTrace server unavailable")
143
+ return self
144
+ self.id = created["id"]
145
+ self.display_id = created["display_id"]
146
+ return self
147
+
148
+ def __exit__(self, exc_type, exc, traceback) -> bool:
149
+ if exc is not None:
150
+ self.abort(str(exc))
151
+ elif not self._finished:
152
+ self.finish("undecided")
153
+ return False
154
+
155
+ def _send(self, path: str, payload: dict[str, Any], *, buffer: bool = True) -> Any:
156
+ if not self.id:
157
+ return None
158
+ return self.client.request("POST", f"/api/v1/runs/{self.id}/{path}", payload, buffer=buffer)
159
+
160
+ def log_metric(self, name: str, value: float, step: int | None = None, timestamp: str | None = None) -> None:
161
+ self.log_metrics({name: value}, step=step, timestamp=timestamp)
162
+
163
+ def log_metrics(self, values: dict[str, float], step: int | None = None, timestamp: str | None = None) -> None:
164
+ self._send("metrics", {"metrics": [{"name": name, "value": value, "step": step, "timestamp": timestamp} for name, value in values.items()]})
165
+
166
+ def log_param(self, name: str, value: Any) -> None:
167
+ self.log_params({name: value})
168
+
169
+ def log_params(self, values: dict[str, Any]) -> None:
170
+ self._send("parameters", {"parameters": values})
171
+
172
+ def log_event(self, message: str, level: str = "info", event_type: str | None = None, metadata: dict[str, Any] | None = None) -> None:
173
+ self._send("events", {"message": message, "level": level, "event_type": event_type, "metadata": metadata or {}})
174
+
175
+ def log_reasoning(self, text: str, stage: str = "during") -> None:
176
+ self.log_event(text, event_type=f"reasoning.{stage}")
177
+
178
+ def set_tags(self, tags: list[str]) -> None:
179
+ self.tags = tags
180
+ self.log_param("tags", tags)
181
+
182
+ def link_run(self, run_id: str, relationship: str) -> None:
183
+ self.log_event(f"Linked {run_id} as {relationship}", event_type="run.relationship", metadata={"run_id": run_id, "relationship": relationship})
184
+
185
+ def _upload_artifact(self, name: str, content: Any, content_type: str, metadata: dict[str, Any] | None = None, kind: str = "artifact") -> dict[str, Any] | None:
186
+ if not self.id:
187
+ return None
188
+ try:
189
+ artifact_metadata = {"kind": kind, **(metadata or {})}
190
+ response = self.client.client.post(
191
+ f"/api/v1/runs/{self.id}/artifacts",
192
+ files={"file": (name, content, content_type)},
193
+ data={"metadata": json.dumps(artifact_metadata)},
194
+ )
195
+ response.raise_for_status()
196
+ return response.json()
197
+ except (OSError, httpx.HTTPError):
198
+ if self.client.strict:
199
+ raise
200
+ warnings.warn(f"RunTrace could not upload artifact {name}", RuntimeWarning, stacklevel=2)
201
+ return None
202
+
203
+ def log_artifact(self, path: str, name: str | None = None, metadata: dict[str, Any] | None = None, kind: str = "artifact") -> dict[str, Any] | None:
204
+ artifact_path = Path(path)
205
+ try:
206
+ with artifact_path.open("rb") as handle:
207
+ return self._upload_artifact(name or artifact_path.name, handle, "application/octet-stream", metadata, kind)
208
+ except OSError:
209
+ if self.client.strict:
210
+ raise
211
+ warnings.warn(f"RunTrace could not upload artifact {artifact_path}", RuntimeWarning, stacklevel=2)
212
+ return None
213
+
214
+ def log_text(self, name: str, content: str, kind: str = "log", metadata: dict[str, Any] | None = None) -> dict[str, Any] | None:
215
+ """Save previewable text output such as stdout, stderr, reports, or logs."""
216
+ return self._upload_artifact(name, content.encode("utf-8"), "text/plain", metadata, kind)
217
+
218
+ def log_config(self, values: dict[str, Any], name: str = "config.json", metadata: dict[str, Any] | None = None) -> dict[str, Any] | None:
219
+ """Save configuration as both queryable parameters and a versioned JSON artifact."""
220
+ self.log_params(values)
221
+ return self._upload_artifact(name, json.dumps(values, indent=2, sort_keys=True).encode("utf-8"), "application/json", metadata, "config")
222
+
223
+ def finish(self, outcome: str, result_summary: str | None = None, conclusion: str | None = None) -> None:
224
+ disposition = {"success": "kept", "partial_success": "kept", "failure": "discarded", "inconclusive": "undecided"}.get(outcome, outcome)
225
+ self._send("finish", {"disposition": disposition, "result_summary": result_summary or "", "conclusion": conclusion or ""}, buffer=False)
226
+ self._finished = True
227
+
228
+ def abort(self, reason: str | None = None) -> None:
229
+ self._send("crash", {"error_summary": reason or "Run aborted"}, buffer=False)
230
+ self._finished = True
231
+
232
+
233
+ _default = RunTrace(
234
+ base_url=os.getenv("RUNTRACE_BASE_URL", "http://localhost:8000"),
235
+ api_token=os.getenv("RUNTRACE_API_TOKEN") or os.getenv("RUNTRACE_API_KEY"),
236
+ )
237
+
238
+
239
+ def configure(
240
+ base_url: str = "http://localhost:8000",
241
+ api_token: str | None = None,
242
+ strict: bool = False,
243
+ *,
244
+ api_key: str | None = None,
245
+ ) -> RunTrace:
246
+ global _default
247
+ _default = RunTrace(base_url=base_url, api_token=api_token or api_key, strict=strict)
248
+ return _default
249
+
250
+
251
+ def run(project: str, hypothesis: str, reasoning: str = "", name: str | None = None, tags: list[str] | None = None, **kwargs: Any) -> Run:
252
+ return _default.run(project, name or hypothesis[:80], hypothesis, reasoning, tags, **kwargs)
253
+
254
+
255
+ def search(project: str, query: str, limit: int = 10) -> dict[str, Any] | None:
256
+ return _default.search(project, query, limit)
257
+
258
+
259
+ def create_project(name: str, slug: str, description: str = "") -> dict[str, Any] | None:
260
+ return _default.create_project(name, slug, description)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ DEFAULT_BASE_URL = "http://localhost:8000"
11
+
12
+
13
+ def credentials_path() -> Path:
14
+ override = os.getenv("RUNTRACE_CREDENTIALS_FILE")
15
+ if override:
16
+ return Path(override).expanduser()
17
+ config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
18
+ return config_home / "runtrace" / "credentials.json"
19
+
20
+
21
+ def load_credentials() -> dict[str, str]:
22
+ try:
23
+ payload: Any = json.loads(credentials_path().read_text())
24
+ except (OSError, json.JSONDecodeError):
25
+ return {}
26
+ if not isinstance(payload, dict):
27
+ return {}
28
+ return {
29
+ key: value
30
+ for key in ("base_url", "api_token")
31
+ if isinstance((value := payload.get(key)), str) and value
32
+ }
33
+
34
+
35
+ def resolve_connection(
36
+ base_url: str | None = None,
37
+ api_token: str | None = None,
38
+ ) -> tuple[str, str | None]:
39
+ saved = load_credentials()
40
+ resolved_base_url = (
41
+ base_url
42
+ or os.getenv("RUNTRACE_BASE_URL")
43
+ or saved.get("base_url")
44
+ or DEFAULT_BASE_URL
45
+ )
46
+ resolved_api_token = (
47
+ api_token
48
+ or os.getenv("RUNTRACE_API_TOKEN")
49
+ or os.getenv("RUNTRACE_API_KEY")
50
+ or saved.get("api_token")
51
+ )
52
+ return resolved_base_url.rstrip("/"), resolved_api_token
53
+
54
+
55
+ def save_credentials(base_url: str, api_token: str) -> Path:
56
+ path = credentials_path()
57
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
58
+ try:
59
+ path.parent.chmod(0o700)
60
+ except OSError:
61
+ pass
62
+
63
+ temporary_name: str | None = None
64
+ try:
65
+ with tempfile.NamedTemporaryFile(
66
+ "w",
67
+ encoding="utf-8",
68
+ dir=path.parent,
69
+ prefix=".credentials-",
70
+ delete=False,
71
+ ) as temporary:
72
+ temporary_name = temporary.name
73
+ json.dump({"base_url": base_url.rstrip("/"), "api_token": api_token}, temporary)
74
+ temporary.write("\n")
75
+ temporary.flush()
76
+ os.fsync(temporary.fileno())
77
+ os.chmod(temporary_name, 0o600)
78
+ os.replace(temporary_name, path)
79
+ finally:
80
+ if temporary_name:
81
+ try:
82
+ Path(temporary_name).unlink()
83
+ except FileNotFoundError:
84
+ pass
85
+ return path