quantikmind-cli 0.1.0__tar.gz → 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 (45) hide show
  1. quantikmind_cli-1.0.0/PKG-INFO +30 -0
  2. quantikmind_cli-1.0.0/README.md +5 -0
  3. quantikmind_cli-1.0.0/pyproject.toml +46 -0
  4. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/prometheus.py +29 -5
  5. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/api.py +47 -5
  6. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/cli.py +77 -6
  7. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/config.py +4 -0
  8. quantikmind_cli-1.0.0/quantikmind_cli/influence.py +253 -0
  9. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/record_tests.py +25 -0
  10. quantikmind_cli-1.0.0/quantikmind_cli/runs.py +843 -0
  11. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/scanner.py +1 -1
  12. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/subset.py +14 -0
  13. quantikmind_cli-1.0.0/quantikmind_cli/telemetry.py +23 -0
  14. quantikmind_cli-1.0.0/quantikmind_cli.egg-info/PKG-INFO +30 -0
  15. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/SOURCES.txt +8 -1
  16. quantikmind_cli-1.0.0/quantikmind_cli.egg-info/requires.txt +7 -0
  17. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_api.py +53 -0
  18. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_cli.py +121 -5
  19. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_config.py +11 -0
  20. quantikmind_cli-1.0.0/tests/test_doc_templates.py +30 -0
  21. quantikmind_cli-1.0.0/tests/test_influence.py +107 -0
  22. quantikmind_cli-1.0.0/tests/test_prometheus.py +202 -0
  23. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_record_tests.py +18 -0
  24. quantikmind_cli-1.0.0/tests/test_saved_runs.py +437 -0
  25. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_scanner.py +12 -0
  26. quantikmind_cli-1.0.0/tests/test_telemetry.py +20 -0
  27. quantikmind_cli-0.1.0/PKG-INFO +0 -695
  28. quantikmind_cli-0.1.0/README.md +0 -663
  29. quantikmind_cli-0.1.0/pyproject.toml +0 -69
  30. quantikmind_cli-0.1.0/quantikmind_cli.egg-info/PKG-INFO +0 -695
  31. quantikmind_cli-0.1.0/quantikmind_cli.egg-info/requires.txt +0 -7
  32. quantikmind_cli-0.1.0/tests/test_prometheus.py +0 -117
  33. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/__init__.py +0 -0
  34. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/__init__.py +0 -0
  35. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/push.py +0 -0
  36. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/service.py +0 -0
  37. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/git.py +0 -0
  38. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/manifest.py +0 -0
  39. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/dependency_links.txt +0 -0
  40. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/entry_points.txt +0 -0
  41. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/top_level.txt +0 -0
  42. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/setup.cfg +0 -0
  43. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_e2e_smoke.py +0 -0
  44. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_git.py +0 -0
  45. {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_manifest.py +0 -0
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: quantikmind-cli
3
+ Version: 1.0.0
4
+ Summary: Command line tools for Quantik Mind test selection and runtime signal collection
5
+ Author: Quantik Mind
6
+ Keywords: quantik-mind,testing,ci,cli
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click
20
+ Requires-Dist: requests
21
+ Requires-Dist: PyYAML
22
+ Requires-Dist: rich
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == "test"
25
+
26
+ # Quantik Mind CLI
27
+
28
+ The canonical installation, command and CI/CD guide is [the integration CLI guide](../docs/integration/03-cli-guide.md). It describes the intended final 1.0.0 release. This source prepares 1.0.0rc1 for local release verification; it is not a published final release. Publication and backend compatibility approval remain separate release gates.
29
+
30
+ For local development: `python -m pip install -e .` from this directory.
@@ -0,0 +1,5 @@
1
+ # Quantik Mind CLI
2
+
3
+ The canonical installation, command and CI/CD guide is [the integration CLI guide](../docs/integration/03-cli-guide.md). It describes the intended final 1.0.0 release. This source prepares 1.0.0rc1 for local release verification; it is not a published final release. Publication and backend compatibility approval remain separate release gates.
4
+
5
+ For local development: `python -m pip install -e .` from this directory.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools==80.9.0", "wheel==0.45.1"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quantikmind-cli"
7
+ version = "1.0.0"
8
+ description = "Command line tools for Quantik Mind test selection and runtime signal collection"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [
12
+ { name = "Quantik Mind" },
13
+ ]
14
+ keywords = ["quantik-mind", "testing", "ci", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Testing",
26
+ ]
27
+ dependencies = [
28
+ "click",
29
+ "requests",
30
+ "PyYAML",
31
+ "rich",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ test = [
36
+ "pytest",
37
+ ]
38
+
39
+ [project.scripts]
40
+ qmind = "quantikmind_cli.cli:cli"
41
+
42
+ [tool.setuptools.packages.find]
43
+ include = ["quantikmind_cli*"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
3
4
  from dataclasses import dataclass
4
5
  from typing import Any, Mapping
5
6
 
@@ -17,11 +18,22 @@ class MetricCheck:
17
18
  warning: str | None = None
18
19
 
19
20
 
21
+ # v2 histogram is service_response_time_seconds_bucket: p95 is seconds.
22
+ AGENT_METRICS_CONTRACT_VERSION = "service-metrics-v2"
23
+
24
+ # Both counters must describe the same eligible request population. Missing
25
+ # numerator series remain absent; filtering the denominator excludes no traffic.
20
26
  SERVICE_METRIC_QUERIES = {
21
27
  "cpu_load": "avg by (service)(service_cpu_load)",
22
- "error_rate": "rate(service_errors_total[5m])",
23
- "request_rate": "rate(service_requests_total[5m])",
24
- "latency_p95": "histogram_quantile(0.95, rate(service_response_time_seconds_bucket[5m]))",
28
+ "error_rate": (
29
+ "sum by (service) (rate(service_errors_total[5m]))"
30
+ " / (sum by (service) (rate(service_requests_total[5m])) > 0)"
31
+ ),
32
+ "request_rate": "sum by (service) (rate(service_requests_total[5m]))",
33
+ "latency_p95": (
34
+ "histogram_quantile(0.95, sum by (le, service) "
35
+ "(rate(service_response_time_seconds_bucket[5m])))"
36
+ ),
25
37
  }
26
38
 
27
39
 
@@ -78,15 +90,26 @@ class PrometheusClient:
78
90
  def collect_service_metrics(self) -> dict[str, dict[str, float]]:
79
91
  metrics: dict[str, dict[str, float]] = {}
80
92
  for payload_key, query in SERVICE_METRIC_QUERIES.items():
93
+ seen: set[str] = set()
81
94
  for sample in self.query(query):
82
95
  service = _sample_service(sample)
83
96
  if not service:
84
97
  continue
98
+ if service in seen:
99
+ raise PrometheusError(f"Duplicate service result for {payload_key}: {service}")
100
+ seen.add(service)
85
101
  value = _sample_value(sample)
86
102
  if value is None:
87
103
  continue
104
+ if payload_key == "error_rate" and not 0 <= value <= 1:
105
+ continue
106
+ if payload_key in {"request_rate", "latency_p95"} and value < 0:
107
+ continue
88
108
  metrics.setdefault(service, {})[payload_key] = value
89
- return metrics
109
+ for values in metrics.values():
110
+ if values.get("request_rate", 0) <= 0:
111
+ values.pop("error_rate", None)
112
+ return {service: values for service, values in metrics.items() if values}
90
113
 
91
114
 
92
115
  def normalize_prometheus_url(value: str) -> str:
@@ -112,6 +135,7 @@ def _sample_value(sample: Mapping[str, Any]) -> float | None:
112
135
  if not isinstance(value, list) or len(value) < 2:
113
136
  return None
114
137
  try:
115
- return float(value[1])
138
+ numeric = float(value[1])
139
+ return numeric if math.isfinite(numeric) else None
116
140
  except (TypeError, ValueError):
117
141
  return None
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any, Mapping
4
+ import os
5
+ import math
6
+ import re
4
7
 
5
8
  import requests
6
9
 
7
- from .config import DEFAULT_BASE_URL, resolve_base_url
10
+ from .config import ConfigError, DEFAULT_BASE_URL, resolve_base_url
8
11
 
9
12
 
10
13
  class ApiError(RuntimeError):
@@ -32,6 +35,7 @@ class QuantikMindApiClient:
32
35
  token: str | None = None,
33
36
  auth_mode: str = "auto",
34
37
  timeout: float = 30.0,
38
+ selection_read_timeout_seconds: float = 120.0,
35
39
  session: requests.Session | None = None,
36
40
  ) -> None:
37
41
  self.base_url = base_url.rstrip("/")
@@ -39,6 +43,13 @@ class QuantikMindApiClient:
39
43
  self.token = token
40
44
  self.auth_mode = auth_mode
41
45
  self.timeout = timeout
46
+ try:
47
+ reference = float(selection_read_timeout_seconds)
48
+ except (TypeError, ValueError) as exc:
49
+ raise ConfigError("selection_read_timeout_seconds must be positive and finite") from exc
50
+ if isinstance(selection_read_timeout_seconds, bool) or not math.isfinite(reference) or reference <= 0:
51
+ raise ConfigError("selection_read_timeout_seconds must be positive and finite")
52
+ self.selection_read_timeout_seconds = reference
42
53
  self.session = session or requests.Session()
43
54
 
44
55
  @classmethod
@@ -53,6 +64,7 @@ class QuantikMindApiClient:
53
64
  api_key=credentials.get("api_key"),
54
65
  token=credentials.get("token") or credentials.get("access_token"),
55
66
  auth_mode=str(credentials.get("auth_mode") or "auto"),
67
+ selection_read_timeout_seconds=(repo_config or {}).get("selection_read_timeout_seconds", 120.0),
56
68
  )
57
69
 
58
70
  def auth_headers(self) -> dict[str, str]:
@@ -65,6 +77,16 @@ class QuantikMindApiClient:
65
77
  return {"X-QM-API-Key": self.api_key}
66
78
  return {"Authorization": f"Bearer {credential}"}
67
79
 
80
+ def safe_error(self, message: str) -> str:
81
+ for value in (self.api_key, self.token):
82
+ if value:
83
+ message = message.replace(value, "[REDACTED]")
84
+ message = re.sub(r"https?://\S+", "[URL omitted]", message)
85
+ message = re.sub(r"qm_(?:live|dev)_[A-Za-z0-9_-]+", "[REDACTED]", message)
86
+ if re.search(r"<\s*(?:html|!doctype)", message, re.I):
87
+ return "API returned an error page; raw response omitted."
88
+ return message[:2000]
89
+
68
90
  def request(
69
91
  self,
70
92
  method: str,
@@ -73,6 +95,7 @@ class QuantikMindApiClient:
73
95
  json: Any | None = None,
74
96
  params: Mapping[str, Any] | None = None,
75
97
  headers: Mapping[str, str] | None = None,
98
+ timeout: float | tuple[float, float] | None = None,
76
99
  ) -> Any:
77
100
  url = f"{self.base_url}/{path.lstrip('/')}"
78
101
  merged_headers = {"Accept": "application/json"}
@@ -87,15 +110,22 @@ class QuantikMindApiClient:
87
110
  json=json,
88
111
  params=dict(params or {}),
89
112
  headers=merged_headers,
90
- timeout=self.timeout,
113
+ timeout=self.timeout if timeout is None else timeout,
91
114
  )
