diff --git a/cli/src/m2_market/cli.py b/cli/src/m2_market/cli.py index f89742a..7e6e06e 100644 --- a/cli/src/m2_market/cli.py +++ b/cli/src/m2_market/cli.py @@ -6,6 +6,7 @@ import socket import sys import tarfile import tempfile +import time import uuid from pathlib import Path @@ -20,6 +21,7 @@ from m2_market.ledger import InsufficientFunds, LedgerClient from m2_market.output import emit_json, format_price, render_kv, render_table, search_results_table from m2_market.publish import PublishValidationError, publish_bundle, validate_bundle from m2_market.registry import RegistryClient, RegistryError +from m2_market.telemetry import post_install_outcome # Exit-code map (contracts/cli.md). EXIT_OK = 0 # ok / no-op @@ -201,6 +203,7 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str | ref = f"{listing_id}:{socket.gethostname()}:{uuid.uuid4()}" seller = listing.get("seller") or solution.get("seller") amount = price.get("amount") + started = time.monotonic() try: grant = ledger.install(buyer=config.operator_id, seller=seller, amount=amount, ref=ref) @@ -221,6 +224,15 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str | bundle_root = _extract_bundle(bundle_path, tmp_dir / "extracted") except (RegistryError, OSError, tarfile.TarError) as exc: ledger.refund(ref) + post_install_outcome( + config, + listing_id, + seller, + amount, + ref, + "fetch_failed_refunded", + time.monotonic() - started, + ) click.echo( f"m2-market install: bundle fetch/verify failed, refunded: {exc}", err=True, @@ -244,6 +256,15 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str | return except Exception as exc: ledger.refund(ref) + post_install_outcome( + config, + listing_id, + seller, + amount, + ref, + "apply_failed_refunded", + time.monotonic() - started, + ) click.echo( f"m2-market install: apply failed, refunded: {exc}\n" "Remediation: inspect the recipe's post-install checks, fix the " @@ -260,6 +281,16 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str | changeset.changeset_hash, "applied", ) + post_install_outcome( + config, + listing_id, + seller, + amount, + ref, + "applied", + time.monotonic() - started, + grant_id=grant.get("grant_id"), + ) _print_entrypoint_hint(json_output, solution, {"listing_id": listing_id, "noop": False}) diff --git a/cli/src/m2_market/telemetry.py b/cli/src/m2_market/telemetry.py new file mode 100644 index 0000000..f203127 --- /dev/null +++ b/cli/src/m2_market/telemetry.py @@ -0,0 +1,58 @@ +"""Install outcome telemetry write-back (contracts/catalog-index.md §Telemetry write-back). + +Fire-and-forget: writes an episodic memory into the `market:evidence` partition so +listings accrue install evidence. Telemetry must never break an install — any +failure is swallowed after printing one warning line to stderr. +""" + +from __future__ import annotations + +import sys + +import httpx + +from m2_market.config import Config + +PARTITION = "market:evidence" + + +def post_install_outcome( + config: Config, + listing_id: str, + seller: str | None, + amount, + ref: str, + outcome: str, + wall_seconds: float, + grant_id: str | None = None, +) -> None: + """Best-effort write of an install outcome to memory-api. Never raises.""" + try: + with httpx.Client( + base_url=config.memory_api_url, + headers={"X-API-Key": config.memory_api_key}, + timeout=10.0, + ) as client: + response = client.post( + "/memory/store", + json={ + "content": f"install {outcome}: {listing_id}", + "agent_id": PARTITION, + "memory_type": "episodic", + "importance": 0.8, + "source": "m2-market-cli", + "metadata": { + "listing_id": listing_id, + "buyer": config.operator_id, + "seller": seller, + "amount": amount, + "outcome": outcome, + "ref": ref, + "wall_seconds": wall_seconds, + "grant_id": grant_id, + }, + }, + ) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 - telemetry must never break an install + print(f"m2-market: telemetry write-back failed (ignored): {exc}", file=sys.stderr) diff --git a/cli/tests/test_cli_install.py b/cli/tests/test_cli_install.py index 2156aa1..da6f7d5 100644 --- a/cli/tests/test_cli_install.py +++ b/cli/tests/test_cli_install.py @@ -85,6 +85,7 @@ class FakeState: self.refund_calls = 0 self.license_exists = False self.insufficient_funds = False + self.telemetry_calls = [] def _b64_json(payload: dict) -> str: @@ -164,6 +165,12 @@ def make_handler(state: FakeState): }, ) + if host == "catalog.test" and path == "/memory/store": + body = json.loads(request.content) + assert body["agent_id"] == "market:evidence", body + state.telemetry_calls.append(body) + return httpx.Response(200, json={"id": "mem-telemetry-1"}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") return handler @@ -255,6 +262,10 @@ def test_funded_install_happy_path(fake_backend, config_env, monkeypatch): assert calls.plan_calls == 1 assert calls.apply_calls == 1 assert calls.verify_calls == 1 + assert len(fake_backend.telemetry_calls) == 1 + telemetry = fake_backend.telemetry_calls[0] + assert telemetry["metadata"]["outcome"] == "applied" + assert telemetry["metadata"]["listing_id"] == LISTING_ID def test_insufficient_funds_no_adapter_call(fake_backend, config_env, monkeypatch):