115
+ except requests.ReadTimeout as exc:
116
+ if path == "/api/v1/functional/select":
117
+ raise NetworkError(
118
+ "Selection read timed out. The backend may still complete the run. "
119
+ "Check existing runs before requesting another selection; this request was not retried."
120
+ ) from exc
121
+ raise NetworkError(self.safe_error(f"Unable to reach Quantik Mind API: {exc}")) from exc
92
122
  except requests.RequestException as exc:
93
- raise NetworkError(f"Unable to reach Quantik Mind API: {exc}") from exc
123
+ raise NetworkError(self.safe_error(f"Unable to reach Quantik Mind API: {exc}")) from exc
94
124
 
95
125
  if response.status_code in {401, 403}:
96
- raise AuthenticationError(_error_message(response, "Authentication failed"))
126
+ raise AuthenticationError(self.safe_error(_error_message(response, "Authentication failed")))
97
127
  if response.status_code >= 400:
98
- raise ApiError(_error_message(response, f"API request failed with {response.status_code}"))
128
+ raise ApiError(self.safe_error(_error_message(response, f"API request failed with {response.status_code}")))
99
129
 
100
130
  if not response.content:
101
131
  return None
@@ -179,14 +209,20 @@ class QuantikMindApiClient:
179
209
  project_id: str,
180
210
  *,
181
211
  changed_files: list[str] | None = None,
212
+ tau: float | None = None,
182
213
  ) -> dict[str, Any]:
183
214
  params: dict[str, Any] = {"project_id": project_id}
184
215
  if changed_files:
185
216
  params["changed_files"] = changed_files
217
+ if tau is not None:
218
+ if not math.isfinite(float(tau)) or not 0.05 <= float(tau) <= 0.95:
219
+ raise ApiError("tau must be a finite number between 0.05 and 0.95")
220
+ params["tau"] = float(tau)
186
221
  payload = self.request(
187
222
  "GET",
188
223
  "/api/v1/functional/select",
189
224
  params=params,
225
+ timeout=(self.timeout, self.selection_read_timeout_seconds),
190
226
  )
191
227
  if not isinstance(payload, dict):
192
228
  raise ApiError("API returned an unexpected functional selection response")
@@ -265,6 +301,12 @@ class QuantikMindApiClient:
265
301
  raise ApiError("API returned an unexpected observability configuration response")
266
302
  return response
267
303
 
304
+ def track_funnel_event(self, event_type: str, metadata: Mapping[str, Any] | None = None) -> None:
305
+ self.request("POST", "/api/v1/funnel/event", json={
306
+ "eventType": event_type, "eventSource": "ci" if os.getenv("CI") else "cli",
307
+ "metadata": dict(metadata or {}),
308
+ })
309
+
268
310
  def get_observability_status(self, project_id: str) -> dict[str, Any]:
269
311
  response = self.request(
270
312
  "GET",
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timezone
4
4
  import json
5
+ import math
5
6
  import os
6
7
  from pathlib import Path
7
8
  import time
@@ -13,7 +14,7 @@ from rich.table import Table
13
14
  import yaml
14
15
 
15
16
  from .api import AgentMetricsEndpointUnavailable, ApiError, AuthenticationError, NetworkError, QuantikMindApiClient
16
- from .agent.prometheus import PrometheusClient, PrometheusError
17
+ from .agent.prometheus import AGENT_METRICS_CONTRACT_VERSION, PrometheusClient, PrometheusError
17
18
  from .config import (
18
19
  DEFAULT_BASE_URL,
19
20
  ConfigError,
@@ -39,6 +40,7 @@ from .subset import (
39
40
  selected_test_selectors,
40
41
  selection_stats,
41
42
  )
43
+ from .telemetry import track_cli_event
42
44
 
43
45
 
44
46
  console = Console(width=120)
@@ -234,6 +236,7 @@ def init(
234
236
  {"api_key": api_key, "auth_mode": _auth_mode_for_api_key(api_key, existing_credentials)},
235
237
  path=credentials_path(),
236
238
  )
239
+ track_cli_event(client, "cli_initialized", {"command": "init"})
237
240
 
238
241
  table = Table(title="Quantik Mind setup")
239
242
  table.add_column("Setting")
@@ -423,12 +426,32 @@ def record_tests(results_dir: Path | None) -> None:
423
426
  is_flag=True,
424
427
  help="Print the complete selection payload as machine-readable JSON.",
425
428
  )
429
+ @click.option("--wait", "wait_for_run", is_flag=True, help="Wait for saved sync/async completion without resubmitting.")
430
+ @click.option("--timeout-seconds", type=click.FloatRange(min=0.1), default=600)
431
+ @click.option("--poll-interval-seconds", type=click.FloatRange(min=0.1), default=3)
432
+ @click.option("--receipt-file", type=click.Path(path_type=Path, dir_okay=False))
433
+ @click.option("--expected-influence", type=click.Choice(["off", "on"]))
434
+ @click.option(
435
+ "--tau",
436
+ type=float,
437
+ default=None,
438
+ help=(
439
+ "Per-run selection breadth (0.05-0.95). Lower is more restrictive; "
440
+ "actual size also depends on the probability distribution and coverage guardrails."
441
+ ),
442
+ )
426
443
  def subset(
427
444
  framework: str | None,
428
445
  internal_ids: bool,
429
446
  changed_files: tuple[str, ...],
430
447
  changed_files_file: Path | None,
431
448
  json_output: bool,
449
+ wait_for_run: bool,
450
+ timeout_seconds: float,
451
+ poll_interval_seconds: float,
452
+ receipt_file: Path | None,
453
+ expected_influence: str | None,
454
+ tau: float | None,
432
455
  ) -> None:
433
456
  """Request or inspect selected test subsets."""
434
457
  try:
@@ -452,16 +475,43 @@ def subset(
452
475
  except ConfigError as exc:
453
476
  raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
454
477
 
455
- client = QuantikMindApiClient.from_config(repo_config, credentials)
478
+ try:
479
+ client = QuantikMindApiClient.from_config(repo_config, credentials)
480
+ except ConfigError as exc:
481
+ raise click.ClickException(str(exc)) from exc
482
+ if tau is not None and (not math.isfinite(tau) or not 0.05 <= tau <= 0.95):
483
+ raise click.BadParameter(
484
+ "must be a finite number between 0.05 and 0.95", param_hint="--tau"
485
+ )
486
+ if wait_for_run or receipt_file or expected_influence:
487
+ from .runs import submit, emit
488
+ explicit = _resolve_subset_changed_files(changed_files=changed_files, changed_files_file=changed_files_file)
489
+ result = submit(client, project_id, explicit, tau=tau, wait=wait_for_run, timeout=timeout_seconds,
490
+ poll_interval=poll_interval_seconds, receipt_file=receipt_file, expected=expected_influence)
491
+ emit(result)
492
+ return
493
+ telemetry_state = load_agent_state()
494
+ is_first_run = not bool(telemetry_state.get("first_selection_completed"))
495
+ if is_first_run:
496
+ track_cli_event(client, "first_run_started", {"command": "subset"})
497
+ started_at = time.monotonic()
456
498
  try:
457
499
  explicit_changed_files = _resolve_subset_changed_files(
458
500
  changed_files=changed_files,
459
501
  changed_files_file=changed_files_file,
460
502
  )
461
- response = client.select_functional_tests(
462
- project_id,
463
- changed_files=explicit_changed_files,
464
- )
503
+ selection_kwargs = {"changed_files": explicit_changed_files}
504
+ if tau is not None:
505
+ selection_kwargs["tau"] = tau
506
+ response = client.select_functional_tests(project_id, **selection_kwargs)
507
+ if "selected" not in response and response.get("status") in {"pending", "queued", "running"} and response.get("run_id"):
508
+ from .runs import RunError, uuid_value, write_json
509
+ run_id = uuid_value(response["run_id"])
510
+ receipt = Path.cwd() / ".qmind" / "receipts" / (run_id + ".json")
511
+ write_json(receipt, {"schema_version": 1, "run_id": run_id, "project_id": project_id, "status": response["status"], "submission_outcome": "known"})
512
+ if json_output:
513
+ click.echo(json.dumps(response, sort_keys=True))
514
+ raise RunError(f"Selection accepted as {run_id}. Receipt: {receipt}. Use qmind runs wait/export; do not resubmit.", 3)
465
515
  selectors = selected_test_selectors(response, use_internal_ids=internal_ids)
466
516
  if json_output:
467
517
  formatted = json.dumps(
@@ -472,12 +522,28 @@ def subset(
472
522
  else:
473
523
  formatted = format_test_selectors(selected_framework, selectors)
474
524
  except AuthenticationError as exc:
525
+ track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "authentication_error"})
475
526
  raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
476
527
  except (NetworkError, ApiError) as exc:
528
+ track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "api_error"})
477
529
  raise click.ClickException(f"Could not select tests: {exc}") from exc
478
530
  except SubsetError as exc:
531
+ track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "response_error"})
479
532
  raise click.ClickException(str(exc)) from exc
480
533
 
534
+ counts = response.get("counts", {}) if isinstance(response, dict) else {}
535
+ event_metadata = {"command": "subset", "run_id": response.get("run_id"),
536
+ "test_count": counts.get("total"), "selected_test_count": counts.get("selected"),
537
+ "skipped_test_count": counts.get("skipped"), "duration_ms": int((time.monotonic()-started_at)*1000)}
538
+ if is_first_run:
539
+ track_cli_event(client, "first_run_completed", event_metadata)
540
+ telemetry_state["first_selection_completed"] = True
541
+ try:
542
+ write_agent_state(telemetry_state)
543
+ except Exception:
544
+ pass
545
+ track_cli_event(client, "test_selection_executed", event_metadata)
546
+
481
547
  stats = selection_stats(response)
482
548
  if stats and not json_output:
483
549
  click.echo(stats, err=True)
@@ -1035,6 +1101,7 @@ def _run_agent_cycle(
1035
1101
  payload = {
1036
1102
  "project_id": project_id,
1037
1103
  "timestamp": timestamp,
1104
+ "contract_version": AGENT_METRICS_CONTRACT_VERSION,
1038
1105
  "metrics": metrics,
1039
1106
  }
1040
1107
  try:
@@ -1668,5 +1735,9 @@ def _display_value(value: object) -> str:
1668
1735
  return "unknown" if value is None else str(value)
1669
1736
 
1670
1737
 
1738
+ from .runs import register as _register_saved_runs
1739
+
1740
+ _register_saved_runs(cli)
1741
+
1671
1742
  if __name__ == "__main__":
1672
1743
  cli()
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
  from typing import Any, Mapping
5
6
 
@@ -23,6 +24,9 @@ def repo_config_path(root: str | Path = ".") -> Path:
23
24
 
24
25
 
25
26
  def credentials_path(home: str | Path | None = None) -> Path:
27
+ override = os.environ.get("QMIND_CREDENTIALS_PATH") if home is None else None
28
+ if override:
29
+ return Path(override).expanduser()
26
30
  base = Path(home) if home is not None else Path.home()
27
31
  return base / CREDENTIALS_DIR / CREDENTIALS_FILE
28
32