Compare commits
1 commit
master
...
phase0/con
| Author | SHA1 | Date | |
|---|---|---|---|
| cd43dac89f |
112 changed files with 1644 additions and 8152 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,10 +1 @@
|
||||||
.m2herd/
|
.m2herd/
|
||||||
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
uv.lock
|
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
web/frontend/node_modules/
|
|
||||||
web/frontend/dist/
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"feature_directory":"specs/002-market-web"}
|
{"feature_directory":"specs/001-market-first-wedge"}
|
||||||
|
|
|
||||||
BIN
cli/src/m2_market/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/catalog.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/catalog.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/cli.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/cli.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/config.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/ledger.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/ledger.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/output.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/output.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/registry.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/registry.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/apply/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/apply/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/apply/__pycache__/base.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/apply/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/apply/__pycache__/local.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/apply/__pycache__/local.cpython-312.pyc
Normal file
Binary file not shown.
BIN
cli/src/m2_market/apply/__pycache__/m2core_sync.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/apply/__pycache__/m2core_sync.cpython-312.pyc
Normal file
Binary file not shown.
|
|
@ -151,11 +151,6 @@ class LocalApplyAdapter:
|
||||||
if not dest.is_absolute():
|
if not dest.is_absolute():
|
||||||
dest = home / dest
|
dest = home / dest
|
||||||
dest = Path(os.path.normpath(str(dest)))
|
dest = Path(os.path.normpath(str(dest)))
|
||||||
# Resolve symlinked/bind-mounted ancestors so the containment check
|
|
||||||
# compares real paths: on m2o desktops $HOME (/home/developer) is a
|
|
||||||
# mount of /agent_home/home/developer, and Path.home().resolve()
|
|
||||||
# already followed it — an unresolved dest would false-positive here.
|
|
||||||
dest = dest.resolve()
|
|
||||||
|
|
||||||
if dest != home and home not in dest.parents:
|
if dest != home and home not in dest.parents:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,22 @@
|
||||||
"""Client for the `market:catalog` semantic index (memory-api).
|
"""Client for the `market:catalog` semantic index (memory-api).
|
||||||
|
|
||||||
Query contract: specs/001-market-first-wedge/contracts/catalog-index.md
|
Query contract: specs/001-market-first-wedge/contracts/catalog-index.md
|
||||||
|
|
||||||
Adapted 2026-07-02 to the REAL memory-api surface (agent.memory.system):
|
|
||||||
`POST /memory/search` with `{query, agent_id, limit}` — the partition is the
|
|
||||||
`agent_id` namespace, and each result carries the listing in `metadata`.
|
|
||||||
|
|
||||||
DEVIATION from catalog-index.md (recorded): memory-api's server-side filter is a
|
|
||||||
scalar `tenant_id`, but listings carry a `tenant_visibility` ARRAY. Until the
|
|
||||||
memory-api grows array filters (fedlearn T13 follow-up), the visibility filter
|
|
||||||
`tenant_visibility ⊇ {caller tenant | "*"}` is enforced HERE, in the one shared
|
|
||||||
reader client that every surface (CLI, Store, Scout) goes through — never in
|
|
||||||
per-surface code.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# Overridable so a full catalog rebuild can land in a fresh partition and readers
|
PARTITION = "market:catalog"
|
||||||
# switch by config (constitution II rebuildability; see docs/runbook.md).
|
|
||||||
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogClient:
|
class CatalogClient:
|
||||||
"""Tenant-scoped semantic search over the market:catalog partition."""
|
"""Tenant-scoped semantic search over the market:catalog partition.
|
||||||
|
|
||||||
|
`tenant` is always sent on every request — the server enforces the
|
||||||
|
`tenant_visibility ⊇ {caller tenant | "*"}` filter (FR-007); this client
|
||||||
|
never relies on filtering results client-side.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -34,8 +24,8 @@ class CatalogClient:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
tenant: str,
|
tenant: str,
|
||||||
*,
|
*,
|
||||||
search_path: str = "/memory/search",
|
search_path: str = "/search",
|
||||||
timeout: float = 30.0,
|
timeout: float = 10.0,
|
||||||
transport: httpx.BaseTransport | None = None,
|
transport: httpx.BaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.tenant = tenant
|
self.tenant = tenant
|
||||||
|
|
@ -56,93 +46,95 @@ class CatalogClient:
|
||||||
def __exit__(self, *exc_info: object) -> None:
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def _visible(self, listing: dict) -> bool:
|
|
||||||
visibility = listing.get("tenant_visibility") or []
|
|
||||||
return "*" in visibility or self.tenant in visibility
|
|
||||||
|
|
||||||
def search(self, query_text: str, limit: int = 10) -> list[dict]:
|
def search(self, query_text: str, limit: int = 10) -> list[dict]:
|
||||||
"""Semantic query against market:catalog, visibility-filtered to `self.tenant`.
|
"""Semantic query against market:catalog, filtered server-side to `self.tenant`.
|
||||||
|
|
||||||
Returns a list of `{listing_id, score, listing}` ordered by hybrid score.
|
Returns a list of `{listing_id, score, listing}` ordered by hybrid score.
|
||||||
"""
|
"""
|
||||||
response = self._client.post(
|
response = self._client.post(
|
||||||
self.search_path,
|
self.search_path,
|
||||||
json={
|
json={
|
||||||
"query": query_text,
|
"query_text": query_text,
|
||||||
"agent_id": PARTITION,
|
"partition": PARTITION,
|
||||||
"memory_types": ["semantic"],
|
"tenant": self.tenant,
|
||||||
# Overfetch so the visibility filter doesn't starve the result set.
|
"limit": limit,
|
||||||
"limit": max(limit * 3, limit),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
items = []
|
return self._extract_items(response.json())
|
||||||
for result in response.json().get("results", []):
|
|
||||||
meta = result.get("metadata") or {}
|
|
||||||
listing = meta.get("listing")
|
|
||||||
listing_id = meta.get("listing_id")
|
|
||||||
if not listing or not listing_id:
|
|
||||||
continue
|
|
||||||
if not self._visible(listing):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
|
|
||||||
)
|
|
||||||
if len(items) >= limit:
|
|
||||||
break
|
|
||||||
return items
|
|
||||||
|
|
||||||
def get(self, listing_id: str) -> dict | None:
|
def get(self, listing_id: str) -> dict | None:
|
||||||
"""Fetch one listing's payload by id, or None if not found/visible."""
|
"""Fetch one listing's payload by id, or None if not found/visible.
|
||||||
for item in self.search(listing_id, limit=10):
|
|
||||||
|
Convenience wrapper: searches by `listing_id` (still tenant-scoped —
|
||||||
|
the search endpoint is the only server-side-filtered fallback) and
|
||||||
|
picks the matching item out of the results.
|
||||||
|
"""
|
||||||
|
for item in self.search(listing_id, limit=1):
|
||||||
if item.get("listing_id") == listing_id:
|
if item.get("listing_id") == listing_id:
|
||||||
return item.get("listing")
|
return item.get("listing")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_items(payload: object) -> list[dict]:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
items = payload.get("items")
|
||||||
|
if isinstance(items, list):
|
||||||
|
return items
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Inline verification against the real response shape.
|
# Inline verification: assert the request body sent to memory-api carries
|
||||||
|
# `partition` and `tenant` (server-side filter contract, catalog-index.md).
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
def _handler(request: httpx.Request) -> httpx.Response:
|
def _handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["url"] = str(request.url)
|
||||||
|
captured["headers"] = dict(request.headers)
|
||||||
|
captured["body"] = httpx.Request("POST", request.url, content=request.content).content
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
body = _json.loads(request.content)
|
body = _json.loads(request.content)
|
||||||
assert body["agent_id"] == PARTITION, body
|
captured["json"] = body
|
||||||
assert body["query"], body
|
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={
|
||||||
"results": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "mem-1",
|
"listing_id": "sol_pdf_export_v1",
|
||||||
"content": "PDF Export ...",
|
|
||||||
"score": 0.87,
|
"score": 0.87,
|
||||||
"metadata": {
|
"listing": {"name": "PDF Export", "tenant_visibility": ["*"]},
|
||||||
"listing_id": "lst_pdf-export",
|
}
|
||||||
"listing": {"name": "PDF Export", "tenant_visibility": ["*"]},
|
]
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "mem-2",
|
|
||||||
"content": "tenant-only thing",
|
|
||||||
"score": 0.5,
|
|
||||||
"metadata": {
|
|
||||||
"listing_id": "lst_private",
|
|
||||||
"listing": {"name": "Private", "tenant_visibility": ["other-tenant"]},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"routing": {},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
client = CatalogClient(
|
client = CatalogClient(
|
||||||
base_url="https://memory.machinemachine.ai",
|
base_url="https://memory.machinemachine.ai",
|
||||||
api_key="mem_test_key",
|
api_key="mem_test_key",
|
||||||
tenant="m2-core",
|
tenant="machine-machine",
|
||||||
transport=httpx.MockTransport(_handler),
|
transport=httpx.MockTransport(_handler),
|
||||||
)
|
)
|
||||||
|
|
||||||
results = client.search("branded pdf report", limit=5)
|
results = client.search("branded pdf report", limit=5)
|
||||||
assert [r["listing_id"] for r in results] == ["lst_pdf-export"], results # filtered
|
|
||||||
assert client.get("lst_pdf-export") == {"name": "PDF Export", "tenant_visibility": ["*"]}
|
assert captured["json"]["partition"] == PARTITION, captured["json"]
|
||||||
assert client.get("lst_private") is None # invisible to this tenant
|
assert captured["json"]["tenant"] == "machine-machine", captured["json"]
|
||||||
print("OK:", results)
|
assert captured["json"]["query_text"] == "branded pdf report"
|
||||||
|
assert captured["json"]["limit"] == 5
|
||||||
|
assert captured["headers"]["x-api-key"] == "mem_test_key"
|
||||||
|
assert results[0]["listing_id"] == "sol_pdf_export_v1"
|
||||||
|
|
||||||
|
fetched = client.get("sol_pdf_export_v1")
|
||||||
|
assert fetched == {"name": "PDF Export", "tenant_visibility": ["*"]}
|
||||||
|
|
||||||
|
missing = client.get("does_not_exist")
|
||||||
|
assert missing is None
|
||||||
|
|
||||||
|
print("OK: request body ->", captured["json"])
|
||||||
|
print("OK: search() ->", results)
|
||||||
|
print("OK: get('sol_pdf_export_v1') ->", fetched)
|
||||||
|
print("OK: get('does_not_exist') ->", missing)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import socket
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -19,9 +18,7 @@ from m2_market.catalog import CatalogClient
|
||||||
from m2_market.config import Config, ConfigError, load_config
|
from m2_market.config import Config, ConfigError, load_config
|
||||||
from m2_market.ledger import InsufficientFunds, LedgerClient
|
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.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.registry import RegistryClient, RegistryError
|
||||||
from m2_market.telemetry import post_install_outcome
|
|
||||||
|
|
||||||
# Exit-code map (contracts/cli.md).
|
# Exit-code map (contracts/cli.md).
|
||||||
EXIT_OK = 0 # ok / no-op
|
EXIT_OK = 0 # ok / no-op
|
||||||
|
|
@ -203,7 +200,6 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
|
||||||
ref = f"{listing_id}:{socket.gethostname()}:{uuid.uuid4()}"
|
ref = f"{listing_id}:{socket.gethostname()}:{uuid.uuid4()}"
|
||||||
seller = listing.get("seller") or solution.get("seller")
|
seller = listing.get("seller") or solution.get("seller")
|
||||||
amount = price.get("amount")
|
amount = price.get("amount")
|
||||||
started = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
grant = ledger.install(buyer=config.operator_id, seller=seller, amount=amount, ref=ref)
|
grant = ledger.install(buyer=config.operator_id, seller=seller, amount=amount, ref=ref)
|
||||||
|
|
@ -224,15 +220,6 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
|
||||||
bundle_root = _extract_bundle(bundle_path, tmp_dir / "extracted")
|
bundle_root = _extract_bundle(bundle_path, tmp_dir / "extracted")
|
||||||
except (RegistryError, OSError, tarfile.TarError) as exc:
|
except (RegistryError, OSError, tarfile.TarError) as exc:
|
||||||
ledger.refund(ref)
|
ledger.refund(ref)
|
||||||
post_install_outcome(
|
|
||||||
config,
|
|
||||||
listing_id,
|
|
||||||
seller,
|
|
||||||
amount,
|
|
||||||
ref,
|
|
||||||
"fetch_failed_refunded",
|
|
||||||
time.monotonic() - started,
|
|
||||||
)
|
|
||||||
click.echo(
|
click.echo(
|
||||||
f"m2-market install: bundle fetch/verify failed, refunded: {exc}",
|
f"m2-market install: bundle fetch/verify failed, refunded: {exc}",
|
||||||
err=True,
|
err=True,
|
||||||
|
|
@ -256,15 +243,6 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
ledger.refund(ref)
|
ledger.refund(ref)
|
||||||
post_install_outcome(
|
|
||||||
config,
|
|
||||||
listing_id,
|
|
||||||
seller,
|
|
||||||
amount,
|
|
||||||
ref,
|
|
||||||
"apply_failed_refunded",
|
|
||||||
time.monotonic() - started,
|
|
||||||
)
|
|
||||||
click.echo(
|
click.echo(
|
||||||
f"m2-market install: apply failed, refunded: {exc}\n"
|
f"m2-market install: apply failed, refunded: {exc}\n"
|
||||||
"Remediation: inspect the recipe's post-install checks, fix the "
|
"Remediation: inspect the recipe's post-install checks, fix the "
|
||||||
|
|
@ -281,16 +259,6 @@ def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str |
|
||||||
changeset.changeset_hash,
|
changeset.changeset_hash,
|
||||||
"applied",
|
"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})
|
_print_entrypoint_hint(json_output, solution, {"listing_id": listing_id, "noop": False})
|
||||||
|
|
||||||
|
|
@ -336,25 +304,8 @@ def wallet(ctx: click.Context, operator: str | None) -> None:
|
||||||
@click.argument("bundle_dir")
|
@click.argument("bundle_dir")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def publish(ctx: click.Context, bundle_dir: str) -> None:
|
def publish(ctx: click.Context, bundle_dir: str) -> None:
|
||||||
"""Validate a bundle and open a listing PR on the registry (Story 2 step 1-2)."""
|
"""Validate a bundle and open a listing PR on the registry."""
|
||||||
config: Config = ctx.obj["config"]
|
_not_implemented(ctx, "publish")
|
||||||
json_output = ctx.obj["json"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
solution, listing = validate_bundle(Path(bundle_dir))
|
|
||||||
except PublishValidationError as exc:
|
|
||||||
for line in exc.errors:
|
|
||||||
click.echo(f"m2-market publish: {line}", err=True)
|
|
||||||
ctx.exit(EXIT_VALIDATION)
|
|
||||||
return
|
|
||||||
|
|
||||||
with RegistryClient(config.forgejo_url, config.forgejo_token) as registry:
|
|
||||||
pr_url = publish_bundle(Path(bundle_dir), solution, listing, registry)
|
|
||||||
|
|
||||||
if json_output:
|
|
||||||
emit_json({"status": "ok", "pr_url": pr_url})
|
|
||||||
else:
|
|
||||||
click.echo(pr_url)
|
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
"""Publish command implementation (contracts/cli.md publish, contracts/registry-layout.md).
|
|
||||||
|
|
||||||
Validates a bundle against the frozen schemas (schemas/*.json), packages
|
|
||||||
payload/+recipe.yaml+solution.json into a release tarball, and runs the
|
|
||||||
publish protocol against the registry: branch -> commit -> release -> PR.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator
|
|
||||||
|
|
||||||
from m2_market.registry import RegistryClient
|
|
||||||
|
|
||||||
|
|
||||||
class PublishValidationError(Exception):
|
|
||||||
"""Raised when the bundle fails schema validation (contracts/cli.md exit 5)."""
|
|
||||||
|
|
||||||
def __init__(self, errors: list[str]):
|
|
||||||
super().__init__("; ".join(errors))
|
|
||||||
self.errors = errors
|
|
||||||
|
|
||||||
|
|
||||||
def schemas_dir() -> Path:
|
|
||||||
"""Resolve the frozen schemas dir, honoring the M2_MARKET_SCHEMAS_DIR override."""
|
|
||||||
override = os.environ.get("M2_MARKET_SCHEMAS_DIR")
|
|
||||||
if override:
|
|
||||||
return Path(override)
|
|
||||||
return Path(__file__).resolve().parents[3] / "schemas"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict:
|
|
||||||
with path.open(encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(instance: dict, schema_path: Path, label: str) -> list[str]:
|
|
||||||
schema = _load_json(schema_path)
|
|
||||||
validator = Draft202012Validator(schema)
|
|
||||||
errors = []
|
|
||||||
for err in sorted(validator.iter_errors(instance), key=lambda e: list(map(str, e.path))):
|
|
||||||
loc = "/".join(str(p) for p in err.path) or "<root>"
|
|
||||||
errors.append(f"{label}: {loc}: {err.message}")
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def validate_bundle(bundle_dir: Path) -> tuple[dict, dict]:
|
|
||||||
"""Validate BUNDLE_DIR/solution.json + listing.json against the frozen schemas.
|
|
||||||
|
|
||||||
Returns (solution, listing) on success; raises PublishValidationError
|
|
||||||
(one message per violation, including tenant-firewall rules encoded in
|
|
||||||
solution.schema.json's if/else) otherwise.
|
|
||||||
"""
|
|
||||||
solution_path = bundle_dir / "solution.json"
|
|
||||||
listing_path = bundle_dir / "listing.json"
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
solution: dict | None = None
|
|
||||||
listing: dict | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
solution = _load_json(solution_path)
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
errors.append(f"solution.json: {exc}")
|
|
||||||
try:
|
|
||||||
listing = _load_json(listing_path)
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
errors.append(f"listing.json: {exc}")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise PublishValidationError(errors)
|
|
||||||
|
|
||||||
schemas = schemas_dir()
|
|
||||||
errors += _validate(solution, schemas / "solution.schema.json", "solution.json")
|
|
||||||
errors += _validate(listing, schemas / "listing.schema.json", "listing.json")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise PublishValidationError(errors)
|
|
||||||
|
|
||||||
return solution, listing
|
|
||||||
|
|
||||||
|
|
||||||
def build_bundle_tarball(bundle_dir: Path, solution: dict, dest_path: Path) -> str:
|
|
||||||
"""Tar payload/+recipe.yaml+solution.json into dest_path; return its sha256:<hex> hash."""
|
|
||||||
payload_dir = bundle_dir / "payload"
|
|
||||||
recipe_path = bundle_dir / "recipe.yaml"
|
|
||||||
|
|
||||||
with tarfile.open(dest_path, "w:gz") as tar:
|
|
||||||
if payload_dir.is_dir():
|
|
||||||
tar.add(payload_dir, arcname="payload")
|
|
||||||
if recipe_path.is_file():
|
|
||||||
tar.add(recipe_path, arcname="recipe.yaml")
|
|
||||||
solution_bytes = json.dumps(solution, indent=2, sort_keys=True).encode("utf-8")
|
|
||||||
info = tarfile.TarInfo(name="solution.json")
|
|
||||||
info.size = len(solution_bytes)
|
|
||||||
tar.addfile(info, io.BytesIO(solution_bytes))
|
|
||||||
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with dest_path.open("rb") as fh:
|
|
||||||
for chunk in iter(lambda: fh.read(65536), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return f"sha256:{digest.hexdigest()}"
|
|
||||||
|
|
||||||
|
|
||||||
def render_pr_body(solution: dict, listing: dict) -> str:
|
|
||||||
"""PR template body (registry-layout.md publish protocol step 2)."""
|
|
||||||
permissions = solution.get("permissions") or []
|
|
||||||
permissions_lines = "\n".join(f"- `{perm}`" for perm in permissions) or "- (none)"
|
|
||||||
price = solution.get("price") or {}
|
|
||||||
tenant_scope = solution.get("tenant_scope", "-")
|
|
||||||
|
|
||||||
body = (
|
|
||||||
f"## Evidence summary\n{listing.get('evidence_summary', '-')}\n\n"
|
|
||||||
f"## Price\n{price.get('amount', '-')} {price.get('currency', '-')}\n\n"
|
|
||||||
f"## Permissions\n{permissions_lines}\n\n"
|
|
||||||
f"## Rollback\nRe-run `m2-market install` after uninstall; the apply adapter "
|
|
||||||
f"restores pre-install backups (contracts/apply-adapter.md).\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
if tenant_scope != "m2-core":
|
|
||||||
owner_initiated = bool(solution.get("owner_initiated"))
|
|
||||||
double_scrubbed = bool((solution.get("scrub_status") or {}).get("double_scrubbed"))
|
|
||||||
body += (
|
|
||||||
"\n## Tenant-derived attestations\n"
|
|
||||||
f"- [{'x' if owner_initiated else ' '}] owner_initiated\n"
|
|
||||||
f"- [{'x' if double_scrubbed else ' '}] double_scrubbed\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def publish_bundle(bundle_dir: Path, solution: dict, listing: dict, registry: RegistryClient) -> str:
|
|
||||||
"""Run the publish protocol (branch -> commit -> release -> PR); returns the PR URL."""
|
|
||||||
listing_id = listing["listing_id"]
|
|
||||||
branch = f"listing/{listing_id}"
|
|
||||||
install_ref = listing["install_ref"]
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="m2-market-publish-") as tmp_dir_str:
|
|
||||||
tarball_path = Path(tmp_dir_str) / f"{install_ref}.tar.gz"
|
|
||||||
content_hash = build_bundle_tarball(bundle_dir, solution, tarball_path)
|
|
||||||
|
|
||||||
solution = dict(solution)
|
|
||||||
solution["content_hash"] = content_hash
|
|
||||||
|
|
||||||
registry.create_branch(branch)
|
|
||||||
registry.create_file(
|
|
||||||
f"listings/{listing_id}/listing.json",
|
|
||||||
json.dumps(listing, indent=2, sort_keys=True).encode("utf-8") + b"\n",
|
|
||||||
message=f"listing({listing_id}): add listing.json",
|
|
||||||
branch=branch,
|
|
||||||
)
|
|
||||||
registry.create_file(
|
|
||||||
f"listings/{listing_id}/solution.json",
|
|
||||||
json.dumps(solution, indent=2, sort_keys=True).encode("utf-8") + b"\n",
|
|
||||||
message=f"listing({listing_id}): add solution.json",
|
|
||||||
branch=branch,
|
|
||||||
)
|
|
||||||
|
|
||||||
release = registry.create_release(
|
|
||||||
tag_name=install_ref,
|
|
||||||
target_commitish=branch,
|
|
||||||
name=install_ref,
|
|
||||||
body=f"Bundle release for {listing_id}",
|
|
||||||
)
|
|
||||||
registry.upload_release_asset(release["id"], tarball_path.name, tarball_path.read_bytes())
|
|
||||||
|
|
||||||
pr = registry.create_pull_request(
|
|
||||||
title=f"listing: {listing.get('name', listing_id)} ({listing_id})",
|
|
||||||
body=render_pr_body(solution, listing),
|
|
||||||
head=branch,
|
|
||||||
base="main",
|
|
||||||
)
|
|
||||||
|
|
||||||
return pr["html_url"]
|
|
||||||
|
|
@ -84,56 +84,6 @@ class RegistryClient:
|
||||||
|
|
||||||
return dest_path
|
return dest_path
|
||||||
|
|
||||||
def create_branch(self, new_branch: str, old_branch: str = "main") -> dict:
|
|
||||||
resp = self._client.post(
|
|
||||||
self._repo_api("/branches"),
|
|
||||||
json={"new_branch_name": new_branch, "old_branch_name": old_branch},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def create_file(self, repo_path: str, content: bytes, message: str, branch: str) -> dict:
|
|
||||||
resp = self._client.post(
|
|
||||||
self._repo_api(f"/contents/{repo_path}"),
|
|
||||||
json={
|
|
||||||
"content": base64.b64encode(content).decode("ascii"),
|
|
||||||
"message": message,
|
|
||||||
"branch": branch,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def create_release(self, tag_name: str, target_commitish: str, name: str, body: str) -> dict:
|
|
||||||
resp = self._client.post(
|
|
||||||
self._repo_api("/releases"),
|
|
||||||
json={
|
|
||||||
"tag_name": tag_name,
|
|
||||||
"target_commitish": target_commitish,
|
|
||||||
"name": name,
|
|
||||||
"body": body,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def upload_release_asset(self, release_id: int, filename: str, content: bytes) -> dict:
|
|
||||||
resp = self._client.post(
|
|
||||||
self._repo_api(f"/releases/{release_id}/assets"),
|
|
||||||
params={"name": filename},
|
|
||||||
files={"attachment": (filename, content, "application/gzip")},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def create_pull_request(self, title: str, body: str, head: str, base: str = "main") -> dict:
|
|
||||||
resp = self._client.post(
|
|
||||||
self._repo_api("/pulls"),
|
|
||||||
json={"title": title, "body": body, "head": head, "base": base},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_bundle(path: str | Path, content_hash: str) -> bool:
|
def verify_bundle(path: str | Path, content_hash: str) -> bool:
|
||||||
algo, _, expected = content_hash.partition(":")
|
algo, _, expected = content_hash.partition(":")
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
"""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)
|
|
||||||
Binary file not shown.
|
|
@ -85,7 +85,6 @@ class FakeState:
|
||||||
self.refund_calls = 0
|
self.refund_calls = 0
|
||||||
self.license_exists = False
|
self.license_exists = False
|
||||||
self.insufficient_funds = False
|
self.insufficient_funds = False
|
||||||
self.telemetry_calls = []
|
|
||||||
|
|
||||||
|
|
||||||
def _b64_json(payload: dict) -> str:
|
def _b64_json(payload: dict) -> str:
|
||||||
|
|
@ -147,29 +146,11 @@ def make_handler(state: FakeState):
|
||||||
if path == "/download/bundle.tar.gz":
|
if path == "/download/bundle.tar.gz":
|
||||||
return httpx.Response(200, content=BUNDLE_BYTES)
|
return httpx.Response(200, content=BUNDLE_BYTES)
|
||||||
|
|
||||||
if host == "catalog.test" and path == "/memory/search":
|
if host == "catalog.test" and path == "/search":
|
||||||
body = json.loads(request.content)
|
body = json.loads(request.content)
|
||||||
assert body["agent_id"] == "market:catalog", body
|
if body["query_text"] == LISTING_ID:
|
||||||
score = 0.9 if body["query"] == LISTING_ID else 0.8
|
return httpx.Response(200, json={"items": [{"listing_id": LISTING_ID, "score": 0.9, "listing": LISTING}]})
|
||||||
return httpx.Response(
|
return httpx.Response(200, json={"items": [{"listing_id": LISTING_ID, "score": 0.8, "listing": LISTING}]})
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": "mem-1",
|
|
||||||
"score": score,
|
|
||||||
"metadata": {"listing_id": LISTING_ID, "listing": LISTING},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"routing": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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}")
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
|
|
||||||
|
|
@ -262,10 +243,6 @@ def test_funded_install_happy_path(fake_backend, config_env, monkeypatch):
|
||||||
assert calls.plan_calls == 1
|
assert calls.plan_calls == 1
|
||||||
assert calls.apply_calls == 1
|
assert calls.apply_calls == 1
|
||||||
assert calls.verify_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):
|
def test_insufficient_funds_no_adapter_call(fake_backend, config_env, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
"""T026 — CLI integration tests: REAL ledger (uvicorn on an ephemeral port, tmp sqlite),
|
|
||||||
fixture catalog/registry, REAL temp bundle applied by the local adapter into a temp HOME.
|
|
||||||
|
|
||||||
quickstart.md Scenarios B/C at test scale: funded install end-to-end, insufficient funds,
|
|
||||||
idempotent re-run, apply-failure refund + rollback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import tarfile
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
import uvicorn
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
SERVICE_KEY = "svc-test-key"
|
|
||||||
ADMIN_KEY = "adm-test-key"
|
|
||||||
LISTING_ID = "lst_mm_pdf_report"
|
|
||||||
PRICE = 40
|
|
||||||
|
|
||||||
|
|
||||||
# --- real ledger server -------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def ledger_url(tmp_path_factory, monkeypatch):
|
|
||||||
db = tmp_path_factory.mktemp("ledger") / "ledger.db"
|
|
||||||
monkeypatch.setenv("LEDGER_DB_PATH", str(db))
|
|
||||||
monkeypatch.setenv("LEDGER_SERVICE_KEYS", SERVICE_KEY)
|
|
||||||
monkeypatch.setenv("LEDGER_ADMIN_KEYS", ADMIN_KEY)
|
|
||||||
|
|
||||||
# Import AFTER env is set so module-level defaults read the test values.
|
|
||||||
from m2_ledger.api import app # noqa: PLC0415
|
|
||||||
|
|
||||||
with socket.socket() as s:
|
|
||||||
s.bind(("127.0.0.1", 0))
|
|
||||||
port = s.getsockname()[1]
|
|
||||||
|
|
||||||
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
|
|
||||||
thread = threading.Thread(target=server.run, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
url = f"http://127.0.0.1:{port}"
|
|
||||||
for _ in range(100):
|
|
||||||
try:
|
|
||||||
if httpx.get(url + "/health", timeout=1).status_code == 200:
|
|
||||||
break
|
|
||||||
except httpx.HTTPError:
|
|
||||||
time.sleep(0.05)
|
|
||||||
else:
|
|
||||||
pytest.fail("ledger server did not come up")
|
|
||||||
|
|
||||||
yield url
|
|
||||||
|
|
||||||
server.should_exit = True
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def _ledger_get(url: str, path: str, key: str = SERVICE_KEY) -> dict:
|
|
||||||
r = httpx.get(url + path, headers={"X-API-Key": key})
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _grant(url: str, operator: str, amount: int = 100) -> None:
|
|
||||||
httpx.post(
|
|
||||||
url + "/grant/starter",
|
|
||||||
json={"operator_id": operator, "amount": amount},
|
|
||||||
headers={"X-API-Key": ADMIN_KEY},
|
|
||||||
).raise_for_status()
|
|
||||||
|
|
||||||
|
|
||||||
def _tx_count(url: str) -> int:
|
|
||||||
return len(_ledger_get(url, "/tx")["transactions"])
|
|
||||||
|
|
||||||
|
|
||||||
# --- temp bundle + fixtures ---------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _build_bundle(root: Path, failing_check: bool = False) -> tuple[Path, str]:
|
|
||||||
"""Real bundle tarball; returns (tar_path, content_hash)."""
|
|
||||||
src = root / "bundle-src"
|
|
||||||
(src / "payload").mkdir(parents=True)
|
|
||||||
(src / "payload" / "hello.txt").write_text("hello from m2-market test bundle\n")
|
|
||||||
check_cmd = "false" if failing_check else "test -f ~/m2-market-test/hello.txt"
|
|
||||||
(src / "recipe.yaml").write_text(
|
|
||||||
"targets:\n"
|
|
||||||
" - src: hello.txt\n"
|
|
||||||
" dest: ~/m2-market-test/hello.txt\n"
|
|
||||||
"checks:\n"
|
|
||||||
f" - cmd: {check_cmd}\n"
|
|
||||||
" expect_exit: 0\n"
|
|
||||||
"entrypoint: cat ~/m2-market-test/hello.txt\n"
|
|
||||||
)
|
|
||||||
(src / "solution.json").write_text(json.dumps({"solution_id": "sol_test"}))
|
|
||||||
tar_path = root / "bundle.tar.gz"
|
|
||||||
with tarfile.open(tar_path, "w:gz") as tar:
|
|
||||||
tar.add(src, arcname="bundle")
|
|
||||||
digest = hashlib.sha256(tar_path.read_bytes()).hexdigest()
|
|
||||||
return tar_path, f"sha256:{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def env(tmp_path, monkeypatch, ledger_url):
|
|
||||||
"""Isolated HOME/state/config + fixture catalog/registry pointed at the real ledger."""
|
|
||||||
home = tmp_path / "home"
|
|
||||||
home.mkdir()
|
|
||||||
monkeypatch.setenv("HOME", str(home))
|
|
||||||
monkeypatch.setenv("M2_MARKET_STATE", str(tmp_path / "state.json"))
|
|
||||||
|
|
||||||
bundle_path, content_hash = _build_bundle(tmp_path)
|
|
||||||
|
|
||||||
config = tmp_path / "config.toml"
|
|
||||||
config.write_text(
|
|
||||||
f'operator_id = "m2bd"\n'
|
|
||||||
f'tenant = "m2-core"\n'
|
|
||||||
f'ledger_url = "{ledger_url}"\n'
|
|
||||||
f'ledger_api_key = "{SERVICE_KEY}"\n'
|
|
||||||
f'memory_api_url = "http://catalog.invalid"\n'
|
|
||||||
f'memory_api_key = "k"\n'
|
|
||||||
f'forgejo_url = "http://registry.invalid"\n'
|
|
||||||
f'forgejo_token = "t"\n'
|
|
||||||
f'apply_adapter = "local"\n'
|
|
||||||
)
|
|
||||||
monkeypatch.setenv("M2_MARKET_CONFIG", str(config))
|
|
||||||
|
|
||||||
listing = {
|
|
||||||
"listing_id": LISTING_ID,
|
|
||||||
"name": "MM PDF Report",
|
|
||||||
"summary": "branded pdf reports",
|
|
||||||
"price": {"amount": PRICE, "currency": "m2cr", "model": "fixed"},
|
|
||||||
"seller": "sdjs-operator",
|
|
||||||
"solution_version": "1.0.0",
|
|
||||||
"install_ref": f"{LISTING_ID}-v1.0.0",
|
|
||||||
}
|
|
||||||
solution = {
|
|
||||||
"solution_id": "sol_test",
|
|
||||||
"content_hash": content_hash,
|
|
||||||
"permissions": ["write ~/m2-market-test"],
|
|
||||||
"deployment": {"entrypoint": "cat ~/m2-market-test/hello.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
from m2_market import cli as cli_mod # noqa: PLC0415
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli_mod.CatalogClient, "get", lambda self, lid: dict(listing) if lid == LISTING_ID else None)
|
|
||||||
monkeypatch.setattr(cli_mod.RegistryClient, "get_solution", lambda self, lid: dict(solution))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
cli_mod.RegistryClient,
|
|
||||||
"download_bundle",
|
|
||||||
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bundle_path, Path(dest) / "bundle.tar.gz"))[1] and Path(dest) / "bundle.tar.gz",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"home": home,
|
|
||||||
"ledger_url": ledger_url,
|
|
||||||
"bundle_path": bundle_path,
|
|
||||||
"listing": listing,
|
|
||||||
"solution": solution,
|
|
||||||
"tmp": tmp_path,
|
|
||||||
"monkeypatch": monkeypatch,
|
|
||||||
"cli_mod": cli_mod,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _run(args, **kwargs):
|
|
||||||
from m2_market.cli import main # noqa: PLC0415
|
|
||||||
|
|
||||||
return CliRunner().invoke(main, args, catch_exceptions=False, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# --- scenarios ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_funded_install_end_to_end(env):
|
|
||||||
_grant(env["ledger_url"], "m2bd", 100)
|
|
||||||
|
|
||||||
result = _run(["install", LISTING_ID, "--yes"])
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
|
|
||||||
# ledger rows exact: net + cut
|
|
||||||
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
|
|
||||||
assert _ledger_get(env["ledger_url"], "/balance/sdjs-operator")["balance"] == 36
|
|
||||||
assert _ledger_get(env["ledger_url"], "/balance/platform")["balance"] == 4
|
|
||||||
# grant exists
|
|
||||||
lic = httpx.get(
|
|
||||||
env["ledger_url"] + f"/license/m2bd/{LISTING_ID}",
|
|
||||||
headers={"X-API-Key": SERVICE_KEY},
|
|
||||||
)
|
|
||||||
assert lic.status_code == 200
|
|
||||||
# file placed into the temp HOME by the local adapter
|
|
||||||
assert (env["home"] / "m2-market-test" / "hello.txt").exists()
|
|
||||||
# state.json updated
|
|
||||||
state = json.loads(Path(os.environ["M2_MARKET_STATE"]).read_text())
|
|
||||||
assert state["installs"][LISTING_ID]["status"] == "applied"
|
|
||||||
# entrypoint hint printed
|
|
||||||
assert "cat ~/m2-market-test/hello.txt" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_insufficient_funds_exit_3_nothing_written(env):
|
|
||||||
# no grant: wallet is empty
|
|
||||||
before = _tx_count(env["ledger_url"])
|
|
||||||
result = _run(["install", LISTING_ID, "--yes"])
|
|
||||||
assert result.exit_code == 3
|
|
||||||
assert _tx_count(env["ledger_url"]) == before
|
|
||||||
assert not (env["home"] / "m2-market-test").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_idempotent_rerun_no_second_debit(env):
|
|
||||||
_grant(env["ledger_url"], "m2bd", 100)
|
|
||||||
assert _run(["install", LISTING_ID, "--yes"]).exit_code == 0
|
|
||||||
txs_after_first = _tx_count(env["ledger_url"])
|
|
||||||
|
|
||||||
rerun = _run(["install", LISTING_ID, "--yes"])
|
|
||||||
assert rerun.exit_code == 0
|
|
||||||
assert "no-op" in rerun.output
|
|
||||||
assert _tx_count(env["ledger_url"]) == txs_after_first
|
|
||||||
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == 100 - PRICE
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_failure_refunds_and_rolls_back(env):
|
|
||||||
_grant(env["ledger_url"], "m2bd", 100)
|
|
||||||
# swap in a bundle whose post-install check fails
|
|
||||||
bad_bundle, bad_hash = _build_bundle(env["tmp"] / "bad", failing_check=True)
|
|
||||||
env["solution"]["content_hash"] = bad_hash
|
|
||||||
env["monkeypatch"].setattr(
|
|
||||||
env["cli_mod"].RegistryClient,
|
|
||||||
"download_bundle",
|
|
||||||
lambda self, ref, dest: (Path(dest).mkdir(parents=True, exist_ok=True), shutil.copy(bad_bundle, Path(dest) / "b.tar.gz"))[1] and Path(dest) / "b.tar.gz",
|
|
||||||
)
|
|
||||||
|
|
||||||
before = _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"]
|
|
||||||
result = _run(["install", LISTING_ID, "--yes"])
|
|
||||||
assert result.exit_code == 4
|
|
||||||
assert "refund" in result.output.lower()
|
|
||||||
# refund compensates exactly
|
|
||||||
assert _ledger_get(env["ledger_url"], "/balance/m2bd")["balance"] == before
|
|
||||||
refunds = _ledger_get(env["ledger_url"], "/tx?reason=refund")["transactions"]
|
|
||||||
assert refunds, "compensating refund rows expected"
|
|
||||||
# rollback removed placed files
|
|
||||||
assert not (env["home"] / "m2-market-test" / "hello.txt").exists()
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""Inline verification for the publish CLI command (contracts/cli.md, registry-layout.md).
|
|
||||||
|
|
||||||
Covers: (1) invalid bundle -> schema errors printed, exit 5, before any registry
|
|
||||||
call; (2) valid bundle -> happy-path branch/commit/release/PR sequence against
|
|
||||||
a MockTransport fake of the Forgejo API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import m2_market.cli as cli_mod
|
|
||||||
|
|
||||||
LISTING_ID = "lst_mm-pdf-report"
|
|
||||||
SOLUTION_ID = "sol_mm-pdf-report"
|
|
||||||
INSTALL_REF = "lst_mm-pdf-report-v1.0.0"
|
|
||||||
SELLER = "sdjs-operator"
|
|
||||||
|
|
||||||
VALID_SOLUTION = {
|
|
||||||
"schema_version": "m2.solution.v1",
|
|
||||||
"solution_id": SOLUTION_ID,
|
|
||||||
"name": "MM PDF Branded Report",
|
|
||||||
"summary": "Generate branded PDF reports from Markdown",
|
|
||||||
"intent": "Produce publish-ready branded PDF output without manual design work",
|
|
||||||
"deployment": {
|
|
||||||
"recipe_ref": "recipes/mm-pdf-report/recipe.yaml",
|
|
||||||
"entrypoint": "skills/mm-pdf/render.py",
|
|
||||||
"verify_command": "python skills/mm-pdf/render.py --self-test",
|
|
||||||
},
|
|
||||||
"applicability": {"image_classes": ["desktop-agent"]},
|
|
||||||
"tenant_scope": "m2-core",
|
|
||||||
"evidence": [{"source": "session:2026-05-11-mm-pdf-branded-report"}],
|
|
||||||
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"price": {"amount": 500, "currency": "m2cr", "model": "fixed"},
|
|
||||||
"seller": SELLER,
|
|
||||||
"license": {"terms": "internal", "major_version_coverage": True},
|
|
||||||
"permissions": [{"scope": "filesystem:write", "path": "/tmp/mm-pdf-output"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
VALID_LISTING = {
|
|
||||||
"schema_version": "m2.listing.v1",
|
|
||||||
"listing_id": LISTING_ID,
|
|
||||||
"solution_id": SOLUTION_ID,
|
|
||||||
"solution_version": "1.0.0",
|
|
||||||
"inventory_type": "solution",
|
|
||||||
"name": "MM PDF Branded Report",
|
|
||||||
"summary": "Generate branded PDF reports from Markdown",
|
|
||||||
"category": "reporting",
|
|
||||||
"price": {"amount": 500, "currency": "m2cr", "model": "fixed"},
|
|
||||||
"seller": SELLER,
|
|
||||||
"evidence_summary": "Generated branded PDF report; operator confirmed styling matched brand deck.",
|
|
||||||
"tenant_visibility": ["*"],
|
|
||||||
"stats": {"installs": 0, "proposals_shown": 0, "proposals_accepted": 0},
|
|
||||||
"status": "draft",
|
|
||||||
"install_ref": INSTALL_REF,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _write_bundle(bundle_dir, solution=None, listing=None):
|
|
||||||
bundle_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(bundle_dir / "solution.json").write_text(json.dumps(solution if solution is not None else VALID_SOLUTION))
|
|
||||||
(bundle_dir / "listing.json").write_text(json.dumps(listing if listing is not None else VALID_LISTING))
|
|
||||||
payload_dir = bundle_dir / "payload"
|
|
||||||
payload_dir.mkdir(exist_ok=True)
|
|
||||||
(payload_dir / "report.md").write_text("# hello\n")
|
|
||||||
(bundle_dir / "recipe.yaml").write_text("targets: []\nchecks: []\n")
|
|
||||||
return bundle_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def config_env(tmp_path, monkeypatch):
|
|
||||||
config_path = tmp_path / "config.toml"
|
|
||||||
config_path.write_text(
|
|
||||||
"""
|
|
||||||
operator_id = "buyer1"
|
|
||||||
tenant = "test-tenant"
|
|
||||||
ledger_url = "https://ledger.test"
|
|
||||||
ledger_api_key = "lk"
|
|
||||||
memory_api_url = "https://catalog.test"
|
|
||||||
memory_api_key = "mk"
|
|
||||||
forgejo_url = "https://registry.test"
|
|
||||||
forgejo_token = "rt"
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
monkeypatch.setenv("M2_MARKET_CONFIG", str(config_path))
|
|
||||||
monkeypatch.setenv("M2_MARKET_SCHEMAS_DIR", str(_repo_schemas_dir()))
|
|
||||||
return config_path
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_schemas_dir():
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
return Path(__file__).resolve().parents[2] / "schemas"
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_bundle_fails_validation_before_any_network_call(tmp_path, config_env, monkeypatch):
|
|
||||||
def fail_if_called(*args, **kwargs):
|
|
||||||
raise AssertionError("registry must not be contacted when validation fails")
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.Client, "post", fail_if_called)
|
|
||||||
|
|
||||||
bad_solution = dict(VALID_SOLUTION)
|
|
||||||
bad_solution["evidence"] = [] # violates evidence minItems (Principle III evidence-backed listings)
|
|
||||||
bundle_dir = _write_bundle(tmp_path / "bundle", solution=bad_solution)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)])
|
|
||||||
|
|
||||||
assert result.exit_code == cli_mod.EXIT_VALIDATION, result.output
|
|
||||||
assert "evidence" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_publish_happy_path_opens_pr(tmp_path, config_env, monkeypatch):
|
|
||||||
calls = {"branch": 0, "files": [], "release": 0, "asset": 0, "pr": 0}
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
path = request.url.path
|
|
||||||
if path.endswith("/branches") and request.method == "POST":
|
|
||||||
calls["branch"] += 1
|
|
||||||
return httpx.Response(201, json={"name": f"listing/{LISTING_ID}"})
|
|
||||||
if "/contents/" in path and request.method == "POST":
|
|
||||||
calls["files"].append(path)
|
|
||||||
return httpx.Response(201, json={"content": {"path": path}})
|
|
||||||
if path.endswith("/releases") and request.method == "POST":
|
|
||||||
calls["release"] += 1
|
|
||||||
return httpx.Response(201, json={"id": 42, "tag_name": INSTALL_REF})
|
|
||||||
if path.endswith("/releases/42/assets") and request.method == "POST":
|
|
||||||
calls["asset"] += 1
|
|
||||||
return httpx.Response(201, json={"name": f"{INSTALL_REF}.tar.gz"})
|
|
||||||
if path.endswith("/pulls") and request.method == "POST":
|
|
||||||
calls["pr"] += 1
|
|
||||||
return httpx.Response(
|
|
||||||
201,
|
|
||||||
json={"html_url": f"https://registry.test/m2/market-registry/pulls/7"},
|
|
||||||
)
|
|
||||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
|
||||||
|
|
||||||
orig_init = httpx.Client.__init__
|
|
||||||
|
|
||||||
def patched_init(self, *args, **kwargs):
|
|
||||||
kwargs["transport"] = httpx.MockTransport(handler)
|
|
||||||
orig_init(self, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
|
|
||||||
|
|
||||||
bundle_dir = _write_bundle(tmp_path / "bundle")
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)])
|
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "https://registry.test/m2/market-registry/pulls/7" in result.output
|
|
||||||
assert calls["branch"] == 1
|
|
||||||
assert len(calls["files"]) == 2
|
|
||||||
assert calls["release"] == 1
|
|
||||||
assert calls["asset"] == 1
|
|
||||||
assert calls["pr"] == 1
|
|
||||||
126
contracts/cli.md
Normal file
126
contracts/cli.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# m2-market CLI contract v1
|
||||||
|
|
||||||
|
The CLI is the only marketplace surface that calls m2-ledger directly. Store, Scout, and Hermes
|
||||||
|
consume the CLI JSON contract.
|
||||||
|
|
||||||
|
Config: `~/.m2-market/config.toml` mode `0600`.
|
||||||
|
|
||||||
|
Required keys: `operator_id`, `tenant`, `ledger_url`, `ledger_api_key`, `memory_api_url`,
|
||||||
|
`memory_api_key`, `forgejo_url`, `forgejo_token`, `apply_adapter`.
|
||||||
|
|
||||||
|
State: `~/.m2-market/state.json`. Install entries MUST store `grant_id` and `tx_group_id`.
|
||||||
|
|
||||||
|
## Verbs
|
||||||
|
|
||||||
|
- `m2-market search <query> [--type solution|capability|resource|service] [--limit N] [--json]`
|
||||||
|
- `m2-market show <listing_id> [--json]`
|
||||||
|
- `m2-market install <listing_id> [--yes] [--json]`
|
||||||
|
- `m2-market uninstall <listing_id> [--yes] [--json]`
|
||||||
|
- `m2-market upgrade <listing_id> [--to <semver>] [--yes] [--json]`
|
||||||
|
- `m2-market wallet [--operator <operator_id>] [--json]`
|
||||||
|
- `m2-market publish <bundle-dir> [--tenant-visibility <tenant-or-*>]`
|
||||||
|
- `m2-market package init|import-fedlearn|validate|build|diff-permissions|evidence-summary|verify-release`
|
||||||
|
- `m2-market reindex`
|
||||||
|
- `m2-market propose "<intent>" --tenant <tenant> --operator <operator_id> --json`
|
||||||
|
- `m2-market evidence capture|submit|show`
|
||||||
|
- `m2-market telemetry submit <event-jsonl>`
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | Success, including idempotent no-op install. |
|
||||||
|
| 1 | Usage error, unsupported operation, or direct install refused for `price.model=job`. |
|
||||||
|
| 2 | Listing, release, license, or local install state not found. |
|
||||||
|
| 3 | Insufficient funds or ledger payment declined before apply. |
|
||||||
|
| 4 | Apply, verify, uninstall, or upgrade failed after settlement; CLI attempted refund when applicable. |
|
||||||
|
| 5 | Schema validation, tenant firewall, secret scan, permissions, or publish gate rejection. |
|
||||||
|
| 6 | External dependency unavailable or deferred adapter path not landed. |
|
||||||
|
|
||||||
|
## `show --json`
|
||||||
|
|
||||||
|
The detail payload is frozen for Store and Scout:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "m2.market.show.v1",
|
||||||
|
"listing": {
|
||||||
|
"schema_version": "m2.listing.v1",
|
||||||
|
"listing_id": "lst_mm-pdf-report",
|
||||||
|
"solution_id": "sol_mm-pdf-report",
|
||||||
|
"solution_version": "1.0.0",
|
||||||
|
"inventory_type": "solution",
|
||||||
|
"name": "MM PDF Report",
|
||||||
|
"summary": "Generate branded PDF reports.",
|
||||||
|
"category": "reporting",
|
||||||
|
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
|
||||||
|
"seller": {"operator_id": "op_m2core", "display_name": "M2 Core"},
|
||||||
|
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"tenant_visibility": ["*"],
|
||||||
|
"status": "published",
|
||||||
|
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
||||||
|
},
|
||||||
|
"solution": {
|
||||||
|
"schema_version": "m2.solution.v1",
|
||||||
|
"solution_id": "sol_mm-pdf-report",
|
||||||
|
"name": "MM PDF Report",
|
||||||
|
"summary": "Generate branded PDF reports.",
|
||||||
|
"intent": "Turn approved analysis into a styled client-ready PDF report.",
|
||||||
|
"deployment": {
|
||||||
|
"recipe_ref": "recipe.yaml",
|
||||||
|
"entrypoint": "mm-pdf generate <input.md>",
|
||||||
|
"verify_command": "mm-pdf --version"
|
||||||
|
},
|
||||||
|
"applicability": {
|
||||||
|
"image_classes": ["primus", "agent-latest"],
|
||||||
|
"roles": ["operator"],
|
||||||
|
"tool_requirements": ["bash"]
|
||||||
|
},
|
||||||
|
"tenant_scope": "m2-core",
|
||||||
|
"evidence": [{"source": "forgejo:m2/m2-market/listings/lst_mm-pdf-report/evidence/provenance.jsonl"}],
|
||||||
|
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
|
||||||
|
"seller": {"operator_id": "op_m2core", "display_name": "M2 Core"},
|
||||||
|
"license": {
|
||||||
|
"license_id": "lic_m2_market_standard_v1",
|
||||||
|
"terms": "m2-market-standard-v1",
|
||||||
|
"scope": "operator",
|
||||||
|
"major_version_coverage": true,
|
||||||
|
"allows_internal_modification": true,
|
||||||
|
"allows_redistribution": false,
|
||||||
|
"support": "best_effort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wallet": {"operator_id": "op_chris", "balance": 100, "currency": "m2cr", "as_of": "2026-07-02T00:00:00Z"},
|
||||||
|
"install_state": {
|
||||||
|
"status": "not_installed",
|
||||||
|
"installed_version": null,
|
||||||
|
"grant_id": null,
|
||||||
|
"tx_group_id": null
|
||||||
|
},
|
||||||
|
"permissions_diff": {
|
||||||
|
"schema_version": "m2.permissions_diff.v1",
|
||||||
|
"mode": "new_install",
|
||||||
|
"listing_id": "lst_mm-pdf-report",
|
||||||
|
"current_install_ref": null,
|
||||||
|
"target_install_ref": "lst_mm-pdf-report-v1.0.0",
|
||||||
|
"summary": {"added": 2, "removed": 0, "changed": 0, "unchanged": 0, "risk": "medium"},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"permission_id": "fs-write-agent-home",
|
||||||
|
"action": "added",
|
||||||
|
"kind": "filesystem",
|
||||||
|
"access": "write",
|
||||||
|
"scope": "${AGENT_HOME}/.claude/skills/mm-pdf",
|
||||||
|
"target": "skill files",
|
||||||
|
"reason": "install skill files",
|
||||||
|
"risk": "low",
|
||||||
|
"requires_confirmation": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`permissions_diff.mode` is one of `new_install`, `upgrade`, `reinstall`, `uninstall`, or
|
||||||
|
`no_change`. `items[].action` is one of `added`, `removed`, `changed`, or `unchanged`.
|
||||||
24
contracts/deeplink.md
Normal file
24
contracts/deeplink.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# M2 Store deep-link contract v1
|
||||||
|
|
||||||
|
Canonical listing detail URI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
m2store://listing/<listing_id>?source=&proposal_id=&q=
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
m2store://listing/lst_competitor-scan?source=scout&proposal_id=prop_01j2abc
|
||||||
|
m2store://listing/lst_mm-pdf-report?source=hermes&q=pdf%20report
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Path segment is `listing`, not `solution`.
|
||||||
|
- `<listing_id>` uses the `lst_` prefix from `contracts/ids.md`.
|
||||||
|
- Allowed query parameters are exactly `source`, `proposal_id`, and `q`.
|
||||||
|
- The URI is navigate-only. It never installs, purchases, grants licenses, or mutates state.
|
||||||
|
- Unknown listing IDs open Store search/detail error UI without leaking tenant-forbidden records.
|
||||||
|
- Store may keep an internal route such as `/solution/<listing_id>`, but the external protocol
|
||||||
|
stays `m2store://listing/<listing_id>`.
|
||||||
22
contracts/ids.md
Normal file
22
contracts/ids.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Marketplace ID prefixes v1
|
||||||
|
|
||||||
|
Canonical prefixes:
|
||||||
|
|
||||||
|
| Prefix | Object | Example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `op_` | Operator ID | `op_m2core` |
|
||||||
|
| `prop_` | Proposal ID | `prop_01j2abc` |
|
||||||
|
| `lst_` | Listing ID | `lst_mm-pdf-report` |
|
||||||
|
| `sub_` | Fedlearn submission ID | `sub_01j2candidate` |
|
||||||
|
| `clu_` | Fedlearn cluster ID | `clu_01j2cluster` |
|
||||||
|
| `core_` | Promoted core learning ID | `core_01j2learning` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Operators use `op_<slug>` everywhere. Bare names such as `m2bd` or `sdjs-operator` are display
|
||||||
|
names, not marketplace IDs.
|
||||||
|
- Proposals use `prop_`, never `prp_`.
|
||||||
|
- Listings use `lst_` and control price, visibility, version, and license grants.
|
||||||
|
- Solution manifests keep `sol_` for `solution_id`; this is already frozen in schema and is not
|
||||||
|
a replacement for `lst_` in Store links or ledger refs.
|
||||||
|
- Ledger transaction groups, grants, accounts, and jobs keep S1-owned prefixes outside this file.
|
||||||
139
contracts/ledger-api.md
Normal file
139
contracts/ledger-api.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# m2-ledger API contract v1
|
||||||
|
|
||||||
|
S1 owns ledger semantics. Marketplace clients MUST use the `/v1/*` paths below; older
|
||||||
|
`/tx/install`, `/tx/refund`, `/license/*`, `/licenses/check`, and `/balance/*` paths are not
|
||||||
|
contractual for m2-market.
|
||||||
|
|
||||||
|
All mutation requests require `Idempotency-Key` or an `idempotency_key` body field. If both are
|
||||||
|
present, they must match. All amounts are integer `m2cr`.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
`GET /v1/accounts/me`
|
||||||
|
|
||||||
|
Returns the caller's resolved ledger identity:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_id": "acct_op_chris",
|
||||||
|
"operator_id": "op_chris",
|
||||||
|
"tenant_id": "m2",
|
||||||
|
"scopes": ["ledger:read", "ledger:install"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Balances
|
||||||
|
|
||||||
|
`GET /v1/balances/{account_or_operator_id}`
|
||||||
|
|
||||||
|
Returns a balance derived from the append-only transaction log. Store and Scout do not call this
|
||||||
|
directly; they use `m2-market wallet`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_id": "acct_op_chris",
|
||||||
|
"operator_id": "op_chris",
|
||||||
|
"tenant_id": "m2",
|
||||||
|
"balance": 100,
|
||||||
|
"currency": "m2cr",
|
||||||
|
"as_of": "2026-07-02T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install settlement
|
||||||
|
|
||||||
|
`POST /v1/settlements/install`
|
||||||
|
|
||||||
|
Fixed-price install settlement. Job-priced listings are refused by the CLI before this call and
|
||||||
|
use the jobs API instead.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"buyer_operator_id": "op_chris",
|
||||||
|
"seller_operator_id": "op_m2core",
|
||||||
|
"tenant_id": "m2",
|
||||||
|
"listing_id": "lst_mm-pdf-report",
|
||||||
|
"solution_id": "sol_mm-pdf-report",
|
||||||
|
"solution_version": "1.0.0",
|
||||||
|
"major_version": 1,
|
||||||
|
"install_ref": "lst_mm-pdf-report-v1.0.0",
|
||||||
|
"price": {"model": "fixed", "amount": 25, "currency": "m2cr"},
|
||||||
|
"revenue_split": {"platform_pct": 10, "seller_pct": 90}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success returns the transaction group and license grant. The CLI MUST persist `tx_group_id` in
|
||||||
|
`~/.m2-market/state.json` before apply so refund can be addressed by group id.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tx_group_id": "txg_01j2install",
|
||||||
|
"grant_id": "gr_01j2license",
|
||||||
|
"balance": 75,
|
||||||
|
"license": {
|
||||||
|
"operator_id": "op_chris",
|
||||||
|
"listing_id": "lst_mm-pdf-report",
|
||||||
|
"major_version": 1,
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`402 insufficient_funds` writes no transactions or grant. Repeated idempotency keys return the
|
||||||
|
original settlement.
|
||||||
|
|
||||||
|
## Refund on apply failure
|
||||||
|
|
||||||
|
`POST /v1/settlements/install/{tx_group_id}/refund`
|
||||||
|
|
||||||
|
Refunds by `tx_group_id`, not by listing ref. Refunds are compensating append-only rows and mark
|
||||||
|
the related license grant `refunded`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reason": "apply_failed",
|
||||||
|
"apply_error": "verify command failed",
|
||||||
|
"listing_id": "lst_mm-pdf-report"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licenses
|
||||||
|
|
||||||
|
`GET /v1/licenses/{operator_id}/{listing_id}?major_version=1`
|
||||||
|
|
||||||
|
Returns the active/refunded/revoked grant for `(operator_id, listing_id, major_version)`, or `404`
|
||||||
|
for not found or forbidden.
|
||||||
|
|
||||||
|
## Starter grants
|
||||||
|
|
||||||
|
`POST /v1/grants/starter`
|
||||||
|
|
||||||
|
Admin/service scoped. Default amount is `100 m2cr`.
|
||||||
|
|
||||||
|
## Jobs
|
||||||
|
|
||||||
|
`POST /v1/jobs`
|
||||||
|
|
||||||
|
Creates a funded job/escrow for job-priced listings. Job settlements use ledger `tx_type=job`.
|
||||||
|
|
||||||
|
Additional job paths:
|
||||||
|
|
||||||
|
- `POST /v1/jobs/{job_id}/start`
|
||||||
|
- `POST /v1/jobs/{job_id}/milestones/{milestone_id}/submit`
|
||||||
|
- `POST /v1/jobs/{job_id}/milestones/{milestone_id}/accept`
|
||||||
|
- `POST /v1/jobs/{job_id}/cancel`
|
||||||
|
- `POST /v1/jobs/{job_id}/dispute`
|
||||||
|
|
||||||
|
## Route/resource settlement
|
||||||
|
|
||||||
|
`POST /v1/settlements/route`
|
||||||
|
|
||||||
|
Records credit-denominated route/resource rows tied to m2-gpt metering references. It does not
|
||||||
|
modify m2-gpt metering.
|
||||||
|
|
||||||
|
## Audit snapshots
|
||||||
|
|
||||||
|
`POST /v1/admin/snapshots`
|
||||||
|
|
||||||
|
Writes deterministic balance/hash-chain snapshots to Forgejo repo `m2/market-audit`, not to the
|
||||||
|
registry repo `m2/m2-market`.
|
||||||
62
contracts/telemetry.md
Normal file
62
contracts/telemetry.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Marketplace telemetry contract v1
|
||||||
|
|
||||||
|
All shown/opened/installed/dismissed marketplace telemetry uses one envelope:
|
||||||
|
`m2.market.telemetry.v1`.
|
||||||
|
|
||||||
|
`m2.market.evidence.v1` is reserved for S6 job evidence records only. Do not write install,
|
||||||
|
proposal, Store, Scout, or CLI telemetry with the evidence schema.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "m2.market.telemetry.v1",
|
||||||
|
"event_id": "evt_01j2telemetry",
|
||||||
|
"event_type": "market.install.succeeded",
|
||||||
|
"occurred_at": "2026-07-02T00:00:00Z",
|
||||||
|
"tenant_id": "m2",
|
||||||
|
"operator_id": "op_chris",
|
||||||
|
"agent_id": "chris-m2o",
|
||||||
|
"listing_id": "lst_mm-pdf-report",
|
||||||
|
"solution_id": "sol_mm-pdf-report",
|
||||||
|
"solution_version": "1.0.0",
|
||||||
|
"proposal_id": "prop_01j2abc",
|
||||||
|
"source": "cli",
|
||||||
|
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required fields: `schema_version`, `event_id`, `event_type`, `occurred_at`, `tenant_id`,
|
||||||
|
`source`, and at least one of `listing_id`, `solution_id`, or `proposal_id`.
|
||||||
|
|
||||||
|
Telemetry must contain summaries and IDs only. No raw client data, secrets, transcript bodies, or
|
||||||
|
private job artifacts.
|
||||||
|
|
||||||
|
## Event type registry
|
||||||
|
|
||||||
|
- `market.listing.shown`
|
||||||
|
- `market.listing.opened`
|
||||||
|
- `market.proposal.generated`
|
||||||
|
- `market.proposal.shown`
|
||||||
|
- `market.proposal.opened`
|
||||||
|
- `market.proposal.accepted`
|
||||||
|
- `market.proposal.dismissed`
|
||||||
|
- `market.install.started`
|
||||||
|
- `market.install.succeeded`
|
||||||
|
- `market.install.failed`
|
||||||
|
- `market.install.refunded`
|
||||||
|
- `market.uninstall.succeeded`
|
||||||
|
- `market.upgrade.succeeded`
|
||||||
|
- `market.invoke.succeeded`
|
||||||
|
- `market.invoke.failed`
|
||||||
|
|
||||||
|
Indexers may fold telemetry into `listing.stats.installs`, `listing.stats.proposals_shown`, and
|
||||||
|
`listing.stats.proposals_accepted`. Self-purchases and self-generated proposal loops are excluded
|
||||||
|
from quality statistics.
|
||||||
|
|
||||||
|
## Job evidence reservation
|
||||||
|
|
||||||
|
`m2.market.evidence.v1` records are S6 job evidence records and carry `evidence_id`, `job_id`,
|
||||||
|
`wanted`, `tools`, `contributors`, `metering`, `resources`, `outcome`, `reusable_verdict`,
|
||||||
|
`scrub_status`, and `content_hash`. They are not a general event envelope.
|
||||||
34
contracts/tenants.md
Normal file
34
contracts/tenants.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Tenant namespace contract v1
|
||||||
|
|
||||||
|
Canonical tenant IDs come from the live m2-gpt `tenants` table. Marketplace contracts must not
|
||||||
|
invent alternate sentinels or display-name aliases.
|
||||||
|
|
||||||
|
Live table read on 2026-07-02:
|
||||||
|
|
||||||
|
| tenant_id | display_name | status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `erlengrund-m2o` | erlengrund-m2o | active |
|
||||||
|
| `gunnar-m2o` | gunnar-m2o | active |
|
||||||
|
| `m2` | m2 Operator | active |
|
||||||
|
| `m2bd-m2o` | m2bd-m2o | active |
|
||||||
|
| `matrix` | matrix | active |
|
||||||
|
| `parlobyg-m2o` | parlobyg-m2o | active |
|
||||||
|
| `smoke7623` | Smoke smoke7623 | active |
|
||||||
|
| `e2e-verify-0617` | E2E Verify delete me | archived |
|
||||||
|
|
||||||
|
Marketplace-reserved scopes:
|
||||||
|
|
||||||
|
- `*` in `listing.tenant_visibility` means public to all tenants.
|
||||||
|
- `m2-core` is the shared commons scope for reusable, scrubbed marketplace evidence and shared
|
||||||
|
Solutions. It replaces `__fleet__` for marketplace contracts.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `tenant_visibility` is an allowlist of `*` or canonical active tenant IDs.
|
||||||
|
- Empty tenant filters fail closed.
|
||||||
|
- Readers must apply the S2 filter contract:
|
||||||
|
`filters.tenant_visibility_any = ["*", "<caller_tenant>"]`, with client-side backstop that
|
||||||
|
drops hits where `status != "published"` or caller tenant is not visible.
|
||||||
|
- Cross-tenant not-found and forbidden states are surfaced as `404` to avoid existence leaks.
|
||||||
|
- `m2-core` is allowed for `solution.tenant_scope` and evidence scope, but it is not a buyer
|
||||||
|
tenant and is not a replacement for `tenant_visibility: ["*"]`.
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
# m2-market operational runbook
|
|
||||||
|
|
||||||
## Catalog rebuild (constitution II rebuildability)
|
|
||||||
|
|
||||||
The live memory-api is store/search only — no delete, no partition drop — so
|
|
||||||
`m2-market-indexer` reindex is an **idempotent fill** of the current partition, not a
|
|
||||||
rebuild. A true rebuild (needed after listing updates/delists or index corruption) is a
|
|
||||||
**fresh-partition switch**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Fill a fresh partition from the registry (source of truth)
|
|
||||||
MEMORY_API_PARTITION=market:catalog-v2 \
|
|
||||||
MEMORY_API_URL=... MEMORY_API_KEY=... FORGEJO_URL=... FORGEJO_TOKEN=... \
|
|
||||||
LISTING_SCHEMA_PATH=$REPO/schemas/listing.schema.json \
|
|
||||||
python3 -m m2_market_indexer.reindex
|
|
||||||
|
|
||||||
# 2. Switch every reader (CLI/Store/Scout) by config — no code change
|
|
||||||
export M2_MARKET_CATALOG_PARTITION=market:catalog-v2 # or config management
|
|
||||||
|
|
||||||
# 3. The old partition is abandoned in place (memory-api cannot delete it);
|
|
||||||
# it costs storage only. Fold the suffix rotation back when memory-api
|
|
||||||
# grows delete support (fedlearn T13 follow-up).
|
|
||||||
```
|
|
||||||
|
|
||||||
Verification: `m2-market search <known listing>` returns the same results against the
|
|
||||||
new partition (quickstart.md Scenario E).
|
|
||||||
|
|
||||||
## memory-api endpoint discovery
|
|
||||||
|
|
||||||
The live memory-api container IP changes across Coolify redeploys (known split-brain,
|
|
||||||
two stacks). Resolve the current one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MC=$(docker ps --format '{{.Names}}' | grep memory-api | head -1)
|
|
||||||
docker inspect $MC | jq -r '.[0].NetworkSettings.Networks | to_entries[0].value.IPAddress'
|
|
||||||
```
|
|
||||||
|
|
||||||
Public route: `https://memory.machinemachine.ai`. Writes need `X-API-Key` (the key lives
|
|
||||||
in the memory-api container env as MEMORY_API_KEY); reads are currently open.
|
|
||||||
|
|
||||||
## Ledger
|
|
||||||
|
|
||||||
- Public: `https://ledger.machinemachine.ai` (Cloudflare TLS → Traefik http router — the
|
|
||||||
domain is registered `http://` in Coolify on purpose; re-adding `https://` recreates
|
|
||||||
the redirect loop).
|
|
||||||
- Deploy: push to `m2/m2-market` master → Forgejo webhook (hook id 14) → Coolify deploy.
|
|
||||||
Webhook delivery can lag ~40 s. Manual: `GET /api/v1/deploy?uuid=<app>` on Coolify.
|
|
||||||
- Keys: `/home/m2/.m2-ledger-keys` (host, 0600). Service key reads/installs; admin key
|
|
||||||
grants/snapshots.
|
|
||||||
- Daily snapshot: host cron 03:10 → `POST /snapshot` → commits
|
|
||||||
`m2/market-registry:audit/YYYY-MM-DD.json`. Balance disputes: recompute from the tx
|
|
||||||
log (`GET /tx`), which is append-only truth.
|
|
||||||
- Refund semantics: compensating refunds may overdraw a seller (debt position, settled
|
|
||||||
at manual payout reconciliation) — data-model.md §Transaction.
|
|
||||||
|
|
||||||
## Publish/status flow (known gap, analyze finding I2)
|
|
||||||
|
|
||||||
`m2-market publish` opens the PR with `status: "draft"`; nothing flips it on merge yet.
|
|
||||||
Until automated: after merging a listing PR, commit `status: "published"` on main (the
|
|
||||||
indexer only indexes published listings).
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# Solution Scout v0
|
|
||||||
|
|
||||||
Solution Scout is a Hermes plugin that watches safe turn summaries, searches the M2
|
|
||||||
Marketplace catalog, and shows a non-blocking proposal when a published Solution appears to
|
|
||||||
cover the current task.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scout/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer is an idempotent file drop to:
|
|
||||||
|
|
||||||
- `~/.hermes/plugins/m2-solution-scout/plugin.yaml`
|
|
||||||
- `~/.hermes/plugins/m2-solution-scout/__init__.py`
|
|
||||||
- `~/.config/m2-market/pull-policy.toml` only when that file does not already exist
|
|
||||||
|
|
||||||
It does not enable Scout. To opt in, edit `~/.config/m2-market/pull-policy.toml` and set:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[scout]
|
|
||||||
enabled = true
|
|
||||||
tenant_id = "machine-machine"
|
|
||||||
desktop_id = "chris-m2o"
|
|
||||||
operator_id = "op_chris"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
Scout fails closed. Missing config, `enabled=false`, or a missing `tenant_id` makes every hook
|
|
||||||
a no-op. `raw_keystrokes=true` or `raw_client_data=true` refuses session start. The plugin
|
|
||||||
summarizes and redacts on-box before memory-api egress, searches only `agent_id =
|
|
||||||
"market:catalog"`, filters hits by `tenant_visibility` containing the desktop tenant or `"*"`,
|
|
||||||
rate-limits proposals, and never calls `m2-market install`.
|
|
||||||
|
|
||||||
## Event Flow
|
|
||||||
|
|
||||||
On `on_session_start`, the plugin loads `pull-policy.toml`. On `post_llm_call`, it gates on
|
|
||||||
policy, rate caps, and cooldowns, then starts a background thread so slow catalog queries do
|
|
||||||
not block the operator turn. The worker builds an `m2.scout.intent_summary.v1`, posts
|
|
||||||
`/memory/search` with `agent_id=market:catalog`, applies client-side tenant filtering and
|
|
||||||
threshold checks, builds an `m2.scout.proposal.v1`, shows `notify-send` with a Store deep
|
|
||||||
link, and falls back to the Herdr socket toast when desktop notification delivery is
|
|
||||||
unavailable.
|
|
||||||
|
|
||||||
The deep link format is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
m2store://listing/{listing_id}?proposal_id={proposal_id}&source=scout
|
|
||||||
```
|
|
||||||
|
|
||||||
## Telemetry Paths
|
|
||||||
|
|
||||||
Local state lives at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.local/share/m2-market/scout/state.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Proposal telemetry is append-only JSONL:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.local/share/m2-market/scout/outbox/YYYY-MM-DD.jsonl
|
|
||||||
```
|
|
||||||
|
|
||||||
The v0 plugin records `proposal_shown` locally for later batching to `market:evidence`.
|
|
||||||
Accept, dismiss, debit, grant, and install remain owned by M2 Store and `m2-market`.
|
|
||||||
Binary file not shown.
BIN
indexer/src/m2_market_indexer/__pycache__/client.cpython-312.pyc
Normal file
BIN
indexer/src/m2_market_indexer/__pycache__/client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
indexer/src/m2_market_indexer/__pycache__/config.cpython-312.pyc
Normal file
BIN
indexer/src/m2_market_indexer/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -11,7 +11,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -105,17 +104,7 @@ class MemoryClient:
|
||||||
"""Writer for the `market:catalog` partition (contracts/catalog-index.md).
|
"""Writer for the `market:catalog` partition (contracts/catalog-index.md).
|
||||||
|
|
||||||
ONLY m2-market-indexer writes here (reindex + watch); everything else is a
|
ONLY m2-market-indexer writes here (reindex + watch); everything else is a
|
||||||
reader via CatalogClient.
|
reader via CatalogClient's `/search`.
|
||||||
|
|
||||||
Adapted 2026-07-02 to the REAL memory-api surface (agent.memory.system):
|
|
||||||
the API is store/search only — no delete, no partition drop, no
|
|
||||||
upsert-by-id. So:
|
|
||||||
- "upsert" = search-first for metadata.listing_id, store only if absent
|
|
||||||
(idempotent fill; a listing UPDATE requires memory-api delete support —
|
|
||||||
recorded limitation, fedlearn T13 follow-up).
|
|
||||||
- drop_partition / delete_document are WARN no-ops. Reindex therefore
|
|
||||||
cannot yet prove full rebuildability (constitution II) — the registry
|
|
||||||
remains truth and the gap is tracked in docs/runbook.md.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -124,12 +113,14 @@ class MemoryClient:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
partition: str,
|
partition: str,
|
||||||
*,
|
*,
|
||||||
partition_path: str = "", # retained for config compat; unused
|
partition_path: str,
|
||||||
document_path: str = "", # retained for config compat; unused
|
document_path: str,
|
||||||
timeout: float = 30.0,
|
timeout: float = 15.0,
|
||||||
transport: httpx.BaseTransport | None = None,
|
transport: httpx.BaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.partition = partition
|
self.partition = partition
|
||||||
|
self.partition_path = partition_path
|
||||||
|
self.document_path = document_path
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
base_url=base_url.rstrip("/"),
|
base_url=base_url.rstrip("/"),
|
||||||
headers={"X-API-Key": api_key},
|
headers={"X-API-Key": api_key},
|
||||||
|
|
@ -146,48 +137,29 @@ class MemoryClient:
|
||||||
def __exit__(self, *exc_info: object) -> None:
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def _exists(self, doc_id: str) -> bool:
|
def _partition_url(self) -> str:
|
||||||
resp = self._client.post(
|
return self.partition_path.format(partition=self.partition)
|
||||||
"/memory/search",
|
|
||||||
json={"query": doc_id, "agent_id": self.partition, "limit": 10},
|
def _document_url(self, doc_id: str) -> str:
|
||||||
)
|
return self.document_path.format(partition=self.partition, doc_id=doc_id)
|
||||||
resp.raise_for_status()
|
|
||||||
return any(
|
|
||||||
(r.get("metadata") or {}).get("listing_id") == doc_id
|
|
||||||
for r in resp.json().get("results", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
def drop_partition(self) -> None:
|
def drop_partition(self) -> None:
|
||||||
"""No-op: memory-api has no partition delete (store/search only)."""
|
"""Delete the partition wholesale; a later upsert recreates it."""
|
||||||
print(
|
resp = self._client.delete(self._partition_url())
|
||||||
f"WARN: memory-api cannot drop partition {self.partition}; "
|
if resp.status_code not in (200, 204, 404):
|
||||||
"reindex is an idempotent fill, not a rebuild",
|
resp.raise_for_status()
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
def upsert_document(self, doc_id: str, text: str, payload: dict) -> None:
|
def upsert_document(self, doc_id: str, text: str, payload: dict) -> None:
|
||||||
if self._exists(doc_id):
|
resp = self._client.put(
|
||||||
return
|
self._document_url(doc_id),
|
||||||
resp = self._client.post(
|
json={"text": text, "payload": payload},
|
||||||
"/memory/store",
|
|
||||||
json={
|
|
||||||
"content": text,
|
|
||||||
"agent_id": self.partition,
|
|
||||||
"memory_type": "semantic",
|
|
||||||
"importance": 0.8,
|
|
||||||
"source": "m2-market-indexer",
|
|
||||||
"metadata": {"listing_id": doc_id, "listing": payload},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def delete_document(self, doc_id: str) -> None:
|
def delete_document(self, doc_id: str) -> None:
|
||||||
"""No-op: memory-api has no delete; delisted listings are filtered at
|
resp = self._client.delete(self._document_url(doc_id))
|
||||||
read time by CatalogClient (status/visibility in the payload)."""
|
if resp.status_code not in (200, 204, 404):
|
||||||
print(
|
resp.raise_for_status()
|
||||||
f"WARN: memory-api cannot delete {doc_id}; relying on read-time filtering",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_embedded_text(listing: dict, solution: dict | None) -> str:
|
def build_embedded_text(listing: dict, solution: dict | None) -> str:
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -13,7 +13,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -26,10 +25,6 @@ from m2_market_indexer.client import MemoryClient, RegistryClient # noqa: E402
|
||||||
from m2_market_indexer.reindex import run # noqa: E402
|
from m2_market_indexer.reindex import run # noqa: E402
|
||||||
|
|
||||||
SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "listing.schema.json"
|
SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "listing.schema.json"
|
||||||
# Pin the schema for config.listing_schema_path(): when the package under test is the
|
|
||||||
# venv-installed copy (uv workspace), its parents[3]-based resolution lands outside the
|
|
||||||
# repo and loads the wrong file. The test's fixture contract is THIS repo's schema.
|
|
||||||
os.environ["LISTING_SCHEMA_PATH"] = str(SCHEMA_PATH)
|
|
||||||
|
|
||||||
LISTING_PUBLISHED = {
|
LISTING_PUBLISHED = {
|
||||||
"schema_version": "m2.listing.v1",
|
"schema_version": "m2.listing.v1",
|
||||||
|
|
@ -92,12 +87,7 @@ def _make_handlers():
|
||||||
|
|
||||||
def memory_handler(request: httpx.Request) -> httpx.Response:
|
def memory_handler(request: httpx.Request) -> httpx.Response:
|
||||||
calls.append(("memory", f"{request.method} {request.url.path}"))
|
calls.append(("memory", f"{request.method} {request.url.path}"))
|
||||||
if request.url.path == "/memory/search":
|
return httpx.Response(200, json={"ok": True})
|
||||||
# exists-check: nothing indexed yet
|
|
||||||
return httpx.Response(200, json={"results": [], "routing": {}})
|
|
||||||
if request.url.path == "/memory/store":
|
|
||||||
return httpx.Response(200, json={"id": "mem-1", "status": "stored"})
|
|
||||||
return httpx.Response(404, json={"message": "not found"})
|
|
||||||
|
|
||||||
return calls, registry_handler, memory_handler
|
return calls, registry_handler, memory_handler
|
||||||
|
|
||||||
|
|
@ -133,11 +123,9 @@ def _exercise_reindex_flow():
|
||||||
}, counts
|
}, counts
|
||||||
|
|
||||||
memory_calls = [c for kind, c in calls if kind == "memory"]
|
memory_calls = [c for kind, c in calls if kind == "memory"]
|
||||||
# Real memory-api surface: drop is a no-op (store/search only); upsert is
|
|
||||||
# exists-check (search) then store — for the one published listing only.
|
|
||||||
assert memory_calls == [
|
assert memory_calls == [
|
||||||
"POST /memory/search",
|
"DELETE /partitions/market:catalog",
|
||||||
"POST /memory/store",
|
"PUT /partitions/market:catalog/documents/lst_pdf-export",
|
||||||
], memory_calls
|
], memory_calls
|
||||||
|
|
||||||
registry_calls = [c for kind, c in calls if kind == "registry"]
|
registry_calls = [c for kind, c in calls if kind == "registry"]
|
||||||
|
|
|
||||||
BIN
ledger/src/m2_ledger/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
ledger/src/m2_ledger/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ledger/src/m2_ledger/__pycache__/api.cpython-312.pyc
Normal file
BIN
ledger/src/m2_ledger/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ledger/src/m2_ledger/__pycache__/auth.cpython-312.pyc
Normal file
BIN
ledger/src/m2_ledger/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc
Normal file
BIN
ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
|
|
@ -143,11 +143,6 @@ def snapshot(conn=Depends(get_conn), _key: str = Depends(require_admin)):
|
||||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
path = f"audit/{date}.json"
|
path = f"audit/{date}.json"
|
||||||
content = json.dumps(balances, sort_keys=True, indent=2)
|
content = json.dumps(balances, sort_keys=True, indent=2)
|
||||||
from m2_ledger.snapshot import SnapshotError, commit_snapshot
|
# TODO(T034): commit `content` to `path` in m2/market-registry via Forgejo API
|
||||||
|
# (REGISTRY_REPO_URL + FORGEJO_TOKEN); this endpoint only produces the JSON.
|
||||||
try:
|
return {"path": path, "content": content}
|
||||||
result = commit_snapshot(path, content)
|
|
||||||
except SnapshotError as exc:
|
|
||||||
# Snapshot content is still returned so an operator can commit by hand.
|
|
||||||
return {"path": path, "content": content, "committed": False, "error": str(exc)}
|
|
||||||
return {"path": path, "content": content, "committed": True, "commit": result["commit"]}
|
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,6 @@ def record_install(
|
||||||
tx_ids.append(_insert_tx(conn, ts, buyer, "platform", cut, "install", ref))
|
tx_ids.append(_insert_tx(conn, ts, buyer, "platform", cut, "install", ref))
|
||||||
paying_tx_id = net_tx_id if net_tx_id is not None else tx_ids[0]
|
paying_tx_id = net_tx_id if net_tx_id is not None else tx_ids[0]
|
||||||
|
|
||||||
# ref is "listing_id:machine:uuid" (contracts/ledger-api.md) — the grant is
|
|
||||||
# keyed by the bare listing_id so GET /license/{op}/{listing} resolves.
|
|
||||||
listing_id = ref.split(":", 1)[0]
|
|
||||||
grant_id = f"gr_{uuid.uuid4().hex}"
|
grant_id = f"gr_{uuid.uuid4().hex}"
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -143,7 +140,7 @@ def record_install(
|
||||||
solution_version_major, tx_id, ts)
|
solution_version_major, tx_id, ts)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(grant_id, buyer, listing_id, 1, paying_tx_id, ts),
|
(grant_id, buyer, ref, 1, paying_tx_id, ts),
|
||||||
)
|
)
|
||||||
|
|
||||||
grant_row = conn.execute(
|
grant_row = conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
"""Daily balance snapshot → committed to the registry's audit/ (constitution V, T034).
|
|
||||||
|
|
||||||
The commit uses the Forgejo contents API: create the file, or update it (same-day
|
|
||||||
re-runs) with the current blob sha. Config via env: FORGEJO_URL, FORGEJO_TOKEN,
|
|
||||||
REGISTRY_REPO (default m2/market-registry), FORGEJO_USER (default m2).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def commit_snapshot(path: str, content: str) -> dict:
|
|
||||||
forgejo_url = os.environ.get("FORGEJO_URL", "https://git.machinemachine.ai").rstrip("/")
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
|
||||||
repo = os.environ.get("REGISTRY_REPO", "m2/market-registry")
|
|
||||||
user = os.environ.get("FORGEJO_USER", "m2")
|
|
||||||
if not token:
|
|
||||||
raise SnapshotError("FORGEJO_TOKEN not set — snapshot produced but not committed")
|
|
||||||
|
|
||||||
api = f"{forgejo_url}/api/v1/repos/{repo}/contents/{path}"
|
|
||||||
b64 = base64.b64encode(content.encode()).decode()
|
|
||||||
message = f"audit: ledger balance snapshot {path.rsplit('/', 1)[-1]}"
|
|
||||||
|
|
||||||
with httpx.Client(auth=(user, token), timeout=15.0) as client:
|
|
||||||
existing = client.get(api)
|
|
||||||
if existing.status_code == 200:
|
|
||||||
sha = existing.json().get("sha")
|
|
||||||
resp = client.put(api, json={"content": b64, "message": message, "sha": sha})
|
|
||||||
else:
|
|
||||||
resp = client.post(api, json={"content": b64, "message": message})
|
|
||||||
if resp.status_code not in (200, 201):
|
|
||||||
raise SnapshotError(f"registry commit failed: {resp.status_code} {resp.text[:200]}")
|
|
||||||
data = resp.json()
|
|
||||||
return {"path": path, "commit": (data.get("commit") or {}).get("sha")}
|
|
||||||
BIN
ledger/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc
Normal file
BIN
ledger/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -47,7 +47,6 @@ class LedgerMachine(RuleBasedStateMachine):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.any_refund = False
|
|
||||||
self.conn = models.init_db(":memory:")
|
self.conn = models.init_db(":memory:")
|
||||||
self.installed_refs = []
|
self.installed_refs = []
|
||||||
self._ref_counter = 0
|
self._ref_counter = 0
|
||||||
|
|
@ -95,7 +94,6 @@ class LedgerMachine(RuleBasedStateMachine):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
self.installed_refs.remove(ref)
|
self.installed_refs.remove(ref)
|
||||||
self.any_refund = True
|
|
||||||
|
|
||||||
@invariant()
|
@invariant()
|
||||||
def balances_match_independent_fold(self):
|
def balances_match_independent_fold(self):
|
||||||
|
|
@ -104,12 +102,6 @@ class LedgerMachine(RuleBasedStateMachine):
|
||||||
|
|
||||||
@invariant()
|
@invariant()
|
||||||
def no_non_mint_balance_goes_negative(self):
|
def no_non_mint_balance_goes_negative(self):
|
||||||
# Refund reversals are exempt: a seller who spent their earnings before
|
|
||||||
# the buyer's install was refunded legitimately goes negative (debt,
|
|
||||||
# settled at manual payout reconciliation — data-model.md §Transaction).
|
|
||||||
# Every OTHER path must keep non-mint balances >= 0.
|
|
||||||
if self.any_refund:
|
|
||||||
return
|
|
||||||
for account in _known_accounts(self.conn):
|
for account in _known_accounts(self.conn):
|
||||||
if account == "mint":
|
if account == "mint":
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
47
licenses/m2-market-standard-v1.md
Normal file
47
licenses/m2-market-standard-v1.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# M2 Market Standard License v1
|
||||||
|
|
||||||
|
This license applies to marketplace Solutions that reference
|
||||||
|
`license_id=lic_m2_market_standard_v1` and `terms=m2-market-standard-v1`.
|
||||||
|
|
||||||
|
## Grant
|
||||||
|
|
||||||
|
After successful ledger settlement, M2 grants the purchasing operator a non-exclusive internal
|
||||||
|
commercial license to install and use the purchased Solution for the covered major version.
|
||||||
|
License grants are keyed by `(operator_id, listing_id, major_version)`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The licensed operator may use the Solution inside owned or client workspaces where the operator is
|
||||||
|
authorized to work, subject to the listing's tenant visibility, permissions, and applicable tenant
|
||||||
|
data policy.
|
||||||
|
|
||||||
|
## Major-version coverage
|
||||||
|
|
||||||
|
The grant covers all minor and patch releases in the purchased major version. A new major version
|
||||||
|
requires a new grant unless the listing explicitly states otherwise.
|
||||||
|
|
||||||
|
## Internal modification
|
||||||
|
|
||||||
|
Internal modification is allowed for installation, configuration, debugging, and workflow
|
||||||
|
adaptation. Modified copies remain internal and do not change the seller's support obligations.
|
||||||
|
|
||||||
|
## No redistribution
|
||||||
|
|
||||||
|
Redistribution, resale, public mirroring, or repackaging as another marketplace listing is not
|
||||||
|
allowed without explicit written permission from the seller and platform operator.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Support is best effort. Listings may include verification commands, rollback notes, and evidence,
|
||||||
|
but no uptime, fitness, or outcome warranty is implied.
|
||||||
|
|
||||||
|
## Delisting and refunds
|
||||||
|
|
||||||
|
Delisting does not revoke existing active grants. Refunds for failed apply flows are handled by
|
||||||
|
append-only ledger refund rows and may mark the related grant as refunded.
|
||||||
|
|
||||||
|
## Secrets and tenant data
|
||||||
|
|
||||||
|
The license does not permit embedding secrets, raw client data, or tenant-private artifacts in
|
||||||
|
published bundles, telemetry, or shared evidence. Tenant-derived material must pass the required
|
||||||
|
owner-initiation and double-scrub controls before shared commercial use.
|
||||||
|
|
@ -5,7 +5,7 @@ description = "M2 Marketplace monorepo — schemas, ledger, CLI, indexer (spec 0
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
members = ["ledger", "cli", "indexer", "web/backend"]
|
members = ["ledger", "cli", "indexer"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,9 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.machinemachine.ai/m2/market-registry/schemas/listing.schema.json",
|
"$id": "https://git.machinemachine.ai/m2/m2-market/schemas/listing.schema.json",
|
||||||
"title": "Listing",
|
"title": "m2.listing.v1",
|
||||||
"description": "Catalog-facing record for a marketplace listing. Truth lives in m2/market-registry; derived copy indexed into market:catalog.",
|
"description": "Catalog-facing projection of a marketplace listing. Forgejo m2/m2-market is truth; market:catalog is rebuildable.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
|
||||||
"schema_version": {
|
|
||||||
"const": "m2.listing.v1"
|
|
||||||
},
|
|
||||||
"listing_id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^lst_[a-z0-9-]+$"
|
|
||||||
},
|
|
||||||
"solution_id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^sol_[a-z0-9-]+$"
|
|
||||||
},
|
|
||||||
"solution_version": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"inventory_type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["solution", "capability", "resource", "service"]
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"keywords": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"icon": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Denormalized from solution at listing time.",
|
|
||||||
"properties": {
|
|
||||||
"amount": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"const": "m2cr"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["amount", "currency", "model"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"seller": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "operator_id"
|
|
||||||
},
|
|
||||||
"evidence_summary": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"tenant_visibility": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"minItems": 1
|
|
||||||
},
|
|
||||||
"stats": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Conversion signals (FR-012), updated by telemetry, start zeroed.",
|
|
||||||
"properties": {
|
|
||||||
"installs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"default": 0
|
|
||||||
},
|
|
||||||
"rating": {
|
|
||||||
"type": "number",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 5
|
|
||||||
},
|
|
||||||
"proposals_shown": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"default": 0
|
|
||||||
},
|
|
||||||
"proposals_accepted": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"default": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["installs", "proposals_shown", "proposals_accepted"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["draft", "in_review", "published", "delisted"]
|
|
||||||
},
|
|
||||||
"install_ref": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^lst_[a-z0-9-]+-v\\d+\\.\\d+\\.\\d+$"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
"required": [
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"listing_id",
|
"listing_id",
|
||||||
|
|
@ -121,9 +15,133 @@
|
||||||
"category",
|
"category",
|
||||||
"price",
|
"price",
|
||||||
"seller",
|
"seller",
|
||||||
|
"content_hash",
|
||||||
"tenant_visibility",
|
"tenant_visibility",
|
||||||
"status",
|
"status",
|
||||||
"install_ref"
|
"install_ref"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"properties": {
|
||||||
|
"schema_version": { "const": "m2.listing.v1" },
|
||||||
|
"listing_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^lst_[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"solution_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sol_[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"solution_version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+([+-][0-9A-Za-z.-]+)?$"
|
||||||
|
},
|
||||||
|
"inventory_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["solution", "capability", "resource", "service"]
|
||||||
|
},
|
||||||
|
"name": { "type": "string", "minLength": 1 },
|
||||||
|
"summary": { "type": "string", "minLength": 1 },
|
||||||
|
"category": { "type": "string", "minLength": 1 },
|
||||||
|
"keywords": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"icon": { "type": "string" },
|
||||||
|
"price": { "$ref": "#/$defs/price" },
|
||||||
|
"seller": { "$ref": "#/$defs/seller" },
|
||||||
|
"evidence_summary": { "type": "string" },
|
||||||
|
"content_hash": { "$ref": "#/$defs/sha256" },
|
||||||
|
"tenant_visibility": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Allowed tenants. [\"*\"] means public. Other values must be canonical tenant ids from contracts/tenants.md."
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["installs", "proposals_shown", "proposals_accepted"],
|
||||||
|
"properties": {
|
||||||
|
"installs": { "type": "integer", "minimum": 0, "default": 0 },
|
||||||
|
"rating": { "type": "number", "minimum": 0, "maximum": 5 },
|
||||||
|
"proposals_shown": { "type": "integer", "minimum": 0, "default": 0 },
|
||||||
|
"proposals_accepted": { "type": "integer", "minimum": 0, "default": 0 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["draft", "in_review", "published", "delisted"]
|
||||||
|
},
|
||||||
|
"install_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^lst_[a-z0-9][a-z0-9-]*-v\\d+\\.\\d+\\.\\d+([+-][0-9A-Za-z.-]+)?$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$defs": {
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["model", "amount", "currency"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "fixed" },
|
||||||
|
"amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"currency": { "const": "m2cr" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["model", "currency", "quote", "settlement"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "job" },
|
||||||
|
"currency": { "const": "m2cr" },
|
||||||
|
"quote": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["min_amount", "max_amount", "expires_after_hours", "requires_acceptance"],
|
||||||
|
"properties": {
|
||||||
|
"min_amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"max_amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"expires_after_hours": { "type": "integer", "minimum": 1 },
|
||||||
|
"requires_acceptance": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"settlement": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["ledger_reason", "escrow_required"],
|
||||||
|
"properties": {
|
||||||
|
"ledger_reason": { "const": "job" },
|
||||||
|
"escrow_required": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"seller": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operator_id", "display_name"],
|
||||||
|
"properties": {
|
||||||
|
"operator_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"display_name": { "type": "string", "minLength": 1 },
|
||||||
|
"contact_ref": { "type": "string", "minLength": 1 },
|
||||||
|
"tenant_id": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,200 +1,9 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.machinemachine.ai/m2/market-registry/schemas/solution.schema.json",
|
"$id": "https://git.machinemachine.ai/m2/m2-market/schemas/solution.schema.json",
|
||||||
"title": "Solution",
|
"title": "m2.solution.v1",
|
||||||
"description": "The installable bundle manifest for the m2 marketplace. Frozen v1 contract (specs/001-market-first-wedge/data-model.md, research.md R3).",
|
"description": "Commercial Solution manifest. v1 is a marketplace superset of the fedlearn core fields: applicability, tenant_scope, evidence, content_hash, and scrub_status for tenant-derived work.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
|
||||||
"schema_version": {
|
|
||||||
"const": "m2.solution.v1"
|
|
||||||
},
|
|
||||||
"solution_id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^sol_[a-z0-9-]+$"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"intent": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"behavior": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"memory_schema": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"permissions": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "recipe.yaml ref + entrypoint + verify command",
|
|
||||||
"properties": {
|
|
||||||
"recipe_ref": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"entrypoint": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"verify_command": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["recipe_ref", "entrypoint", "verify_command"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"applicability": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "fedlearn-compatible applicability constraints",
|
|
||||||
"properties": {
|
|
||||||
"image_classes": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"roles": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tool_requirements": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"tenant_scope": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"description": "'m2-core' for shared/free, or a tenant id for tenant-scoped work"
|
|
||||||
},
|
|
||||||
"owner_initiated": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"scrub_status": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"double_scrubbed": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": true
|
|
||||||
},
|
|
||||||
"evidence": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"source": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"excerpt": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"machine": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
|
||||||
"wall_time": {
|
|
||||||
"type": "number",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["source"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content_hash": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"amount": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"const": "m2cr"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["fixed"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["amount", "currency", "model"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"seller": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"description": "operator_id"
|
|
||||||
},
|
|
||||||
"license": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"terms": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"major_version_coverage": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["terms", "major_version_coverage"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"revenue_split": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"platform_pct": {
|
|
||||||
"type": "number",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
"required": [
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"solution_id",
|
"solution_id",
|
||||||
|
|
@ -210,30 +19,238 @@
|
||||||
"seller",
|
"seller",
|
||||||
"license"
|
"license"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"properties": {
|
||||||
"if": {
|
"schema_version": { "const": "m2.solution.v1" },
|
||||||
"properties": {
|
"solution_id": {
|
||||||
"tenant_scope": {
|
"type": "string",
|
||||||
"const": "m2-core"
|
"pattern": "^sol_[a-z0-9][a-z0-9-]*$"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": ["tenant_scope"]
|
"name": { "type": "string", "minLength": 1 },
|
||||||
},
|
"summary": { "type": "string", "minLength": 1 },
|
||||||
"else": {
|
"description": { "type": "string" },
|
||||||
"properties": {
|
"intent": { "type": "string", "minLength": 1 },
|
||||||
"owner_initiated": {
|
"behavior": { "type": "object", "additionalProperties": true },
|
||||||
"const": true
|
"tools": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "object", "additionalProperties": true },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"runtime": { "type": "object", "additionalProperties": true },
|
||||||
|
"memory_schema": { "type": "object", "additionalProperties": true },
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/permission" },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["recipe_ref", "entrypoint", "verify_command"],
|
||||||
|
"properties": {
|
||||||
|
"recipe_ref": { "type": "string", "minLength": 1 },
|
||||||
|
"entrypoint": { "type": "string", "minLength": 1 },
|
||||||
|
"verify_command": { "type": "string", "minLength": 1 }
|
||||||
},
|
},
|
||||||
"scrub_status": {
|
"additionalProperties": false
|
||||||
"type": "object",
|
},
|
||||||
|
"applicability": { "$ref": "#/$defs/applicability" },
|
||||||
|
"tenant_scope": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Canonical tenant id from contracts/tenants.md. Use m2-core for shared commons/evidence."
|
||||||
|
},
|
||||||
|
"owner_initiated": { "type": "boolean" },
|
||||||
|
"scrub_status": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"double_scrubbed": { "type": "boolean" },
|
||||||
|
"reviewers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/evidence" }
|
||||||
|
},
|
||||||
|
"content_hash": { "$ref": "#/$defs/sha256" },
|
||||||
|
"price": { "$ref": "#/$defs/price" },
|
||||||
|
"seller": { "$ref": "#/$defs/seller" },
|
||||||
|
"license": { "$ref": "#/$defs/license" },
|
||||||
|
"revenue_split": { "$ref": "#/$defs/revenue_split" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": { "tenant_scope": { "const": "m2-core" } },
|
||||||
|
"required": ["tenant_scope"]
|
||||||
|
},
|
||||||
|
"else": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"double_scrubbed": {
|
"owner_initiated": { "const": true },
|
||||||
"const": true
|
"scrub_status": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["double_scrubbed"],
|
||||||
|
"properties": {
|
||||||
|
"double_scrubbed": { "const": true }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["double_scrubbed"]
|
"required": ["owner_initiated", "scrub_status"]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
},
|
},
|
||||||
"required": ["owner_initiated", "scrub_status"]
|
"applicability": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["image_classes", "roles", "tool_requirements"],
|
||||||
|
"properties": {
|
||||||
|
"image_classes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"tool_requirements": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source"],
|
||||||
|
"properties": {
|
||||||
|
"source": { "type": "string", "minLength": 1 },
|
||||||
|
"excerpt": { "type": "string" },
|
||||||
|
"machine": { "type": "string" },
|
||||||
|
"session": { "type": "string" },
|
||||||
|
"tokens": { "type": "integer", "minimum": 0 },
|
||||||
|
"wall_time": { "type": "number", "minimum": 0 },
|
||||||
|
"content_hash": { "$ref": "#/$defs/sha256" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["model", "amount", "currency"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "fixed" },
|
||||||
|
"amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"currency": { "const": "m2cr" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["model", "currency", "quote", "settlement"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "job" },
|
||||||
|
"currency": { "const": "m2cr" },
|
||||||
|
"quote": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["min_amount", "max_amount", "expires_after_hours", "requires_acceptance"],
|
||||||
|
"properties": {
|
||||||
|
"min_amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"max_amount": { "type": "integer", "minimum": 1 },
|
||||||
|
"expires_after_hours": { "type": "integer", "minimum": 1 },
|
||||||
|
"requires_acceptance": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"settlement": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["ledger_reason", "escrow_required"],
|
||||||
|
"properties": {
|
||||||
|
"ledger_reason": { "const": "job" },
|
||||||
|
"escrow_required": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"seller": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operator_id", "display_name"],
|
||||||
|
"properties": {
|
||||||
|
"operator_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"display_name": { "type": "string", "minLength": 1 },
|
||||||
|
"contact_ref": { "type": "string", "minLength": 1 },
|
||||||
|
"tenant_id": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["license_id", "terms", "scope", "major_version_coverage", "allows_internal_modification", "allows_redistribution", "support"],
|
||||||
|
"properties": {
|
||||||
|
"license_id": { "const": "lic_m2_market_standard_v1" },
|
||||||
|
"terms": { "const": "m2-market-standard-v1" },
|
||||||
|
"scope": { "const": "operator" },
|
||||||
|
"major_version_coverage": { "const": true },
|
||||||
|
"allows_internal_modification": { "const": true },
|
||||||
|
"allows_redistribution": { "const": false },
|
||||||
|
"support": { "const": "best_effort" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"revenue_split": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["platform_pct", "seller_pct"],
|
||||||
|
"properties": {
|
||||||
|
"platform_pct": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||||
|
"seller_pct": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||||
|
"contributors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operator_id", "pct"],
|
||||||
|
"properties": {
|
||||||
|
"operator_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^op_[a-z0-9][a-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"pct": { "type": "integer", "minimum": 1, "maximum": 100 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["permission_id", "kind", "access", "scope", "target"],
|
||||||
|
"properties": {
|
||||||
|
"permission_id": { "type": "string", "minLength": 1 },
|
||||||
|
"kind": { "type": "string", "minLength": 1 },
|
||||||
|
"access": { "type": "string", "minLength": 1 },
|
||||||
|
"scope": { "type": "string", "minLength": 1 },
|
||||||
|
"target": { "type": "string", "minLength": 1 },
|
||||||
|
"reason": { "type": "string" },
|
||||||
|
"secret_ref": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -12,7 +12,11 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core"
|
||||||
|
},
|
||||||
|
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
"tenant_visibility": ["*"],
|
"tenant_visibility": ["*"],
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core"
|
||||||
|
},
|
||||||
|
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
"status": "published",
|
"status": "published",
|
||||||
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
schemas/tests/fixtures/solution_bad_price.json
vendored
14
schemas/tests/fixtures/solution_bad_price.json
vendored
|
|
@ -26,9 +26,17 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
|
"license_id": "lic_m2_market_standard_v1",
|
||||||
"major_version_coverage": true
|
"terms": "m2-market-standard-v1",
|
||||||
|
"scope": "operator",
|
||||||
|
"major_version_coverage": true,
|
||||||
|
"allows_internal_modification": true,
|
||||||
|
"allows_redistribution": false,
|
||||||
|
"support": "best_effort"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,17 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
|
"license_id": "lic_m2_market_standard_v1",
|
||||||
"major_version_coverage": true
|
"terms": "m2-market-standard-v1",
|
||||||
|
"scope": "operator",
|
||||||
|
"major_version_coverage": true,
|
||||||
|
"allows_internal_modification": true,
|
||||||
|
"allows_redistribution": false,
|
||||||
|
"support": "best_effort"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,17 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
|
"license_id": "lic_m2_market_standard_v1",
|
||||||
"major_version_coverage": true
|
"terms": "m2-market-standard-v1",
|
||||||
|
"scope": "operator",
|
||||||
|
"major_version_coverage": true,
|
||||||
|
"allows_internal_modification": true,
|
||||||
|
"allows_redistribution": false,
|
||||||
|
"support": "best_effort"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
schemas/tests/fixtures/valid_listing.json
vendored
10
schemas/tests/fixtures/valid_listing.json
vendored
|
|
@ -8,14 +8,20 @@
|
||||||
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown",
|
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown",
|
||||||
"category": "reporting",
|
"category": "reporting",
|
||||||
"keywords": ["pdf", "report", "branded", "slides"],
|
"keywords": ["pdf", "report", "branded", "slides"],
|
||||||
"icon": "https://git.machinemachine.ai/m2/market-registry/assets/mm-pdf-report/icon.png",
|
"icon": "https://git.machinemachine.ai/m2/m2-market/assets/mm-pdf-report/icon.png",
|
||||||
"price": {
|
"price": {
|
||||||
"amount": 500,
|
"amount": 500,
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core",
|
||||||
|
"contact_ref": "forgejo:m2/m2-market",
|
||||||
|
"tenant_id": "m2"
|
||||||
|
},
|
||||||
"evidence_summary": "Generated branded PDF report for SDJS quarterly summary; operator confirmed styling matched brand deck.",
|
"evidence_summary": "Generated branded PDF report for SDJS quarterly summary; operator confirmed styling matched brand deck.",
|
||||||
|
"content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
"tenant_visibility": ["*"],
|
"tenant_visibility": ["*"],
|
||||||
"stats": {
|
"stats": {
|
||||||
"installs": 0,
|
"installs": 0,
|
||||||
|
|
|
||||||
27
schemas/tests/fixtures/valid_solution.json
vendored
27
schemas/tests/fixtures/valid_solution.json
vendored
|
|
@ -27,8 +27,12 @@
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
"scope": "filesystem:write",
|
"permission_id": "fs-write-output",
|
||||||
"path": "/tmp/mm-pdf-output"
|
"kind": "filesystem",
|
||||||
|
"access": "write",
|
||||||
|
"scope": "agent",
|
||||||
|
"target": "/tmp/mm-pdf-output",
|
||||||
|
"reason": "write rendered report output"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"deployment": {
|
"deployment": {
|
||||||
|
|
@ -58,12 +62,23 @@
|
||||||
"currency": "m2cr",
|
"currency": "m2cr",
|
||||||
"model": "fixed"
|
"model": "fixed"
|
||||||
},
|
},
|
||||||
"seller": "sdjs-operator",
|
"seller": {
|
||||||
|
"operator_id": "op_m2core",
|
||||||
|
"display_name": "M2 Core",
|
||||||
|
"contact_ref": "forgejo:m2/m2-market",
|
||||||
|
"tenant_id": "m2"
|
||||||
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"terms": "Single-operator install license; covers all machines under the buying operator_id.",
|
"license_id": "lic_m2_market_standard_v1",
|
||||||
"major_version_coverage": true
|
"terms": "m2-market-standard-v1",
|
||||||
|
"scope": "operator",
|
||||||
|
"major_version_coverage": true,
|
||||||
|
"allows_internal_modification": true,
|
||||||
|
"allows_redistribution": false,
|
||||||
|
"support": "best_effort"
|
||||||
},
|
},
|
||||||
"revenue_split": {
|
"revenue_split": {
|
||||||
"platform_pct": 10
|
"platform_pct": 10,
|
||||||
|
"seller_pct": 90
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@ def errors(validator, instance):
|
||||||
return list(validator.iter_errors(instance))
|
return list(validator.iter_errors(instance))
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_errors(errs):
|
||||||
|
flat = []
|
||||||
|
stack = list(errs)
|
||||||
|
while stack:
|
||||||
|
err = stack.pop()
|
||||||
|
flat.append(err)
|
||||||
|
stack.extend(err.context)
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
# --- valid fixtures ---------------------------------------------------------
|
# --- valid fixtures ---------------------------------------------------------
|
||||||
|
|
||||||
def test_valid_solution_passes():
|
def test_valid_solution_passes():
|
||||||
|
|
@ -76,10 +86,11 @@ def test_solution_tenant_violation_fails_on_else_branch():
|
||||||
def test_solution_bad_price_fails_on_price_amount_minimum():
|
def test_solution_bad_price_fails_on_price_amount_minimum():
|
||||||
errs = errors(solution_validator, fixture("solution_bad_price.json"))
|
errs = errors(solution_validator, fixture("solution_bad_price.json"))
|
||||||
assert errs, "expected validation errors"
|
assert errs, "expected validation errors"
|
||||||
|
flat = flatten_errors(errs)
|
||||||
assert any(
|
assert any(
|
||||||
e.validator == "minimum" and list(e.path) == ["price", "amount"]
|
e.validator == "minimum" and list(e.path) in (["price", "amount"], ["amount"])
|
||||||
for e in errs
|
for e in flat
|
||||||
), [(e.validator, list(e.path), e.message) for e in errs]
|
), [(e.validator, list(e.path), e.message) for e in flat]
|
||||||
|
|
||||||
|
|
||||||
# --- invalid listing fixtures ------------------------------------------------
|
# --- invalid listing fixtures ------------------------------------------------
|
||||||
|
|
|
||||||
218
scout/SPIKE.md
218
scout/SPIKE.md
|
|
@ -1,218 +0,0 @@
|
||||||
# Scout Host Spike (T041)
|
|
||||||
|
|
||||||
Time-boxed, read-mostly investigation of this host (m2, the machine this repo lives on)
|
|
||||||
to pick where the Solution Scout (Story 5 / FR-011) should live. Evaluates the three
|
|
||||||
options named in research.md §R6: (A) standalone supervised watcher, (B) Hermes plugin,
|
|
||||||
(C) herdr plugin/hook — against (a) access to structured summaries without raw
|
|
||||||
keystrokes, (b) opt-in enforcement point, (c) on-box summarization cost, (d) notification
|
|
||||||
surface, (e) update/rollout story across the `primus` and `agent-latest` image classes.
|
|
||||||
|
|
||||||
Note: `specs/S4-solution-scout.md` §5 already drafted a recommendation (Option A) before
|
|
||||||
this spike ran. This report re-derives the decision from concrete file/path evidence
|
|
||||||
gathered on the live host, and **overrides that draft** — see §5 for why.
|
|
||||||
|
|
||||||
## 0. Fleet context confirmed on this host
|
|
||||||
|
|
||||||
- Two desktop image classes exist, and they are **not** interchangeable for rollout:
|
|
||||||
- `primus` — built from `/home/m2/m2o/desktop/Dockerfile`, this repo's own supervisord
|
|
||||||
config, canary machine `chris-m2o`. System/image changes here mean re-baking primus
|
|
||||||
(`/home/m2/m2o/desktop/README.md:74`, `:95-104`).
|
|
||||||
- `agent-latest` — `ghcr.io/machine-machine/m2-desktop:agent-latest`, described
|
|
||||||
repeatedly in `/home/m2/m2o/.planning/federated-learning/*` as "legacy/Coolify",
|
|
||||||
a **different image lineage** this repo does not build
|
|
||||||
(`m2o/.planning/federated-learning/proposals/A4-propagation-rollout.md:5,17`).
|
|
||||||
- The federated-learning propagation ADR is explicit: *"image/system changes are baked
|
|
||||||
into `m2-desktop:primus`... This avoids pretending that `primus` and legacy/Coolify
|
|
||||||
`agent-latest` machines can be updated the same way"* (A4:5). Constitution VI says the
|
|
||||||
same: "runtime-sync is the only universal install path across mixed desktop images."
|
|
||||||
- Concretely on this host, every desktop-level supervised program (`hermes-gateway`,
|
|
||||||
`herdr-integrations`, `ttyd`) is wired in by `Dockerfile` `COPY ... >> supervisord.conf`
|
|
||||||
steps at **build time** (`m2o/desktop/Dockerfile:73-126`) into
|
|
||||||
`/etc/supervisor/conf.d/supervisord.conf`. That file is not part of any volume that
|
|
||||||
runtime-sync touches — it only exists post-bake, inside the image.
|
|
||||||
|
|
||||||
This single fact is the crux of the spike: **adding a new supervised process is an
|
|
||||||
image-bake operation**, reachable on `primus` (we own the Dockerfile) but not on
|
|
||||||
`agent-latest` (we don't own that image's build) without a separate, per-machine,
|
|
||||||
non-idempotent `docker exec` patch to a running container's supervisord config — exactly
|
|
||||||
the kind of fleet-wide-blast / non-reversible operation constitution VI rules out.
|
|
||||||
|
|
||||||
## 1. Option A — Standalone supervised watcher
|
|
||||||
|
|
||||||
Evidence:
|
|
||||||
|
|
||||||
- Pattern to copy: `hermes-gateway.supervisor.conf` (`m2o/desktop/hermes/hermes-gateway.supervisor.conf`)
|
|
||||||
— `[program:hermes-gateway]`, `autorestart=true`, `priority=45`, `sleep 12` boot stagger,
|
|
||||||
appended into the active `supervisord.conf` via `Dockerfile:79-82`.
|
|
||||||
- Adding `m2-solution-scout` the same way means a third `COPY ... >> supervisord.conf`
|
|
||||||
block in `m2o/desktop/Dockerfile`, i.e. a `primus` image cut (`VERSION=vN ./build.sh`,
|
|
||||||
README.md:99).
|
|
||||||
- **Rollout gap (the finding S4 missed):** there is no equivalent Dockerfile for
|
|
||||||
`agent-latest` in this repo — it's pulled pre-built from `ghcr.io`. Reaching it means
|
|
||||||
either waiting for whoever owns that image to add the program, or `docker exec`-patching
|
|
||||||
each running `agent-latest` container's `/etc/supervisor/conf.d/supervisord.conf` and
|
|
||||||
`supervisorctl update` by hand per machine. That is not idempotent/reversible/runtime-sync
|
|
||||||
in the sense constitution VI and the "Inherited Hard Constraints" section require.
|
|
||||||
|
|
||||||
Scoring: strongest privacy isolation (a dedicated process only reads what it's told to),
|
|
||||||
but the update/rollout story (e) is the weakest of the three on THIS fleet, not the
|
|
||||||
strongest as S4 assumed.
|
|
||||||
|
|
||||||
## 2. Option B — Hermes plugin
|
|
||||||
|
|
||||||
Evidence:
|
|
||||||
|
|
||||||
- Hermes already runs a real plugin on this host:
|
|
||||||
`/home/m2/.hermes/plugins/herdr-agent-state/{__init__.py,plugin.yaml}`, installed by
|
|
||||||
`herdr integration install hermes` (confirmed via `herdr integration` subcommand list;
|
|
||||||
this exact plugin ships with `# HERDR_INTEGRATION_ID=hermes` header). It registers
|
|
||||||
hooks via `ctx.register_hook(...)` for `on_session_start`, `pre_llm_call`,
|
|
||||||
`pre_tool_call`, `post_tool_call`, `pre_approval_request`, `post_approval_response`,
|
|
||||||
`post_llm_call`, `on_session_end`, `on_session_finalize` — reporting only lifecycle
|
|
||||||
state (`working|blocked|idle`) over the herdr Unix socket, never message content.
|
|
||||||
- Plugin hook docs (`/home/m2/.hermes/hermes-agent/website/docs/user-guide/features/hooks.md`)
|
|
||||||
show the two hooks a Scout would actually want:
|
|
||||||
- `post_llm_call(session_id, user_message, assistant_response, conversation_history,
|
|
||||||
model, platform, **kwargs)` — fires once per turn, **already gives structured,
|
|
||||||
per-turn intent content** (not raw keystrokes — it's the resolved message/response
|
|
||||||
text, the same thing Hermes itself just processed).
|
|
||||||
- `pre_llm_call(...) -> {"context": str}` — could be reused later to inject a
|
|
||||||
"here's a matching Solution" hint directly into the next turn instead of only toasting.
|
|
||||||
- Deployment is a pure file drop: `~/.hermes/plugins/m2-solution-scout/{__init__.py,plugin.yaml}`.
|
|
||||||
No supervisord, no image bake — this is exactly the "runtime fleet-sync agent into
|
|
||||||
persisted home volumes" path the federated-learning ADR (A4) calls out as the one
|
|
||||||
path that reaches BOTH image classes, **because Hermes itself is already supervised
|
|
||||||
on both** (hermes-gateway is the canonical per-desktop supervision pattern referenced
|
|
||||||
by this very task, and its plugin directory lives under `$HERMES_HOME` regardless of
|
|
||||||
which image booted it).
|
|
||||||
- Opt-in enforcement point: hooks are in-process Python: a `pull-policy.toml` read at
|
|
||||||
plugin `register()` time can make every hook a no-op when `scout.enabled=false`,
|
|
||||||
exactly like the CLAUDE.md-documented pattern for other opt-in config.
|
|
||||||
- On-box summarization cost: cheapest of the three — the plugin receives already-clean
|
|
||||||
turn text; no separate log-tailing/parsing/redaction pass over external files is needed
|
|
||||||
before summarizing, only redaction of the text it's handed directly (same
|
|
||||||
`redact_secrets`/`redact_client_identifiers` obligations from S4's `pull-policy.toml` draft).
|
|
||||||
- Notification surface: none built in — the plugin still needs to shell out to
|
|
||||||
`notify-send` (XFCE) or the herdr toast socket (see Option C) for the actual popup.
|
|
||||||
This is a small, one-file bridge either way.
|
|
||||||
|
|
||||||
Cons confirmed from the doc: plugin hooks run **in-process** with Hermes (same trust
|
|
||||||
boundary — a crashing hook is caught and logged per the docs, "never crashing the agent",
|
|
||||||
but a slow hook still adds latency to every turn). And it only sees Hermes sessions —
|
|
||||||
OpenClaw-only or bare-herdr (Claude Code/Codex panes with no Hermes chat active) sessions
|
|
||||||
are invisible to it.
|
|
||||||
|
|
||||||
## 3. Option C — herdr plugin/hook
|
|
||||||
|
|
||||||
Evidence:
|
|
||||||
|
|
||||||
- `herdr agent list` (run once, per task instructions) returns a live, structured JSON
|
|
||||||
lifecycle feed today: `{agent, agent_status: working|idle|done, cwd, pane_id,
|
|
||||||
workspace_id, ...}` for every pane across every workspace on the box — genuinely
|
|
||||||
"structured run/lifecycle summary," zero raw keystrokes, already running, no code to write.
|
|
||||||
- `~/.herdr/runs/*.md` (e.g. `2026-07-02-m2-market-first-wedge-gate.md`) are rich,
|
|
||||||
human/agent-authored narrative summaries — but they are a **manual convention** written
|
|
||||||
by an orchestrating session at the end of a herd run (see this repo's own recent runs),
|
|
||||||
not an automatic per-session artifact. Most single-agent sessions never produce one.
|
|
||||||
This makes Option C's "structured summary" source sparse and bursty rather than
|
|
||||||
continuous — a poor fit for "an operator is mid-session, starts building something... an
|
|
||||||
agent proposes the link right then" (CONCEPT.md §5).
|
|
||||||
- `~/.claude/hooks/herdr-agent-state.sh` (installed by `herdr integration install claude`)
|
|
||||||
shows the wire protocol: a Claude Code hook posts `pane.report_agent_session` over
|
|
||||||
`$HERDR_SOCKET_PATH` — again lifecycle-only, no content.
|
|
||||||
- `~/.config/herdr/config.toml` already has `[ui.toast] delivery = "herdr"` — confirms
|
|
||||||
herdr owns a native toast/notification surface today (this is also directly reusable
|
|
||||||
by Options A/B as the notification backend, independent of which host wins).
|
|
||||||
- `herdr integration` subcommands (`install/uninstall {pi,omp,claude,codex,copilot,
|
|
||||||
opencode,hermes,qodercli}`, `status`) show integrations are themselves file-drops under
|
|
||||||
`~/.claude/hooks/`, `~/.hermes/plugins/`, etc. — same runtime-sync-friendly deploy story
|
|
||||||
as Option B.
|
|
||||||
|
|
||||||
Scoring: best-in-class for (b) opt-in enforcement (a single socket message gate) and (d)
|
|
||||||
notification, but weakest for (a) — the only *continuous* signal herdr exposes natively is
|
|
||||||
coarse lifecycle state (working/idle/done + cwd), not intent. Getting real intent out of
|
|
||||||
herdr alone means depending on the sparse `runs/*.md` convention, which most sessions
|
|
||||||
don't produce.
|
|
||||||
|
|
||||||
## 4. Scored comparison
|
|
||||||
|
|
||||||
| Criterion | A: Standalone watcher | B: Hermes plugin | C: herdr plugin/hook |
|
|
||||||
|---|---|---|---|
|
|
||||||
| (a) structured intent w/o raw keystrokes | Depends entirely on tap sources it's given — none exist continuously today | **Best** — `post_llm_call` gives per-turn content already, every turn | Weak — only continuous signal is lifecycle state; real intent needs sparse `runs/*.md` |
|
|
||||||
| (b) opt-in enforcement point | Own config file, own gate — clean but net-new | Clean — gate inside `register()` before any hook does work | **Best** — one socket message, herdr already gates its own integrations |
|
|
||||||
| (c) on-box summarization cost | Highest — must tail/parse/redact multiple external files first | **Lowest** — handed clean text directly | Low for lifecycle, but high if it also has to parse `runs/*.md` |
|
|
||||||
| (d) notification surface | Must shell out (herdr toast or XFCE) | Must shell out (herdr toast or XFCE) | **Native** — herdr toast is its own surface, `[ui.toast]` already configured |
|
|
||||||
| (e) update/rollout, both image classes | **Weakest** — new supervisor program = image bake; no equivalent Dockerfile for `agent-latest` | **Best** — pure file drop under `$HERMES_HOME`, works wherever Hermes already runs (both classes) | Good — file drop under `~/.claude/hooks`/`~/.hermes/plugins` via `herdr integration install`, but only reaches herdr-managed panes |
|
|
||||||
|
|
||||||
## 5. Recommendation
|
|
||||||
|
|
||||||
**DECISION: Hermes plugin (Option B)**
|
|
||||||
|
|
||||||
Rationale: FR-011 and Story 5's hardest constraints are (1) real per-turn intent
|
|
||||||
without raw keystrokes, and (5) a rollout story that doesn't require rebuilding either
|
|
||||||
image class. Concrete evidence on this host shows Option A fails (5) outright — the only
|
|
||||||
mechanism this repo has for registering a new supervised program is a `Dockerfile` COPY
|
|
||||||
into `supervisord.conf`, which has no `agent-latest` equivalent, making that option
|
|
||||||
image-bake-only on one of the two live classes. Option C is native for opt-in/notification
|
|
||||||
but its only continuous, low-cost signal is coarse lifecycle state — it would need Option
|
|
||||||
B's or Option A's tap to get real intent anyway. Option B satisfies (1) directly
|
|
||||||
(`post_llm_call` hands over already-resolved turn text, cheapest to summarize-on-box), and
|
|
||||||
(5) matches proven fact: this host already runtime-installs a real Hermes plugin
|
|
||||||
(`herdr-agent-state`) as a pure file drop, no image rebuild, and Hermes is the one
|
|
||||||
component already supervised identically across both `primus` and `agent-latest`
|
|
||||||
(that's the same "hermes-gateway pattern" this task was pointed at as reference).
|
|
||||||
|
|
||||||
Caveat worth carrying into T042: a Hermes-hosted Scout is blind to bare-herdr sessions
|
|
||||||
that never touch Hermes chat (a Claude Code/Codex pane with no Hermes turn). This does
|
|
||||||
**not** violate the "Hermes first, never Hermes-only" inherited constraint — that
|
|
||||||
constraint is about marketplace touchpoints in general (Store/CLI/catalog remain
|
|
||||||
Hermes-independent), and Story 5's own acceptance criteria only require discovery "via
|
|
||||||
Scout **or** Hermes/CLI." It is, however, a real coverage gap: pure-herdr desktops get no
|
|
||||||
push-side discovery in v0. Cheap mitigation for a later phase, not blocking: also register
|
|
||||||
`herdr integration install hermes`'s existing socket report as a secondary opt-in trigger
|
|
||||||
so the same plugin can request the herdr toast surface (`[ui.toast] delivery = "herdr"`)
|
|
||||||
instead of shelling to `notify-send`, without adding a second host.
|
|
||||||
|
|
||||||
## 6. v0 implementation sketch
|
|
||||||
|
|
||||||
Components:
|
|
||||||
|
|
||||||
- `~/.hermes/plugins/m2-solution-scout/`
|
|
||||||
- `plugin.yaml` — `name: m2-solution-scout`, `description: Solution Scout intent watch`.
|
|
||||||
- `__init__.py` — `register(ctx)` wires `on_session_start` (load `pull-policy.toml`,
|
|
||||||
no-op immediately if `scout.enabled=false` or `tenant_id` missing — fail closed, per
|
|
||||||
S4's policy rules) and `post_llm_call` (the intent tap).
|
|
||||||
- Config: reuse S4's existing `pull-policy.toml` schema verbatim
|
|
||||||
(`~/.config/m2-market/pull-policy.toml`, `[scout]` block, `raw_keystrokes`/
|
|
||||||
`raw_client_data` reserved-deny keys, `max_proposals_per_day`, cooldowns). No new
|
|
||||||
config format — the plugin is a new *host* for the same contract S4 already specified.
|
|
||||||
- State/telemetry: same paths as S4 §3 (`~/.local/share/m2-market/scout/state.json`,
|
|
||||||
`outbox/YYYY-MM-DD.jsonl`) — the plugin process just runs inside Hermes instead of as
|
|
||||||
its own daemon.
|
|
||||||
|
|
||||||
Event flow (per turn, inside `post_llm_call`):
|
|
||||||
|
|
||||||
1. Gate: `scout.enabled` true, tenant_id present, rate cap and per-session cap not yet
|
|
||||||
hit, cooldown not active for the last dismissed listing → else return immediately.
|
|
||||||
2. Take `user_message` + `assistant_response` (bounded to `summary.max_source_chars`),
|
|
||||||
redact secrets/client identifiers per `pull-policy.toml`, produce
|
|
||||||
`m2.scout.intent_summary.v1` (S4 schema, unchanged) — deterministic extractive summary
|
|
||||||
in v0, no extra LLM call needed since the plugin already has clean turn text.
|
|
||||||
3. `POST /memory/search` to memory-api, `agent_id=market:catalog`, tenant-filtered (S4 §3
|
|
||||||
catalog query, unchanged).
|
|
||||||
4. Score/coverage threshold check (S4's `m2.scout.match.v1`) → if it clears, build
|
|
||||||
`m2.scout.proposal.v1` and fire the toast (`notify-send` primary, herdr socket toast as
|
|
||||||
fallback/secondary surface) with the M2 Store deep link.
|
|
||||||
5. Write `proposal_shown` telemetry to local outbox (batched flush to `market:evidence`,
|
|
||||||
same as S4 §3/§4) — Scout never calls `m2-market install` itself; Store/CLI own
|
|
||||||
debit→grant→apply and emit the accept/dismiss events this plugin later folds back in.
|
|
||||||
6. All of steps 2-5 run off the hot path where possible (background thread, mirroring the
|
|
||||||
`boot-md` hook tutorial's pattern of not blocking the turn on non-essential work) so a
|
|
||||||
slow catalog query never adds latency to the operator's actual turn.
|
|
||||||
|
|
||||||
Guardrails carried over unchanged from FR-011 / S4: default `enabled=false`; opt-in is
|
|
||||||
per-desktop `pull-policy.toml`; `raw_keystrokes`/`raw_client_data` keys hard-refuse start
|
|
||||||
if true; rate cap default 5/day; propose-only — install always requires the human's click
|
|
||||||
through M2 Store.
|
|
||||||
|
|
||||||
DECISION: Hermes plugin
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PLUGIN_SRC="${SRC_DIR}/plugin/m2-solution-scout"
|
|
||||||
PLUGIN_DST="${HOME}/.hermes/plugins/m2-solution-scout"
|
|
||||||
CONFIG_DIR="${HOME}/.config/m2-market"
|
|
||||||
CONFIG_PATH="${CONFIG_DIR}/pull-policy.toml"
|
|
||||||
|
|
||||||
mkdir -p "${PLUGIN_DST}" "${CONFIG_DIR}"
|
|
||||||
install -m 0644 "${PLUGIN_SRC}/plugin.yaml" "${PLUGIN_DST}/plugin.yaml"
|
|
||||||
install -m 0644 "${PLUGIN_SRC}/__init__.py" "${PLUGIN_DST}/__init__.py"
|
|
||||||
|
|
||||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
|
||||||
install -m 0600 "${SRC_DIR}/pull-policy.example.toml" "${CONFIG_PATH}"
|
|
||||||
echo "Created disabled config scaffold: ${CONFIG_PATH}"
|
|
||||||
else
|
|
||||||
echo "Config already exists, left unchanged: ${CONFIG_PATH}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installed Hermes plugin: ${PLUGIN_DST}"
|
|
||||||
echo "Opt in by editing ${CONFIG_PATH}: set scout.enabled=true and scout.tenant_id to this desktop tenant."
|
|
||||||
|
|
@ -1,537 +0,0 @@
|
||||||
"""Hermes plugin for M2 Solution Scout v0.
|
|
||||||
|
|
||||||
The plugin is intentionally fail-closed: no policy opt-in means no hooks do
|
|
||||||
anything beyond loading local config, and raw-source deny keys refuse startup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as _dt
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
POLICY_PATH = Path("~/.config/m2-market/pull-policy.toml").expanduser()
|
|
||||||
STATE_DIR = Path("~/.local/share/m2-market/scout").expanduser()
|
|
||||||
STATE_PATH = STATE_DIR / "state.json"
|
|
||||||
OUTBOX_DIR = STATE_DIR / "outbox"
|
|
||||||
|
|
||||||
_SECRET_PATTERNS = [
|
|
||||||
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
|
|
||||||
re.compile(r"(?i)\b(api[_ -]?key|secret|token)\s*[:=]\s*['\"]?[^'\"\s]{8,}"),
|
|
||||||
]
|
|
||||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
|
||||||
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}")
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyError(RuntimeError):
|
|
||||||
"""Policy explicitly requests an unsafe mode."""
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> _dt.datetime:
|
|
||||||
return _dt.datetime.now(_dt.timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(ts: _dt.datetime | None = None) -> str:
|
|
||||||
return (ts or _utcnow()).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_scalar(value: str) -> Any:
|
|
||||||
value = value.strip()
|
|
||||||
if not value or value.startswith("#"):
|
|
||||||
return ""
|
|
||||||
if "#" in value and not value.lstrip().startswith(("'", '"')):
|
|
||||||
value = value.split("#", 1)[0].strip()
|
|
||||||
if value in {"true", "false"}:
|
|
||||||
return value == "true"
|
|
||||||
if (value.startswith('"') and value.endswith('"')) or (
|
|
||||||
value.startswith("'") and value.endswith("'")
|
|
||||||
):
|
|
||||||
return value[1:-1]
|
|
||||||
if value.startswith("[") and value.endswith("]"):
|
|
||||||
inner = value[1:-1].strip()
|
|
||||||
if not inner:
|
|
||||||
return []
|
|
||||||
return [_parse_scalar(part.strip()) for part in inner.split(",")]
|
|
||||||
try:
|
|
||||||
if "." in value:
|
|
||||||
return float(value)
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_toml(text: str) -> dict[str, Any]:
|
|
||||||
"""Tiny TOML subset parser, with optional tomli avoided for Python 3.10 hosts."""
|
|
||||||
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
current = data
|
|
||||||
for raw in text.splitlines():
|
|
||||||
line = raw.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if line.startswith("[") and line.endswith("]"):
|
|
||||||
current = data
|
|
||||||
for part in line[1:-1].split("."):
|
|
||||||
current = current.setdefault(part.strip(), {})
|
|
||||||
continue
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
current[key.strip()] = _parse_scalar(value)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _read_toml(path: Path) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
import tomli # type: ignore
|
|
||||||
except Exception:
|
|
||||||
tomli = None
|
|
||||||
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
raw = path.read_bytes()
|
|
||||||
if tomli is not None:
|
|
||||||
return tomli.loads(raw.decode("utf-8"))
|
|
||||||
return parse_toml(raw.decode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _section(data: dict[str, Any], *names: str) -> dict[str, Any]:
|
|
||||||
node: Any = data
|
|
||||||
for name in names:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
return {}
|
|
||||||
node = node.get(name, {})
|
|
||||||
return node if isinstance(node, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]:
|
|
||||||
raw = _read_toml(path)
|
|
||||||
scout = _section(raw, "scout")
|
|
||||||
sources = _section(raw, "scout", "sources")
|
|
||||||
summary = _section(raw, "scout", "summary")
|
|
||||||
catalog = _section(raw, "scout", "catalog")
|
|
||||||
store = _section(raw, "scout", "store")
|
|
||||||
telemetry = _section(raw, "scout", "telemetry")
|
|
||||||
|
|
||||||
enabled = bool(scout.get("enabled", False))
|
|
||||||
tenant_id = str(scout.get("tenant_id", "") or "").strip()
|
|
||||||
if sources.get("raw_keystrokes") is True or sources.get("raw_client_data") is True:
|
|
||||||
raise PolicyError("Solution Scout refuses raw_keystrokes/raw_client_data policy")
|
|
||||||
if not enabled or not tenant_id:
|
|
||||||
return {"enabled": False, "reason": "disabled_or_missing_tenant"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"mode": scout.get("mode", "suggest"),
|
|
||||||
"desktop_id": str(scout.get("desktop_id", "unknown-desktop")),
|
|
||||||
"operator_id": str(scout.get("operator_id", "unknown-operator")),
|
|
||||||
"tenant_id": tenant_id,
|
|
||||||
"min_score": float(scout.get("min_score", 0.78)),
|
|
||||||
"min_coverage": float(scout.get("min_coverage", 0.55)),
|
|
||||||
"max_proposals_per_day": int(scout.get("max_proposals_per_day", 5)),
|
|
||||||
"max_proposals_per_session": int(scout.get("max_proposals_per_session", 1)),
|
|
||||||
"dismiss_cooldown_days": int(scout.get("dismiss_cooldown_days", 14)),
|
|
||||||
"proposal_cooldown_minutes": int(scout.get("proposal_cooldown_minutes", 45)),
|
|
||||||
"summary": {
|
|
||||||
"max_source_chars": int(summary.get("max_source_chars", 12000)),
|
|
||||||
"max_summary_chars": int(summary.get("max_summary_chars", 900)),
|
|
||||||
"redact_secrets": bool(summary.get("redact_secrets", True)),
|
|
||||||
"redact_client_identifiers": bool(summary.get("redact_client_identifiers", True)),
|
|
||||||
"fail_closed_on_secret": bool(summary.get("fail_closed_on_secret", True)),
|
|
||||||
},
|
|
||||||
"catalog": {
|
|
||||||
"memory_api_url": str(catalog.get("memory_api_url", "https://memory.machinemachine.ai")),
|
|
||||||
"agent_id": str(catalog.get("agent_id", "market:catalog")),
|
|
||||||
"limit": int(catalog.get("limit", 5)),
|
|
||||||
"routing_strategy": str(catalog.get("routing_strategy", "standard")),
|
|
||||||
},
|
|
||||||
"store": {
|
|
||||||
"deeplink_scheme": str(store.get("deeplink_scheme", "m2store")),
|
|
||||||
"fallback_command": str(store.get("fallback_command", "m2-market show --json")),
|
|
||||||
},
|
|
||||||
"telemetry": {"enabled": bool(telemetry.get("enabled", True))},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def redact(text: str, *, redact_client_identifiers: bool = True) -> tuple[str, dict[str, bool]]:
|
|
||||||
found_secret = False
|
|
||||||
redacted = text
|
|
||||||
for pattern in _SECRET_PATTERNS:
|
|
||||||
redacted, count = pattern.subn("[REDACTED_SECRET]", redacted)
|
|
||||||
found_secret = found_secret or count > 0
|
|
||||||
found_client = False
|
|
||||||
if redact_client_identifiers:
|
|
||||||
redacted, count = _EMAIL_RE.subn("[REDACTED_EMAIL]", redacted)
|
|
||||||
found_client = count > 0
|
|
||||||
return redacted, {
|
|
||||||
"secrets_redacted": found_secret,
|
|
||||||
"client_identifiers_redacted": found_client,
|
|
||||||
"failed_closed": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_turn(user_message: str, assistant_response: str, policy: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
summary_policy = policy["summary"]
|
|
||||||
source = (user_message + "\n" + assistant_response)[: summary_policy["max_source_chars"]]
|
|
||||||
clean, scrub = redact(
|
|
||||||
source,
|
|
||||||
redact_client_identifiers=summary_policy.get("redact_client_identifiers", True),
|
|
||||||
)
|
|
||||||
sentences = re.split(r"(?<=[.!?])\s+", " ".join(clean.split()))
|
|
||||||
summary = " ".join(sentences[:3]).strip()
|
|
||||||
if len(summary) > summary_policy["max_summary_chars"]:
|
|
||||||
summary = summary[: summary_policy["max_summary_chars"]].rsplit(" ", 1)[0].strip()
|
|
||||||
terms = []
|
|
||||||
for word in _WORD_RE.findall(summary.lower()):
|
|
||||||
if word in {"the", "and", "with", "this", "that", "from", "have", "will", "into"}:
|
|
||||||
continue
|
|
||||||
if word not in terms:
|
|
||||||
terms.append(word)
|
|
||||||
if len(terms) >= 12:
|
|
||||||
break
|
|
||||||
intent_hash = "sha256:" + hashlib.sha256(summary.encode("utf-8")).hexdigest()
|
|
||||||
return {
|
|
||||||
"schema_version": "m2.scout.intent_summary.v1",
|
|
||||||
"summary_id": _id("sum", policy),
|
|
||||||
"observation_ids": [],
|
|
||||||
"desktop_id": policy["desktop_id"],
|
|
||||||
"operator_id": policy["operator_id"],
|
|
||||||
"tenant_id": policy["tenant_id"],
|
|
||||||
"session_id": "",
|
|
||||||
"summary": summary,
|
|
||||||
"intent_terms": terms,
|
|
||||||
"negative_terms": [],
|
|
||||||
"scrub_status": scrub,
|
|
||||||
"intent_hash": intent_hash,
|
|
||||||
"created_at": _iso(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _id(prefix: str, policy: dict[str, Any]) -> str:
|
|
||||||
stamp = _utcnow().strftime("%Y%m%d")
|
|
||||||
desktop = re.sub(r"[^A-Za-z0-9]+", "", policy.get("desktop_id", "desktop"))[:12] or "desktop"
|
|
||||||
return f"{prefix}_{stamp}_{desktop}_{random.randrange(36**4):04x}"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_state(path: Path = STATE_PATH) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return {"daily": {}, "sessions": {}, "dismissed": {}, "last_proposal_at": None}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_state(state: dict[str, Any], path: Path = STATE_PATH) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(state, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
|
||||||
tmp.replace(path)
|
|
||||||
|
|
||||||
|
|
||||||
def gate(policy: dict[str, Any], state: dict[str, Any], *, session_id: str = "") -> tuple[bool, str | None]:
|
|
||||||
if not policy.get("enabled"):
|
|
||||||
return False, "disabled"
|
|
||||||
today = _utcnow().date().isoformat()
|
|
||||||
daily_count = int(state.get("daily", {}).get(today, 0))
|
|
||||||
if daily_count >= policy["max_proposals_per_day"]:
|
|
||||||
return False, "rate_capped"
|
|
||||||
if session_id:
|
|
||||||
session_count = int(state.get("sessions", {}).get(session_id, 0))
|
|
||||||
if session_count >= policy["max_proposals_per_session"]:
|
|
||||||
return False, "session_capped"
|
|
||||||
last = state.get("last_proposal_at")
|
|
||||||
if last:
|
|
||||||
try:
|
|
||||||
last_ts = _dt.datetime.fromisoformat(str(last).replace("Z", "+00:00"))
|
|
||||||
cooldown = _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])
|
|
||||||
if _utcnow() - last_ts < cooldown:
|
|
||||||
return False, "proposal_cooldown"
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
def _memory_api_key() -> str:
|
|
||||||
if os.environ.get("M2_MEMORY_API_KEY"):
|
|
||||||
return os.environ["M2_MEMORY_API_KEY"]
|
|
||||||
config = _read_toml(Path("~/.m2-market/config.toml").expanduser())
|
|
||||||
memory = _section(config, "memory")
|
|
||||||
for key in ("api_key", "memory_api_key", "token"):
|
|
||||||
value = memory.get(key) or config.get(key)
|
|
||||||
if value:
|
|
||||||
return str(value)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _visible(listing: dict[str, Any], tenant_id: str) -> bool:
|
|
||||||
visibility = listing.get("tenant_visibility") or []
|
|
||||||
status = listing.get("status", "published")
|
|
||||||
return status == "published" and ("*" in visibility or tenant_id in visibility)
|
|
||||||
|
|
||||||
|
|
||||||
def search_catalog(summary: dict[str, Any], policy: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
catalog = policy["catalog"]
|
|
||||||
headers = {}
|
|
||||||
api_key = _memory_api_key()
|
|
||||||
if api_key:
|
|
||||||
headers["X-API-Key"] = api_key
|
|
||||||
with httpx.Client(base_url=catalog["memory_api_url"], headers=headers, timeout=10.0) as client:
|
|
||||||
response = client.post(
|
|
||||||
"/memory/search",
|
|
||||||
json={
|
|
||||||
"query": summary["summary"],
|
|
||||||
"agent_id": catalog["agent_id"],
|
|
||||||
"routing_strategy": catalog["routing_strategy"],
|
|
||||||
"limit": max(catalog["limit"] * 3, catalog["limit"]),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
items = []
|
|
||||||
for result in response.json().get("results", []):
|
|
||||||
meta = result.get("metadata") or {}
|
|
||||||
listing = meta.get("listing") or {}
|
|
||||||
listing_id = meta.get("listing_id") or listing.get("listing_id") or listing.get("id")
|
|
||||||
if not listing_id or not _visible(listing, policy["tenant_id"]):
|
|
||||||
continue
|
|
||||||
score = float(result.get("score") or 0.0)
|
|
||||||
coverage = _estimate_coverage(summary, listing)
|
|
||||||
if score < policy["min_score"] or coverage < policy["min_coverage"]:
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"listing_id": listing_id,
|
|
||||||
"solution_id": listing.get("solution_id", ""),
|
|
||||||
"score": score,
|
|
||||||
"coverage": coverage,
|
|
||||||
"listing": listing,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(items) >= catalog["limit"]:
|
|
||||||
break
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _estimate_coverage(summary: dict[str, Any], listing: dict[str, Any]) -> float:
|
|
||||||
terms = set(summary.get("intent_terms") or [])
|
|
||||||
haystack = " ".join(
|
|
||||||
str(listing.get(k, "")) for k in ("name", "summary", "description", "category")
|
|
||||||
).lower()
|
|
||||||
keywords = listing.get("keywords") or []
|
|
||||||
if isinstance(keywords, list):
|
|
||||||
haystack += " " + " ".join(str(k).lower() for k in keywords)
|
|
||||||
if not terms:
|
|
||||||
return 0.0
|
|
||||||
hits = sum(1 for term in terms if term in haystack)
|
|
||||||
return round(min(1.0, hits / max(3, len(terms))), 2)
|
|
||||||
|
|
||||||
|
|
||||||
def build_proposal(match: dict[str, Any], summary: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
listing = match["listing"]
|
|
||||||
proposal_id = _id("prp", policy)
|
|
||||||
match_id = _id("mat", policy)
|
|
||||||
listing_id = match["listing_id"]
|
|
||||||
deeplink = (
|
|
||||||
f"{policy['store']['deeplink_scheme']}://listing/{quote(listing_id)}"
|
|
||||||
f"?proposal_id={quote(proposal_id)}&source=scout"
|
|
||||||
)
|
|
||||||
now = _utcnow()
|
|
||||||
return {
|
|
||||||
"schema_version": "m2.scout.proposal.v1",
|
|
||||||
"proposal_id": proposal_id,
|
|
||||||
"match_id": match_id,
|
|
||||||
"desktop_id": policy["desktop_id"],
|
|
||||||
"operator_id": policy["operator_id"],
|
|
||||||
"tenant_id": policy["tenant_id"],
|
|
||||||
"listing": {
|
|
||||||
"listing_id": listing_id,
|
|
||||||
"name": listing.get("name", listing_id),
|
|
||||||
"summary": listing.get("summary", ""),
|
|
||||||
"price": listing.get("price", {}),
|
|
||||||
"stats": listing.get("stats", {}),
|
|
||||||
},
|
|
||||||
"confidence": match["score"],
|
|
||||||
"coverage": match["coverage"],
|
|
||||||
"deeplink": deeplink,
|
|
||||||
"shown_at": _iso(now),
|
|
||||||
"expires_at": _iso(now + _dt.timedelta(minutes=policy["proposal_cooldown_minutes"])),
|
|
||||||
"_telemetry": {
|
|
||||||
"session_id": summary.get("session_id", ""),
|
|
||||||
"intent_hash": summary["intent_hash"],
|
|
||||||
"solution_id": match.get("solution_id", ""),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def write_proposal_shown(proposal: dict[str, Any], outbox_dir: Path = OUTBOX_DIR) -> dict[str, Any]:
|
|
||||||
telemetry = proposal.pop("_telemetry", {})
|
|
||||||
event = {
|
|
||||||
"schema_version": "m2.scout.telemetry.v1",
|
|
||||||
"event_id": _id("evt", proposal),
|
|
||||||
"event_type": "proposal_shown",
|
|
||||||
"proposal_id": proposal["proposal_id"],
|
|
||||||
"listing_id": proposal["listing"]["listing_id"],
|
|
||||||
"solution_id": telemetry.get("solution_id", ""),
|
|
||||||
"operator_id": proposal["operator_id"],
|
|
||||||
"desktop_id": proposal["desktop_id"],
|
|
||||||
"tenant_id": proposal["tenant_id"],
|
|
||||||
"session_id": telemetry.get("session_id", ""),
|
|
||||||
"intent_hash": telemetry.get("intent_hash", ""),
|
|
||||||
"score": proposal["confidence"],
|
|
||||||
"coverage": proposal["coverage"],
|
|
||||||
"reason": None,
|
|
||||||
"created_at": proposal["shown_at"],
|
|
||||||
"proposal": proposal,
|
|
||||||
}
|
|
||||||
outbox_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = outbox_dir / f"{_utcnow().date().isoformat()}.jsonl"
|
|
||||||
with path.open("a", encoding="utf-8") as fh:
|
|
||||||
fh.write(json.dumps(event, sort_keys=True) + "\n")
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def notify(proposal: dict[str, Any]) -> None:
|
|
||||||
listing = proposal["listing"]
|
|
||||||
price = listing.get("price") or {}
|
|
||||||
amount = price.get("amount", "?")
|
|
||||||
currency = price.get("currency", "m2cr")
|
|
||||||
body = (
|
|
||||||
f"This looks like {listing['name']}: ~{int(proposal['coverage'] * 100)}% coverage, "
|
|
||||||
f"{amount} {currency}. Open in M2 Store?"
|
|
||||||
)
|
|
||||||
if shutil.which("notify-send"):
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
[
|
|
||||||
"notify-send",
|
|
||||||
"M2 Solution Scout",
|
|
||||||
body,
|
|
||||||
f"--action=open=Open",
|
|
||||||
f"--action=dismiss=Dismiss",
|
|
||||||
],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_herdr_toast(body, proposal["deeplink"])
|
|
||||||
|
|
||||||
|
|
||||||
def _herdr_toast(message: str, deeplink: str) -> None:
|
|
||||||
socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip()
|
|
||||||
pane_id = os.environ.get("HERDR_PANE_ID", "").strip()
|
|
||||||
if not socket_path or not pane_id:
|
|
||||||
return
|
|
||||||
request = {
|
|
||||||
"id": f"m2-solution-scout:{int(time.time() * 1000)}",
|
|
||||||
"method": "pane.toast",
|
|
||||||
"params": {
|
|
||||||
"pane_id": pane_id,
|
|
||||||
"source": "m2-solution-scout",
|
|
||||||
"title": "M2 Solution Scout",
|
|
||||||
"message": message,
|
|
||||||
"deeplink": deeplink,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
client.settimeout(0.5)
|
|
||||||
client.connect(socket_path)
|
|
||||||
client.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
|
||||||
client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _bump_state(
|
|
||||||
state: dict[str, Any], policy: dict[str, Any], session_id: str, proposal: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
today = _utcnow().date().isoformat()
|
|
||||||
state.setdefault("daily", {})[today] = int(state.setdefault("daily", {}).get(today, 0)) + 1
|
|
||||||
if session_id:
|
|
||||||
state.setdefault("sessions", {})[session_id] = int(
|
|
||||||
state.setdefault("sessions", {}).get(session_id, 0)
|
|
||||||
) + 1
|
|
||||||
state["last_proposal_at"] = _iso()
|
|
||||||
state["last_proposal"] = {
|
|
||||||
"proposal_id": proposal["proposal_id"],
|
|
||||||
"listing_id": proposal["listing"]["listing_id"],
|
|
||||||
"cooldown_until": proposal["expires_at"],
|
|
||||||
}
|
|
||||||
state.setdefault("proposals", []).append(state["last_proposal"])
|
|
||||||
_save_state(state)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_turn(kwargs: dict[str, Any]) -> tuple[str, str, str]:
|
|
||||||
user_message = str(kwargs.get("user_message") or kwargs.get("prompt") or "")
|
|
||||||
assistant_response = str(kwargs.get("assistant_response") or kwargs.get("response") or "")
|
|
||||||
if not user_message:
|
|
||||||
messages = kwargs.get("messages")
|
|
||||||
if isinstance(messages, list):
|
|
||||||
user_message = "\n".join(
|
|
||||||
str(m.get("content", "")) for m in messages if isinstance(m, dict) and m.get("role") == "user"
|
|
||||||
)
|
|
||||||
session_id = str(kwargs.get("session_id") or "")
|
|
||||||
return user_message, assistant_response, session_id
|
|
||||||
|
|
||||||
|
|
||||||
class ScoutRuntime:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.policy: dict[str, Any] = {"enabled": False}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def on_session_start(self, **kwargs: Any) -> None:
|
|
||||||
del kwargs
|
|
||||||
self.policy = load_policy()
|
|
||||||
|
|
||||||
def post_llm_call(self, **kwargs: Any) -> None:
|
|
||||||
user_message, assistant_response, session_id = _extract_turn(kwargs)
|
|
||||||
if not user_message and not assistant_response:
|
|
||||||
return
|
|
||||||
state = _load_state()
|
|
||||||
ok, _reason = gate(self.policy, state, session_id=session_id)
|
|
||||||
if not ok:
|
|
||||||
return
|
|
||||||
worker = threading.Thread(
|
|
||||||
target=self._process,
|
|
||||||
args=(user_message, assistant_response, session_id, state),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
worker.start()
|
|
||||||
|
|
||||||
def _process(
|
|
||||||
self, user_message: str, assistant_response: str, session_id: str, state: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
with self._lock:
|
|
||||||
try:
|
|
||||||
summary = summarize_turn(user_message, assistant_response, self.policy)
|
|
||||||
summary["session_id"] = session_id
|
|
||||||
matches = search_catalog(summary, self.policy)
|
|
||||||
if not matches:
|
|
||||||
return
|
|
||||||
proposal = build_proposal(matches[0], summary, self.policy)
|
|
||||||
write_proposal_shown(proposal)
|
|
||||||
notify(proposal)
|
|
||||||
_bump_state(state, self.policy, session_id, proposal)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
_RUNTIME = ScoutRuntime()
|
|
||||||
|
|
||||||
|
|
||||||
def register(ctx: Any) -> None:
|
|
||||||
ctx.register_hook("on_session_start", _RUNTIME.on_session_start)
|
|
||||||
ctx.register_hook("post_llm_call", _RUNTIME.post_llm_call)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
name: m2-solution-scout
|
|
||||||
version: "0.1"
|
|
||||||
description: Solution Scout intent watch
|
|
||||||
hooks:
|
|
||||||
- on_session_start
|
|
||||||
- post_llm_call
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
[scout]
|
|
||||||
enabled = false
|
|
||||||
mode = "suggest"
|
|
||||||
desktop_id = "chris-m2o"
|
|
||||||
operator_id = "op_chris"
|
|
||||||
tenant_id = ""
|
|
||||||
min_score = 0.60
|
|
||||||
min_coverage = 0.30
|
|
||||||
poll_interval_seconds = 90
|
|
||||||
max_proposals_per_day = 5
|
|
||||||
max_proposals_per_session = 1
|
|
||||||
dismiss_cooldown_days = 14
|
|
||||||
proposal_cooldown_minutes = 45
|
|
||||||
|
|
||||||
[scout.sources]
|
|
||||||
herdr_runs = true
|
|
||||||
session_summaries = true
|
|
||||||
window_titles = false
|
|
||||||
raw_keystrokes = false
|
|
||||||
raw_client_data = false
|
|
||||||
|
|
||||||
[scout.summary]
|
|
||||||
max_source_chars = 12000
|
|
||||||
max_summary_chars = 900
|
|
||||||
redact_secrets = true
|
|
||||||
redact_client_identifiers = true
|
|
||||||
fail_closed_on_secret = true
|
|
||||||
|
|
||||||
[scout.catalog]
|
|
||||||
memory_api_url = "https://memory.machinemachine.ai"
|
|
||||||
agent_id = "market:catalog"
|
|
||||||
limit = 5
|
|
||||||
routing_strategy = "standard"
|
|
||||||
|
|
||||||
[scout.store]
|
|
||||||
deeplink_scheme = "m2store"
|
|
||||||
fallback_command = "m2-market show --json"
|
|
||||||
|
|
||||||
[scout.telemetry]
|
|
||||||
enabled = true
|
|
||||||
agent_id = "market:evidence"
|
|
||||||
batch_seconds = 60
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "m2-solution-scout" / "__init__.py"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def scout(monkeypatch, tmp_path):
|
|
||||||
spec = importlib.util.spec_from_file_location("m2_solution_scout_test", PLUGIN)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
assert spec.loader is not None
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
monkeypatch.setattr(module, "STATE_PATH", tmp_path / "state.json")
|
|
||||||
monkeypatch.setattr(module, "STATE_DIR", tmp_path)
|
|
||||||
monkeypatch.setattr(module, "OUTBOX_DIR", tmp_path / "outbox")
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def policy(scout):
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"desktop_id": "chris-m2o",
|
|
||||||
"operator_id": "op_chris",
|
|
||||||
"tenant_id": "machine-machine",
|
|
||||||
"min_score": 0.78,
|
|
||||||
"min_coverage": 0.2,
|
|
||||||
"max_proposals_per_day": 5,
|
|
||||||
"max_proposals_per_session": 1,
|
|
||||||
"proposal_cooldown_minutes": 45,
|
|
||||||
"summary": {
|
|
||||||
"max_source_chars": 12000,
|
|
||||||
"max_summary_chars": 120,
|
|
||||||
"redact_client_identifiers": True,
|
|
||||||
},
|
|
||||||
"catalog": {
|
|
||||||
"memory_api_url": "https://memory.test",
|
|
||||||
"agent_id": "market:catalog",
|
|
||||||
"limit": 5,
|
|
||||||
"routing_strategy": "standard",
|
|
||||||
},
|
|
||||||
"store": {"deeplink_scheme": "m2store"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_disabled_opted_out_rate_capped_and_cooldown_noop(scout):
|
|
||||||
enabled = policy(scout)
|
|
||||||
assert scout.gate({"enabled": False}, {}, session_id="s1") == (False, "disabled")
|
|
||||||
assert scout.load_policy(Path("/no/such/file")) == {
|
|
||||||
"enabled": False,
|
|
||||||
"reason": "disabled_or_missing_tenant",
|
|
||||||
}
|
|
||||||
today = scout._utcnow().date().isoformat()
|
|
||||||
assert scout.gate(
|
|
||||||
enabled,
|
|
||||||
{"daily": {today: 5}, "sessions": {}, "last_proposal_at": None},
|
|
||||||
session_id="s1",
|
|
||||||
) == (False, "rate_capped")
|
|
||||||
assert scout.gate(
|
|
||||||
enabled,
|
|
||||||
{"daily": {}, "sessions": {"s1": 1}, "last_proposal_at": None},
|
|
||||||
session_id="s1",
|
|
||||||
) == (False, "session_capped")
|
|
||||||
assert scout.gate(
|
|
||||||
enabled,
|
|
||||||
{"daily": {}, "sessions": {}, "last_proposal_at": scout._iso()},
|
|
||||||
session_id="s1",
|
|
||||||
) == (False, "proposal_cooldown")
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_refuses_raw_sources(scout, tmp_path):
|
|
||||||
path = tmp_path / "pull-policy.toml"
|
|
||||||
path.write_text(
|
|
||||||
"""
|
|
||||||
[scout]
|
|
||||||
enabled = true
|
|
||||||
tenant_id = "machine-machine"
|
|
||||||
|
|
||||||
[scout.sources]
|
|
||||||
raw_keystrokes = true
|
|
||||||
raw_client_data = false
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
with pytest.raises(scout.PolicyError):
|
|
||||||
scout.load_policy(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_redaction_and_summary_are_bounded(scout):
|
|
||||||
text = (
|
|
||||||
"Use sk-abcdefghijklmnopqrstuvwxyz and API_KEY=supersecretvalue for jane@example.com. "
|
|
||||||
"Build a competitor research report with sources and PDF output."
|
|
||||||
)
|
|
||||||
summary = scout.summarize_turn(text, "Prepare the report.", policy(scout))
|
|
||||||
assert "sk-" not in summary["summary"]
|
|
||||||
assert "supersecretvalue" not in summary["summary"]
|
|
||||||
assert "jane@example.com" not in summary["summary"]
|
|
||||||
assert "[REDACTED_SECRET]" in summary["summary"]
|
|
||||||
assert len(summary["summary"]) <= 120
|
|
||||||
assert summary["schema_version"] == "m2.scout.intent_summary.v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_proposal_outbox_record_shape_matches_schema(scout, tmp_path):
|
|
||||||
p = policy(scout)
|
|
||||||
summary = scout.summarize_turn("competitor research report pdf", "", p)
|
|
||||||
match = {
|
|
||||||
"listing_id": "lst_competitor-scan",
|
|
||||||
"solution_id": "sol_competitor-scan",
|
|
||||||
"score": 0.84,
|
|
||||||
"coverage": 0.72,
|
|
||||||
"listing": {
|
|
||||||
"name": "Competitor Scan",
|
|
||||||
"summary": "Research report package.",
|
|
||||||
"price": {"amount": 40, "currency": "m2cr", "model": "fixed"},
|
|
||||||
"stats": {"installs": 12, "rating": 4.6},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
proposal = scout.build_proposal(match, summary, p)
|
|
||||||
event = scout.write_proposal_shown(proposal, tmp_path / "outbox")
|
|
||||||
assert event["event_type"] == "proposal_shown"
|
|
||||||
assert event["proposal"]["schema_version"] == "m2.scout.proposal.v1"
|
|
||||||
assert event["proposal"]["listing"]["listing_id"] == "lst_competitor-scan"
|
|
||||||
assert event["proposal"]["deeplink"].startswith("m2store://listing/lst_competitor-scan")
|
|
||||||
lines = list((tmp_path / "outbox").glob("*.jsonl"))[0].read_text(encoding="utf-8").splitlines()
|
|
||||||
assert json.loads(lines[0])["proposal_id"] == event["proposal_id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_mocked_catalog_search_filters_visibility_and_posts_live_shape(scout, monkeypatch):
|
|
||||||
requests = []
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
requests.append(json.loads(request.content.decode("utf-8")))
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"score": 0.91,
|
|
||||||
"metadata": {
|
|
||||||
"listing_id": "lst_competitor-scan",
|
|
||||||
"listing": {
|
|
||||||
"name": "Competitor Scan",
|
|
||||||
"summary": "Competitor research report with PDF output",
|
|
||||||
"keywords": ["competitor", "research", "report"],
|
|
||||||
"tenant_visibility": ["*"],
|
|
||||||
"status": "published",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"score": 0.99,
|
|
||||||
"metadata": {
|
|
||||||
"listing_id": "lst_private",
|
|
||||||
"listing": {
|
|
||||||
"name": "Private",
|
|
||||||
"tenant_visibility": ["other-tenant"],
|
|
||||||
"status": "published",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"routing": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
original_init = httpx.Client.__init__
|
|
||||||
|
|
||||||
def patched_init(self, *args, **kwargs):
|
|
||||||
kwargs["transport"] = httpx.MockTransport(handler)
|
|
||||||
original_init(self, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
|
|
||||||
summary = scout.summarize_turn("competitor research report pdf", "", policy(scout))
|
|
||||||
results = scout.search_catalog(summary, policy(scout))
|
|
||||||
assert requests[0]["agent_id"] == "market:catalog"
|
|
||||||
assert requests[0]["query"] == summary["summary"]
|
|
||||||
assert "tenant_id" not in requests[0]
|
|
||||||
assert [item["listing_id"] for item in results] == ["lst_competitor-scan"]
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "m2.listing.v1",
|
|
||||||
"listing_id": "lst_agent-scaffold",
|
|
||||||
"solution_id": "sol_agent-scaffold",
|
|
||||||
"solution_version": "1.0.0",
|
|
||||||
"inventory_type": "solution",
|
|
||||||
"name": "Agent Scaffold Generator",
|
|
||||||
"summary": "Scaffold OpenClaw/Hermes agent workspaces (PRD.md + SOUL.md + MEMORY.md) from m2-memory context.",
|
|
||||||
"category": "agent-tooling",
|
|
||||||
"keywords": ["agent", "scaffold", "openclaw", "hermes", "prd", "soul", "memory"],
|
|
||||||
"price": {
|
|
||||||
"amount": 60,
|
|
||||||
"currency": "m2cr",
|
|
||||||
"model": "fixed"
|
|
||||||
},
|
|
||||||
"seller": "sdjs-operator",
|
|
||||||
"evidence_summary": "Skill in production use on the m2 host since 2026-04-20, extracted from a real workflow: four PRD.md/SOUL.md agent workspaces at /home/m2/gmi-clinic/agents/ (GMI Clinic fleet deployment) created within a minute of SKILL.md's own mtime, plus three more SOUL.md digital-twin identities at /home/m2/agent-restore-harness/agents/ from 2026-04-22/23 confirming continued production use. Requires buyer-side memory-api access for full function.",
|
|
||||||
"tenant_visibility": ["*"],
|
|
||||||
"stats": {
|
|
||||||
"installs": 0,
|
|
||||||
"proposals_shown": 0,
|
|
||||||
"proposals_accepted": 0
|
|
||||||
},
|
|
||||||
"status": "draft",
|
|
||||||
"install_ref": "lst_agent-scaffold-v1.0.0"
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
name: agent-scaffold
|
|
||||||
description: Scaffolds OpenClaw/Hermes agent workspaces from m2-memory. Query memory for context, generate PRD.md + SOUL.md for a new agent, and optionally wire into the fleet.
|
|
||||||
---
|
|
||||||
|
|
||||||
# agent-scaffold
|
|
||||||
|
|
||||||
Generate a complete agent workspace (PRD + SOUL + MEMORY seed) from context in m2-memory.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
/agent-scaffold <agent-name> "<what this agent does>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```
|
|
||||||
/agent-scaffold gesy-rates-fetcher "Fetches and monitors GESY reimbursement rate updates for GMI Clinic"
|
|
||||||
/agent-scaffold platform-unifier "Maps and unifies the 3-5 GMI Clinic software platforms into Machine.Machine"
|
|
||||||
/agent-scaffold nasr-research-runner "Runs parallel deep research tasks for Nasr's healthcare domain work"
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it produces
|
|
||||||
|
|
||||||
For each agent, the skill:
|
|
||||||
|
|
||||||
1. **Queries m2-memory** for relevant context (entity/project history, related agents, decisions)
|
|
||||||
2. **Generates `PRD.md`** — problem, personas, functional requirements, success metrics, open items
|
|
||||||
3. **Generates `SOUL.md`** — agent identity, values, communication style, scope boundaries
|
|
||||||
4. **Generates `MEMORY.md`** — seed memories pre-loaded from m2-memory search results
|
|
||||||
5. **Prints a deploy snippet** — `docker run` or Coolify env vars to spawn the agent
|
|
||||||
|
|
||||||
## Output location
|
|
||||||
|
|
||||||
`~/agents/<agent-name>/` — commit to `git.machinemachine.ai/machine.machine/specs/` when ready.
|
|
||||||
|
|
||||||
## Design principle
|
|
||||||
|
|
||||||
The goal is reproducibility: if an agent's context is lost (like Nasr's Apr 8 research agents),
|
|
||||||
this skill can reconstruct the workspace from the vector store and hand it to OpenClaw or Hermes
|
|
||||||
to re-execute without starting from scratch.
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
agent-scaffold — generate PRD.md + SOUL.md + MEMORY.md for a new agent
|
|
||||||
from m2-memory context.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scaffold.py <agent-name> "<description>"
|
|
||||||
python3 scaffold.py gesy-rates-fetcher "Monitors GESY rates for GMI Clinic DRG billing"
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MEMORY_API = os.environ.get("M2_MEMORY_API_URL", "http://172.18.0.20:8000")
|
|
||||||
AGENT_ID = os.environ.get("M2_MEMORY_AGENT_ID", "m2")
|
|
||||||
API_KEY = os.environ.get("M2_MEMORY_API_KEY")
|
|
||||||
OUT_BASE = Path.home() / "agents"
|
|
||||||
|
|
||||||
|
|
||||||
def search_memory(query: str, limit: int = 10) -> list[dict]:
|
|
||||||
url = f"{MEMORY_API}/memory/search"
|
|
||||||
body = json.dumps({
|
|
||||||
"query": query,
|
|
||||||
"agent_id": AGENT_ID,
|
|
||||||
"routing_strategy": "deep",
|
|
||||||
"limit": limit,
|
|
||||||
}).encode()
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if API_KEY:
|
|
||||||
headers["X-API-Key"] = API_KEY
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
return json.loads(r.read()).get("results", [])
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_memory_block(results: list[dict]) -> str:
|
|
||||||
lines = []
|
|
||||||
for r in results:
|
|
||||||
ts = (r.get("timestamp") or "")[:10]
|
|
||||||
score = r.get("score", 0)
|
|
||||||
content = (r.get("content") or "").replace("\n", " ")[:200]
|
|
||||||
lines.append(f"- [{ts} score={score:.2f}] {content}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_prd(name: str, description: str, memories: list[dict]) -> str:
|
|
||||||
mem_block = fmt_memory_block(memories)
|
|
||||||
return f"""# {name.replace("-", " ").title()} — PRD
|
|
||||||
|
|
||||||
**Agent ID:** {name}
|
|
||||||
**Description:** {description}
|
|
||||||
**Generated from:** m2-memory search ({len(memories)} relevant memories)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
> TODO: Refine based on memory context below
|
|
||||||
|
|
||||||
{description}
|
|
||||||
|
|
||||||
## Relevant Memory Context
|
|
||||||
|
|
||||||
{mem_block}
|
|
||||||
|
|
||||||
## Personas
|
|
||||||
|
|
||||||
| Persona | Goal |
|
|
||||||
|---------|------|
|
|
||||||
| TODO | TODO |
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
1. TODO — derived from memory context above
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
- TODO
|
|
||||||
|
|
||||||
## Open Items
|
|
||||||
|
|
||||||
- TODO — check memory for unresolved gaps
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
- Agent runtime: OpenClaw / Hermes
|
|
||||||
- Memory: m2-memory (agent_id={name})
|
|
||||||
- Deploy: Coolify stack on Machine.Machine fleet
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def generate_soul(name: str, description: str) -> str:
|
|
||||||
title = name.replace("-", " ").title()
|
|
||||||
return f"""# {title} — Agent Identity
|
|
||||||
|
|
||||||
You are **{title}**, part of the Machine.Machine agent fleet.
|
|
||||||
|
|
||||||
{description}
|
|
||||||
|
|
||||||
## Values
|
|
||||||
- TODO: define 3 core values for this agent
|
|
||||||
|
|
||||||
## Communication style
|
|
||||||
- TODO: define tone and output format
|
|
||||||
|
|
||||||
## Scope boundaries
|
|
||||||
- You do not TODO
|
|
||||||
- You do not TODO
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def generate_memory_seed(memories: list[dict]) -> str:
|
|
||||||
lines = ["# Seed Memories\n",
|
|
||||||
"These memories are pre-loaded from m2-memory at agent creation time.\n"]
|
|
||||||
for r in memories[:10]:
|
|
||||||
ts = (r.get("timestamp") or "")[:19]
|
|
||||||
mtype = r.get("memory_type", "semantic")
|
|
||||||
content = (r.get("content") or "").strip()[:500]
|
|
||||||
lines.append(f"## [{mtype} {ts}]\n{content}\n")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser()
|
|
||||||
p.add_argument("name", help="agent slug (e.g. gesy-rates-fetcher)")
|
|
||||||
p.add_argument("description", help="one-line description of what the agent does")
|
|
||||||
p.add_argument("--out", default=None, help="output directory (default: ~/agents/<name>)")
|
|
||||||
p.add_argument("--limit", type=int, default=10, help="memory search limit")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
out_dir = Path(args.out) if args.out else OUT_BASE / args.name
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
print(f"Searching m2-memory for: {args.description}", file=sys.stderr)
|
|
||||||
try:
|
|
||||||
memories = search_memory(args.description, args.limit)
|
|
||||||
print(f" Found {len(memories)} relevant memories", file=sys.stderr)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Memory search failed ({e}) — generating without context", file=sys.stderr)
|
|
||||||
memories = []
|
|
||||||
|
|
||||||
(out_dir / "PRD.md").write_text(generate_prd(args.name, args.description, memories))
|
|
||||||
(out_dir / "SOUL.md").write_text(generate_soul(args.name, args.description))
|
|
||||||
(out_dir / "MEMORY.md").write_text(generate_memory_seed(memories))
|
|
||||||
|
|
||||||
print(f"\nScaffolded: {out_dir}")
|
|
||||||
print(f" PRD.md — {(out_dir / 'PRD.md').stat().st_size} bytes")
|
|
||||||
print(f" SOUL.md — {(out_dir / 'SOUL.md').stat().st_size} bytes")
|
|
||||||
print(f" MEMORY.md — {(out_dir / 'MEMORY.md').stat().st_size} bytes")
|
|
||||||
print(f"\nNext: review + fill TODOs, then commit to git.machinemachine.ai/machine.machine/specs/")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# agent-scaffold install recipe (apply-adapter.md bundle layout, local adapter v1)
|
|
||||||
# Places the agent-scaffold skill into the buyer's Claude Code skills directory.
|
|
||||||
targets:
|
|
||||||
- src: SKILL.md
|
|
||||||
dest: ~/.claude/skills/agent-scaffold/SKILL.md
|
|
||||||
- src: scripts/scaffold.py
|
|
||||||
dest: ~/.claude/skills/agent-scaffold/scripts/scaffold.py
|
|
||||||
checks:
|
|
||||||
- cmd: test -f ~/.claude/skills/agent-scaffold/SKILL.md
|
|
||||||
expect_exit: 0
|
|
||||||
- cmd: test -x ~/.claude/skills/agent-scaffold/scripts/scaffold.py
|
|
||||||
expect_exit: 0
|
|
||||||
entrypoint: "Ask your agent to use the agent-scaffold skill, or run: python3 ~/.claude/skills/agent-scaffold/scripts/scaffold.py <agent-name> \"<description>\""
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "m2.solution.v1",
|
|
||||||
"solution_id": "sol_agent-scaffold",
|
|
||||||
"name": "Agent Scaffold Generator",
|
|
||||||
"summary": "Scaffold OpenClaw/Hermes agent workspaces (PRD.md + SOUL.md + MEMORY.md) from m2-memory context.",
|
|
||||||
"description": "Installs the agent-scaffold skill: given an agent name and a one-line description, it queries m2-memory (agent.memory.system / memory-api) for relevant context and generates a complete agent workspace — PRD.md (problem, personas, functional requirements, success metrics), SOUL.md (identity, values, communication style, scope boundaries), and MEMORY.md (seed memories pre-loaded from the search results) — plus a deploy snippet for OpenClaw or Hermes. Ships two artifacts: SKILL.md and scripts/scaffold.py (a single-file Python script using only the stdlib, talking HTTP to memory-api). Requirement: the buyer machine needs memory-api (m2-memory) network access for full function — scripts/scaffold.py calls M2_MEMORY_API_URL (default http://172.18.0.20:8000, override via env) at /memory/search. Without reachable memory-api the script still runs and produces PRD.md/SOUL.md, but with an empty 'Relevant Memory Context' section and no MEMORY.md content — degraded, not blocked. content_hash is computed over payload/ + recipe.yaml only (not solution.json itself, to avoid the self-referential hash problem of hashing a file that contains its own hash): sha256 of `tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner -czf - payload recipe.yaml` run from this bundle's root.",
|
|
||||||
"intent": "Give an operator a reproducible way to stand up (or reconstruct) a new agent's working context — PRD + identity + seed memory — from what the fleet's memory system already knows, instead of starting from a blank page.",
|
|
||||||
"behavior": {
|
|
||||||
"skill_ref": "payload/SKILL.md"
|
|
||||||
},
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"name": "memory-api",
|
|
||||||
"kind": "http-api",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "python3",
|
|
||||||
"kind": "cli",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"runtime": {
|
|
||||||
"surfaces": [
|
|
||||||
"claude-code-skill"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"action": "write",
|
|
||||||
"target": "~/.claude/skills/agent-scaffold/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "write",
|
|
||||||
"target": "~/agents/<agent-name>/ (PRD.md, SOUL.md, MEMORY.md — output of running the skill, not part of the install)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "net",
|
|
||||||
"target": "HTTP POST to the configured M2_MEMORY_API_URL /memory/search endpoint"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"deployment": {
|
|
||||||
"recipe_ref": "recipe.yaml",
|
|
||||||
"entrypoint": "Ask your agent to use the agent-scaffold skill, or run: python3 ~/.claude/skills/agent-scaffold/scripts/scaffold.py <agent-name> \"<description>\"",
|
|
||||||
"verify_command": "test -f ~/.claude/skills/agent-scaffold/SKILL.md"
|
|
||||||
},
|
|
||||||
"applicability": {
|
|
||||||
"image_classes": [
|
|
||||||
"primus",
|
|
||||||
"agent-latest"
|
|
||||||
],
|
|
||||||
"roles": [
|
|
||||||
"operator",
|
|
||||||
"desktop-agent"
|
|
||||||
],
|
|
||||||
"tool_requirements": [
|
|
||||||
"python3",
|
|
||||||
"memory-api"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tenant_scope": "m2-core",
|
|
||||||
"evidence": [
|
|
||||||
{
|
|
||||||
"source": "/home/m2/.claude/skills/agent-scaffold/ (SKILL.md, scripts/scaffold.py) — the proven skill this bundle packages, present on the m2 host since 2026-04-20 (SKILL.md mtime 2026-04-20T22:48:00+02:00, scripts/scaffold.py mtime 2026-04-28T00:33:10+02:00 — a later revision of the same script)",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "SKILL.md frontmatter: 'Scaffolds OpenClaw/Hermes agent workspaces from m2-memory. Query memory for context, generate PRD.md + SOUL.md for a new agent, and optionally wire into the fleet.'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/home/m2/gmi-clinic/agents/{admin-data-bridge,clinical-entity-extraction,drg-los-optimizer}/{PRD.md,SOUL.md} — three real PRD+SOUL agent-workspace pairs on disk, all created 2026-04-20 22:46:27–22:47:19 (mtimes), roughly one minute before this skill's own SKILL.md mtime (22:48:00) — the exact PRD.md/SOUL.md workspace shape this skill formalizes and automates, produced for the GMI Clinic AI fleet deployment immediately prior to the skill being written",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "admin-data-bridge/SOUL.md: 'You are the Admin Data Bridge for GMI Clinic... ## Values ## Communication style ## Scope boundaries' — same section structure generate_soul() in scaffold.py emits"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/home/m2/gmi-clinic/agents/gesy-research/PRD.md — a fourth real workspace, mtime 2026-04-20T22:47:48+02:00, whose own text names the exact incident SKILL.md's 'Design principle' section cites as the skill's reason for existing: 'On 2026-04-08, Nasr ran 4 parallel research agents on his machine... The output was intended to be stored in m2-memory, Planka, and Forgejo but the storage step failed (m2 errored). This PRD reconstructs the agent's purpose and gaps so the research can be completed and properly stored.'",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "SKILL.md: 'The goal is reproducibility: if an agent's context is lost (like Nasr's Apr 8 research agents), this skill can reconstruct the workspace from the vector store and hand it to OpenClaw or Hermes to re-execute without starting from scratch.'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/home/m2/agent-restore-harness/agents/*/SOUL.md — three further real SOUL.md agent-identity files for actual named fleet agents (machine names withheld from shared catalog per tenant-firewall hygiene), mtimes 2026-04-22T23:41–2026-04-23T00:48, confirming the PRD+SOUL workspace pattern stayed in production use after the skill was written, not a one-off",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "nasr/SOUL.md: 'You are Nasr's digital twin — his AI counterpart in the Machine.Machine fleet... ## Core Capabilities'"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"content_hash": "sha256:202aaff5519e774e2669bd2ca6220fc67284a506775a90ff7710fb2361cc58ee",
|
|
||||||
"price": {
|
|
||||||
"amount": 60,
|
|
||||||
"currency": "m2cr",
|
|
||||||
"model": "fixed"
|
|
||||||
},
|
|
||||||
"seller": "sdjs-operator",
|
|
||||||
"license": {
|
|
||||||
"terms": "Perpetual use on operator-owned machines for the buyer's own agent-workspace generation; no redistribution of the skill files as a standalone product.",
|
|
||||||
"major_version_coverage": true
|
|
||||||
},
|
|
||||||
"revenue_split": {
|
|
||||||
"platform_pct": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
solutions/mm-pdf-report/.gitkeep
Normal file
0
solutions/mm-pdf-report/.gitkeep
Normal file
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "m2.listing.v1",
|
|
||||||
"listing_id": "lst_mm-pdf-report",
|
|
||||||
"solution_id": "sol_mm-pdf-report",
|
|
||||||
"solution_version": "1.0.0",
|
|
||||||
"inventory_type": "solution",
|
|
||||||
"name": "MM PDF Report Generator",
|
|
||||||
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown.",
|
|
||||||
"category": "document-generation",
|
|
||||||
"keywords": ["pdf", "branding", "markdown", "reports", "decks"],
|
|
||||||
"price": {
|
|
||||||
"amount": 40,
|
|
||||||
"currency": "m2cr",
|
|
||||||
"model": "fixed"
|
|
||||||
},
|
|
||||||
"seller": "sdjs-operator",
|
|
||||||
"evidence_summary": "Skill in production use on the m2 host since 2026-04-21; two real generated PDF/HTML pairs on disk (session-2026-04-20.pdf, spark-cluster-old/fabric-setup.pdf) confirm the pipeline works end-to-end.",
|
|
||||||
"tenant_visibility": ["*"],
|
|
||||||
"stats": {
|
|
||||||
"installs": 0,
|
|
||||||
"proposals_shown": 0,
|
|
||||||
"proposals_accepted": 0
|
|
||||||
},
|
|
||||||
"status": "draft",
|
|
||||||
"install_ref": "lst_mm-pdf-report-v1.0.0"
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
---
|
|
||||||
name: mm-pdf
|
|
||||||
description: Generate beautifully styled Machine.Machine branded PDFs and slide decks from Markdown. Dark navy aesthetic, cyan accents, professional A4 layout. Uses a headless-Chrome render container (any container with Node + Chrome, e.g. a "*-m2o" agent-desktop image) or a local WeasyPrint fallback. Produces PDF + HTML output.
|
|
||||||
---
|
|
||||||
|
|
||||||
# mm-pdf
|
|
||||||
|
|
||||||
Generate MM-branded documents from Markdown content.
|
|
||||||
|
|
||||||
## Design system
|
|
||||||
|
|
||||||
| Token | Value | Use |
|
|
||||||
|-------|-------|-----|
|
|
||||||
| Cover background | `#0a0e1a` | Dark navy |
|
|
||||||
| Primary accent | `#4bb8d4` | Cyan — headings, kickers, borders |
|
|
||||||
| Section banners | `#3d4550` | Slate grey |
|
|
||||||
| Purple sidebar | `#6b46c1` | Alt cover variant |
|
|
||||||
| Body text | `#e2e8f0` | Light grey on dark bg |
|
|
||||||
| Body background | `#111827` | Dark page bg |
|
|
||||||
| Code/data blocks | `#1e2533` | Slightly lighter navy |
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate PDF from markdown
|
|
||||||
mm-pdf generate input.md [--out output.pdf] [--style dark|purple|light]
|
|
||||||
|
|
||||||
# Generate slide deck (Marp)
|
|
||||||
mm-pdf slides input.md [--out output.pdf]
|
|
||||||
|
|
||||||
# With explicit container (auto-discovery picks the first "*m2o*" container if omitted)
|
|
||||||
mm-pdf generate input.md --container <your-render-container-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pipeline
|
|
||||||
|
|
||||||
1. **MD → HTML** — a small Node script (via `marked`) renders Markdown into full MM-styled
|
|
||||||
HTML. Extracts YAML frontmatter (`title`, `subtitle`, `date`, `client`) for the cover
|
|
||||||
page. Strips the first `# H1` to use as title if no frontmatter is present.
|
|
||||||
2. **HTML → PDF** — Chrome headless renders the HTML to PDF (`--print-to-pdf`).
|
|
||||||
|
|
||||||
Both outputs are saved alongside the input file.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
- `<name>.html` — standalone styled HTML (preview, shareable)
|
|
||||||
- `<name>.pdf` — print-ready PDF
|
|
||||||
|
|
||||||
## Styles
|
|
||||||
|
|
||||||
| Style | Cover | Use case |
|
|
||||||
|-------|-------|----------|
|
|
||||||
| `dark` | Navy `#0a0e1a` + cyan accent | Technical specs, deployment docs |
|
|
||||||
| `purple` | Purple sidebar `#6b46c1` | Partner decks |
|
|
||||||
| `light` | White + navy header | Client-facing docs |
|
|
||||||
|
|
||||||
`dark.css` and `purple.css` ship in `templates/`. `light` is documented for forward
|
|
||||||
compatibility but has no template in this bundle yet — add `templates/light.css` locally
|
|
||||||
if you need it (it will be picked up automatically; `generate.sh` falls back to `dark.css`
|
|
||||||
for any missing style file).
|
|
||||||
|
|
||||||
## Components (in Markdown)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
<!-- KPI card row -->
|
|
||||||
<div class="kpi-row">
|
|
||||||
<div class="kpi">**€5K** setup</div>
|
|
||||||
<div class="kpi">**€2K**/mo</div>
|
|
||||||
<div class="kpi">**8 weeks** pilot</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Section kicker -->
|
|
||||||
<span class="kicker">DEPLOYMENT ARCHITECTURE</span>
|
|
||||||
|
|
||||||
<!-- Warning/callout -->
|
|
||||||
<div class="callout">⚠️ Open item: integration path TBD</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Docker, with a running container that has Node.js + Google Chrome (headless) installed
|
|
||||||
and reachable via `docker exec`/`docker cp` from the host running this skill. Any
|
|
||||||
container name is fine — `scripts/generate.sh` auto-discovers the first container whose
|
|
||||||
name matches `nasr-m2o|m2o`, or accepts `--container <name>` to target any other
|
|
||||||
container that meets the Node+Chrome requirement.
|
|
||||||
- `docker` CLI available on the host invoking `mm-pdf`.
|
|
||||||
|
|
||||||
No absolute host paths, hostnames, or client-identifying data are baked into this skill —
|
|
||||||
`--container` and the input Markdown path are the only machine-specific inputs at run time.
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# mm-pdf generate — render Markdown → HTML → PDF using nasr-m2o container
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
STYLE="${MM_PDF_STYLE:-dark}"
|
|
||||||
OUT=""
|
|
||||||
OUT_HTML=""
|
|
||||||
CONTAINER=""
|
|
||||||
INPUT=""
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage: mm-pdf generate <input.md> [--out output.pdf] [--style dark|purple|light] [--container NAME]"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--out) OUT="$2"; shift 2 ;;
|
|
||||||
--style) STYLE="$2"; shift 2 ;;
|
|
||||||
--container) CONTAINER="$2"; shift 2 ;;
|
|
||||||
--help|-h) usage ;;
|
|
||||||
*) INPUT="$1"; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -z "$INPUT" ]] && usage
|
|
||||||
[[ ! -f "$INPUT" ]] && { echo "Error: $INPUT not found"; exit 1; }
|
|
||||||
|
|
||||||
INPUT_ABS="$(realpath "$INPUT")"
|
|
||||||
BASENAME="${INPUT_ABS%.md}"
|
|
||||||
OUT="${OUT:-${BASENAME}.pdf}"
|
|
||||||
OUT_ABS="$(realpath -m "$OUT")"
|
|
||||||
OUT_HTML_ABS="${OUT_ABS%.pdf}.html"
|
|
||||||
|
|
||||||
# Auto-discover container
|
|
||||||
if [[ -z "$CONTAINER" ]]; then
|
|
||||||
CONTAINER=$(docker ps --format "{{.Names}}" | grep -E "nasr-m2o|m2o" | head -1)
|
|
||||||
[[ -z "$CONTAINER" ]] && { echo "Error: no m2o container running. Start nasr-m2o or pass --container"; exit 1; }
|
|
||||||
fi
|
|
||||||
echo "Using container: $CONTAINER"
|
|
||||||
|
|
||||||
# Copy files into container
|
|
||||||
TMP="/tmp/mm-pdf-$$"
|
|
||||||
docker exec "$CONTAINER" mkdir -p "$TMP"
|
|
||||||
docker cp "$INPUT_ABS" "$CONTAINER:$TMP/input.md"
|
|
||||||
|
|
||||||
if [[ -f "$SKILL_DIR/templates/${STYLE}.css" ]]; then
|
|
||||||
docker cp "$SKILL_DIR/templates/${STYLE}.css" "$CONTAINER:$TMP/style.css"
|
|
||||||
else
|
|
||||||
docker cp "$SKILL_DIR/templates/dark.css" "$CONTAINER:$TMP/style.css"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 1: Markdown → styled HTML (via Node/marked)
|
|
||||||
echo "Step 1: Converting Markdown → HTML..."
|
|
||||||
docker exec "$CONTAINER" bash -c "
|
|
||||||
cd $TMP
|
|
||||||
# Install marked locally in tmp dir so require() can find it
|
|
||||||
[ -d node_modules/marked ] || npm install marked --prefix . -q 2>/dev/null
|
|
||||||
|
|
||||||
node - <<'JSEOF'
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { marked } = require('marked');
|
|
||||||
|
|
||||||
const mdRaw = fs.readFileSync('input.md', 'utf8');
|
|
||||||
const css = fs.readFileSync('style.css', 'utf8');
|
|
||||||
|
|
||||||
// Strip YAML frontmatter
|
|
||||||
let body = mdRaw;
|
|
||||||
const fm = {};
|
|
||||||
const fmMatch = mdRaw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
||||||
if (fmMatch) {
|
|
||||||
fmMatch[1].split('\n').forEach(line => {
|
|
||||||
const i = line.indexOf(':');
|
|
||||||
if (i > 0) fm[line.slice(0,i).trim().toLowerCase()] = line.slice(i+1).trim();
|
|
||||||
});
|
|
||||||
body = mdRaw.slice(fmMatch[0].length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract first H1 as title
|
|
||||||
let title = fm.title || '';
|
|
||||||
if (!title) {
|
|
||||||
const h1 = body.match(/^#\s+(.+)$/m);
|
|
||||||
if (h1) { title = h1[1].trim(); body = body.replace(h1[0], ''); }
|
|
||||||
}
|
|
||||||
title = title || 'Machine.Machine';
|
|
||||||
const subtitle = fm.subtitle || fm.description || '';
|
|
||||||
const dateLine = [fm.client, fm.date].filter(Boolean).join(' \u00a0·\u00a0 ');
|
|
||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: false });
|
|
||||||
const contentHtml = marked.parse(body);
|
|
||||||
|
|
||||||
const coverHtml = \`<div class=\"cover\">
|
|
||||||
<div class=\"logo\">MACHINE.MACHINE</div>
|
|
||||||
<div>
|
|
||||||
<h1 class=\"title\">\${title}</h1>
|
|
||||||
\${subtitle ? '<div class=\"subtitle\">' + subtitle + '</div>' : ''}
|
|
||||||
</div>
|
|
||||||
\${dateLine ? '<div class=\"meta\">' + dateLine + '</div>' : ''}
|
|
||||||
</div>\`;
|
|
||||||
|
|
||||||
const html = \`<!DOCTYPE html>
|
|
||||||
<html lang=\"en\">
|
|
||||||
<head>
|
|
||||||
<meta charset=\"utf-8\">
|
|
||||||
<title>\${title}</title>
|
|
||||||
<style>\${css}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
\${coverHtml}
|
|
||||||
<div class=\"body-content\">
|
|
||||||
\${contentHtml}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>\`;
|
|
||||||
|
|
||||||
fs.writeFileSync('output.html', html);
|
|
||||||
console.log('HTML written:', html.length, 'bytes');
|
|
||||||
JSEOF
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "Step 2: Rendering HTML → PDF..."
|
|
||||||
docker exec "$CONTAINER" bash -c "
|
|
||||||
cd $TMP
|
|
||||||
google-chrome \
|
|
||||||
--headless \
|
|
||||||
--no-sandbox \
|
|
||||||
--disable-gpu \
|
|
||||||
--disable-web-security \
|
|
||||||
--print-to-pdf=output.pdf \
|
|
||||||
--print-to-pdf-no-header \
|
|
||||||
--run-all-compositor-stages-before-draw \
|
|
||||||
--virtual-time-budget=5000 \
|
|
||||||
'file://$TMP/output.html'
|
|
||||||
"
|
|
||||||
|
|
||||||
# Copy both outputs back
|
|
||||||
docker cp "$CONTAINER:$TMP/output.html" "$OUT_HTML_ABS"
|
|
||||||
docker cp "$CONTAINER:$TMP/output.pdf" "$OUT_ABS"
|
|
||||||
docker exec "$CONTAINER" rm -rf "$TMP"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "HTML: $OUT_HTML_ABS ($(du -h "$OUT_HTML_ABS" | cut -f1))"
|
|
||||||
echo "PDF: $OUT_ABS ($(du -h "$OUT_ABS" | cut -f1))"
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
/* MM Dark Design System — WeasyPrint / Marp compatible */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--cover-bg: #0a0e1a;
|
|
||||||
--accent: #4bb8d4;
|
|
||||||
--section-bg: #3d4550;
|
|
||||||
--body-bg: #111827;
|
|
||||||
--body-text: #e2e8f0;
|
|
||||||
--muted: #94a3b8;
|
|
||||||
--code-bg: #1e2533;
|
|
||||||
--border: #2d3748;
|
|
||||||
--white: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- PAGE --- */
|
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 15mm 18mm 15mm 18mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 10pt;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--body-text);
|
|
||||||
background: var(--body-bg);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-content {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- COVER PAGE --- */
|
|
||||||
.cover {
|
|
||||||
background: var(--cover-bg);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 52px 52px 40px 52px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .logo {
|
|
||||||
font-size: 11pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .title {
|
|
||||||
font-size: 28pt;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--white);
|
|
||||||
line-height: 1.15;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .subtitle {
|
|
||||||
font-size: 13pt;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-top: 8px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .meta {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .version-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--cover-bg);
|
|
||||||
font-size: 8pt;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- SECTION BANNERS --- */
|
|
||||||
.section-banner, h2 {
|
|
||||||
background: var(--section-bg);
|
|
||||||
color: var(--white);
|
|
||||||
padding: 8px 16px;
|
|
||||||
margin: 24px -18mm 16px -18mm;
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- KICKER --- */
|
|
||||||
.kicker {
|
|
||||||
display: block;
|
|
||||||
font-size: 8pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- HEADINGS --- */
|
|
||||||
h1 {
|
|
||||||
font-size: 20pt;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--white);
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 11pt;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
margin: 16px 0 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--white);
|
|
||||||
margin: 12px 0 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- KPI CARDS --- */
|
|
||||||
.kpi-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--code-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-top: 3px solid var(--accent);
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 9pt;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 16pt;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--white);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- TABLES --- */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 12px 0;
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: var(--section-bg);
|
|
||||||
color: var(--white);
|
|
||||||
padding: 7px 10px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--body-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:nth-child(even) td {
|
|
||||||
background: rgba(255,255,255,0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- CODE BLOCKS --- */
|
|
||||||
pre, code {
|
|
||||||
background: var(--code-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 8.5pt;
|
|
||||||
color: #a5d6a7;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: 12px 14px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
code { padding: 1px 5px; }
|
|
||||||
|
|
||||||
/* --- CALLOUTS --- */
|
|
||||||
.callout {
|
|
||||||
background: rgba(75, 184, 212, 0.08);
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
font-size: 9pt;
|
|
||||||
color: var(--body-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout.warn {
|
|
||||||
border-left-color: #f6a623;
|
|
||||||
background: rgba(246, 166, 35, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout.danger {
|
|
||||||
border-left-color: #e53e3e;
|
|
||||||
background: rgba(229, 62, 62, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- FOOTER --- */
|
|
||||||
@page {
|
|
||||||
@bottom-center {
|
|
||||||
content: "Machine.Machine — Confidential";
|
|
||||||
font-size: 7.5pt;
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
@bottom-right {
|
|
||||||
content: counter(page);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/* MM Purple Sidebar — Nasr reference style */
|
|
||||||
@import url("dark.css");
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--cover-bg: #1a0533;
|
|
||||||
--accent: #9f7aea;
|
|
||||||
--sidebar-bg: #6b46c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
background: var(--cover-bg);
|
|
||||||
padding-left: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 6px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover::before {
|
|
||||||
content: '';
|
|
||||||
background: var(--sidebar-bg);
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-content {
|
|
||||||
grid-column: 2;
|
|
||||||
padding: 52px 52px 40px 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover .logo { color: var(--accent); }
|
|
||||||
.cover .subtitle { color: var(--accent); }
|
|
||||||
h3 { color: var(--accent); }
|
|
||||||
.kpi { border-top-color: var(--accent); }
|
|
||||||
.callout { border-left-color: var(--accent); }
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# mm-pdf-report install recipe (apply-adapter.md bundle layout, local adapter v1)
|
|
||||||
# Places the mm-pdf skill into the buyer's Claude Code skills directory.
|
|
||||||
targets:
|
|
||||||
- src: SKILL.md
|
|
||||||
dest: ~/.claude/skills/mm-pdf/SKILL.md
|
|
||||||
- src: scripts/generate.sh
|
|
||||||
dest: ~/.claude/skills/mm-pdf/scripts/generate.sh
|
|
||||||
- src: templates/dark.css
|
|
||||||
dest: ~/.claude/skills/mm-pdf/templates/dark.css
|
|
||||||
- src: templates/purple.css
|
|
||||||
dest: ~/.claude/skills/mm-pdf/templates/purple.css
|
|
||||||
checks:
|
|
||||||
- cmd: test -f ~/.claude/skills/mm-pdf/SKILL.md
|
|
||||||
expect_exit: 0
|
|
||||||
- cmd: test -x ~/.claude/skills/mm-pdf/scripts/generate.sh
|
|
||||||
expect_exit: 0
|
|
||||||
entrypoint: "Ask your agent to use the mm-pdf skill, or run: ~/.claude/skills/mm-pdf/scripts/generate.sh <input.md> --style dark"
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "m2.solution.v1",
|
|
||||||
"solution_id": "sol_mm-pdf-report",
|
|
||||||
"name": "MM PDF Report Generator",
|
|
||||||
"summary": "Generate Machine.Machine branded PDF reports and slide decks from Markdown.",
|
|
||||||
"description": "Installs the mm-pdf skill: a Markdown-to-branded-PDF/HTML pipeline (dark navy aesthetic, cyan accents, A4 layout) built on a Node+marked HTML render step and headless-Chrome print-to-pdf inside a Docker container. Ships three artifacts: SKILL.md, scripts/generate.sh, and templates/{dark,purple}.css. content_hash is computed over payload/ + recipe.yaml only (not solution.json itself, to avoid the self-referential hash problem of hashing a file that contains its own hash): sha256 of `tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner -czf - payload recipe.yaml` run from this bundle's root.",
|
|
||||||
"intent": "Give an operator a working branded-document pipeline (proposal PDFs, spec sheets, decks) without building one from scratch.",
|
|
||||||
"behavior": {
|
|
||||||
"skill_ref": "payload/SKILL.md"
|
|
||||||
},
|
|
||||||
"tools": [
|
|
||||||
{ "name": "docker", "kind": "cli", "required": true }
|
|
||||||
],
|
|
||||||
"runtime": {
|
|
||||||
"surfaces": ["claude-code-skill"]
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
{ "action": "write", "target": "~/.claude/skills/mm-pdf/" },
|
|
||||||
{ "action": "exec", "target": "docker exec/docker cp against a caller-selected container" }
|
|
||||||
],
|
|
||||||
"deployment": {
|
|
||||||
"recipe_ref": "recipe.yaml",
|
|
||||||
"entrypoint": "Ask your agent to use the mm-pdf skill, or run: ~/.claude/skills/mm-pdf/scripts/generate.sh <input.md> --style dark",
|
|
||||||
"verify_command": "test -f ~/.claude/skills/mm-pdf/SKILL.md"
|
|
||||||
},
|
|
||||||
"applicability": {
|
|
||||||
"image_classes": ["primus", "agent-latest"],
|
|
||||||
"roles": ["operator", "desktop-agent"],
|
|
||||||
"tool_requirements": ["docker"]
|
|
||||||
},
|
|
||||||
"tenant_scope": "m2-core",
|
|
||||||
"evidence": [
|
|
||||||
{
|
|
||||||
"source": "/home/m2/.claude/skills/mm-pdf/ (SKILL.md, scripts/generate.sh, templates/dark.css, templates/purple.css) — the proven skill this bundle packages, present on the m2 host since 2026-04-21 (SKILL.md mtime 2026-04-21T00:05:01+02:00, templates/dark.css mtime 2026-04-21T00:04, scripts/generate.sh mtime 2026-04-21T00:09)",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "SKILL.md frontmatter: 'Generate beautifully styled Machine.Machine branded PDFs and slide decks from Markdown. Dark navy aesthetic, cyan accents, professional A4 layout.'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/home/m2/session-2026-04-20.pdf — real output of this pipeline: 4-page A4 PDF, PDF metadata Title 'output.html' (the pipeline's fixed intermediate filename), Creator 'HeadlessChrome/145.0.0.0' (Chrome print-to-pdf, matches scripts/generate.sh Step 2), CreationDate 2026-04-20T22:56:51+02:00; pdftotext shows the 'MACHINE.MACHINE' cover logo and MM design-system markup",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "pdfinfo: Title=output.html; Creator=Mozilla/5.0 ... HeadlessChrome/145.0.0.0 ...; Producer=Skia/PDF m145; Pages=4; page size 594.96x841.92pts (A4)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/home/m2/spark-cluster-old/fabric-setup.pdf + fabric-setup.html — second real generated pair, 4-page A4 PDF titled 'DGX Spark Cluster — Fabric Setup Reference', same HeadlessChrome/145.0.0.0 print-to-pdf signature, CreationDate 2026-04-21T00:09:40+02:00 (12 seconds before this SKILL.md's own mtime, i.e. produced by the exact skill version in this bundle)",
|
|
||||||
"machine": "m2",
|
|
||||||
"excerpt": "pdftotext footer: 'Machine.Machine — Confidential' page-number footer matches templates/dark.css `@bottom-center`/`@bottom-right` rules"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"content_hash": "sha256:aaee0b1a894efafabddd1bd95c5023c915a6fcd05007776ad921d420f38ea916",
|
|
||||||
"price": {
|
|
||||||
"amount": 40,
|
|
||||||
"currency": "m2cr",
|
|
||||||
"model": "fixed"
|
|
||||||
},
|
|
||||||
"seller": "sdjs-operator",
|
|
||||||
"license": {
|
|
||||||
"terms": "Perpetual use on operator-owned machines for the buyer's own document generation; no redistribution of the skill files as a standalone product.",
|
|
||||||
"major_version_coverage": true
|
|
||||||
},
|
|
||||||
"revenue_split": {
|
|
||||||
"platform_pct": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# Verification — 001-market-first-wedge
|
|
||||||
|
|
||||||
Date: 2026-07-02 · Verifier: orchestrator (Claude Fable, factory loop) · State: master `c52f968`+
|
|
||||||
|
|
||||||
## Success criteria (spec.md)
|
|
||||||
|
|
||||||
| SC | Status | Evidence |
|
|
||||||
|----|--------|----------|
|
|
||||||
| SC-001 one real paid install between two operators | ✅ PASSED | m2bd bought sdjs-operator's `lst_mm-pdf-report` (40 cr) on m2bd-m2o, 2026-07-02T02:15:53Z. Ledger: m2bd 100→60, sdjs-operator 100→136, platform +4. Grant `gr_3697fd…` → paying tx 8. Skill applied + entrypoint executable. |
|
|
||||||
| SC-002 ≥3 evidence-backed listings | ⏸ PARTIAL (2/3) | `lst_mm-pdf-report`, `lst_agent-scaffold` published via PR+approve+merge with real evidence. Third BLOCKED on operator (T031: no real competitor-scan evidence exists; email-triage substitute needs owner-initiation + double-scrub). |
|
|
||||||
| SC-003 100% installs have tx + grant; zero applies without debit | ✅ | Live ledger: every apply preceded by `/tx/install` (409 duplicate-ref guard); property tests (`no grant without paying tx`); audit snapshots `audit/2026-07-02.json` in m2/market-registry. |
|
|
||||||
| SC-004 derived balances == fold(log) | ✅ | Hypothesis state machine (50 examples × 25 steps) + live snapshot recomputation. Refund-overdraw recorded as an accepted economic rule (data-model §Transaction). |
|
|
||||||
| SC-005 search→installed <10 min | ✅ | Gate install: 0.88 s wall (plus seconds of search/confirm). |
|
|
||||||
| SC-006 Scout v0 ≥1 correct proposal on canary | ✅ | Scout v0 (Hermes plugin) live on chris-m2o: proposal `prp_20260702_chrism2o_b0c4d` correctly matched `sol_mm-pdf-report` (score 0.795) for a matching-intent turn; opt-out suppresses; rate-cap/cooldown state tracked; zero raw-input egress (extractive summary + redaction, unit-tested). Caveat: end-to-end Hermes-dispatched turn blocked by the desktop's m2-gpt 401 (fleet auth issue, not Scout) — hook registration verified in-process. |
|
|
||||||
| SC-007 install telemetry queryable as evidence | ✅ | Gate install outcome in `market:evidence` (id `e87d0516…`); CLI now auto-posts (T045, fire-and-forget). |
|
|
||||||
|
|
||||||
## Quickstart scenarios
|
|
||||||
|
|
||||||
| Scenario | Status | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| A publish with evidence (+negative) | ✅ | PRs #1/#2 → CI validate → approved → merged → discoverable. Negative: evidence-stripped bundle → exit 5 with field-precise error. |
|
|
||||||
| B funded install | ✅ | The SC-001 gate run. |
|
|
||||||
| C refusal / idempotency / refund | ✅ | Unfunded operator → exit 3, no side effects. Re-run → no-op exit 0, no second debit. Refund: live-proven by the first gate attempt (bind-mount guard bug) — compensating pair tx 6/7 restored buyer to 100 before retry. |
|
|
||||||
| D ledger audit properties | ✅ | 9 ledger tests green; two committed snapshots (commits `bc5e00b`, `d550f85`). |
|
|
||||||
| E catalog rebuildability | ✅ | Fresh-partition rebuild (`market:catalog-e2e`) from registry returned identical results; runbook documents the switch (memory-api has no delete — as-built amendment recorded). |
|
|
||||||
| F Store on canary | ✅ | M2 Store deployed on chris-m2o (+staged on gunnar): renders live catalog, detail page with evidence/permissions, one-click install executed a REAL purchase (chris-operator 100→60, sdjs 172, platform 8) with a truthful refunded-failure first (root-owned ~/.claude provisioning issue, fixed) then green "Installed." |
|
|
||||||
| G Scout on canary | ✅ | See SC-006. Thresholds calibrated on live scores (min_score 0.60, min_coverage 0.30). |
|
|
||||||
|
|
||||||
## Known gaps / follow-ups
|
|
||||||
|
|
||||||
1. T031 third seed — operator decision (email-triage commercialization or alternative).
|
|
||||||
2. chris-m2o's m2-gpt tenant key gets 401 "User not found" — blocks real LLM turns on that
|
|
||||||
desktop (fleet auth issue, surfaced by Scenario G; Scout hook dispatch itself verified).
|
|
||||||
3. Scout end-to-end through a REAL Hermes-dispatched turn — pending the 401 fix above.
|
|
||||||
4. Publish status flow (analyze I2): merge doesn't auto-flip `status: published` — manual
|
|
||||||
step documented in runbook until automated.
|
|
||||||
5. memory-api partition CRUD (fedlearn T13 follow-up) unlocks true in-place reindex,
|
|
||||||
listing updates, and delist removal.
|
|
||||||
6. m2bd-m2o runs the pre-telemetry CLI build; operators get T045 telemetry on next
|
|
||||||
`uv tool install --force` update.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
The wedge's commercial loop is CLOSED and verified (SC-001). Remaining work is
|
|
||||||
surface expansion (Store deploy, Scout v0), one operator decision (third seed), and
|
|
||||||
recorded platform follow-ups — none block the gate.
|
|
||||||
|
|
@ -39,22 +39,3 @@ Response items: `{listing_id, score, listing: <payload>}` ordered by hybrid scor
|
||||||
Install + proposal events land as memory records in `market:evidence` partition keyed by
|
Install + proposal events land as memory records in `market:evidence` partition keyed by
|
||||||
listing_id; a periodic indexer pass folds counts into `stats` in the registry (PR-less
|
listing_id; a periodic indexer pass folds counts into `stats` in the registry (PR-less
|
||||||
stats commit or scheduled batch PR — implementation's choice, registry remains truth).
|
stats commit or scheduled batch PR — implementation's choice, registry remains truth).
|
||||||
|
|
||||||
## AS-BUILT AMENDMENT (2026-07-02, analyze finding I1)
|
|
||||||
|
|
||||||
The live memory-api (agent.memory.system) exposes `/memory/store` + `/memory/search`
|
|
||||||
only — no partition drop, no delete, no upsert-by-id, and its server-side tenant filter
|
|
||||||
is a scalar `tenant_id` (not an array match). The implementation therefore deviates:
|
|
||||||
|
|
||||||
- Partition = the `agent_id` namespace; documents are memories with the listing in
|
|
||||||
`metadata.listing` and `metadata.listing_id`.
|
|
||||||
- Writer upsert = search-first for `metadata.listing_id`, store only if absent
|
|
||||||
(idempotent fill). Listing UPDATES and delist removal require memory-api delete —
|
|
||||||
until then, delisted/updated listings are filtered/handled at read time.
|
|
||||||
- Reader tenant filter `tenant_visibility ⊇ {caller tenant | "*"}` is enforced in the
|
|
||||||
ONE shared reader client (`cli/src/m2_market/catalog.py`), which every surface uses.
|
|
||||||
- Full rebuild = fresh-partition switch (`MEMORY_API_PARTITION` writer-side,
|
|
||||||
`M2_MARKET_CATALOG_PARTITION` reader-side) — docs/runbook.md.
|
|
||||||
|
|
||||||
The original section above is retained as the target contract for when memory-api
|
|
||||||
grows partition CRUD (fedlearn T13 follow-up).
|
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,7 @@ label) `→ delisted` (delist commit; grants unaffected). Veto label on PR → b
|
||||||
| `ref` | text | listing_id / grant note / install id |
|
| `ref` | text | listing_id / grant note / install id |
|
||||||
|
|
||||||
**Invariants**: rows never UPDATEd or DELETEd; `balance(op) = Σ to_id=op − Σ from_id=op`;
|
**Invariants**: rows never UPDATEd or DELETEd; `balance(op) = Σ to_id=op − Σ from_id=op`;
|
||||||
service refuses tx that would take a non-`mint` balance negative — EXCEPT `refund`
|
service refuses tx that would take a non-`mint` balance negative. An install produces 2–3
|
||||||
reversals, which are compensating entries and may overdraw a seller/platform that already
|
|
||||||
spent the proceeds (a debt position, settled at manual payout reconciliation; found by the
|
|
||||||
T013 property state machine, decided 2026-07-02). An install produces 2–3
|
|
||||||
rows atomically (one SQLite transaction): buyer→seller (net), buyer→platform (cut), and on
|
rows atomically (one SQLite transaction): buyer→seller (net), buyer→platform (cut), and on
|
||||||
apply-failure a compensating seller/platform→buyer `refund` pair referencing the same
|
apply-failure a compensating seller/platform→buyer `refund` pair referencing the same
|
||||||
install id.
|
install id.
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,8 @@ pytest ledger/tests/test_properties.py # balance==fold(log); grants a
|
||||||
## Scenario E — catalog rebuildability (constitution II)
|
## Scenario E — catalog rebuildability (constitution II)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# memory-api has no delete: rebuild = fresh-partition switch (docs/runbook.md)
|
m2-market reindex # drop + rebuild market:catalog
|
||||||
MEMORY_API_PARTITION=market:catalog-e2e python3 -m m2_market_indexer.reindex
|
m2-market search "branded pdf report" # same results as before reindex
|
||||||
M2_MARKET_CATALOG_PARTITION=market:catalog-e2e \
|
|
||||||
m2-market search "branded pdf report" # same results as the live partition
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scenario F — M2 Store on canary (Story 4) [after row 8]
|
## Scenario F — M2 Store on canary (Story 4) [after row 8]
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ published listing
|
||||||
- [x] T011 [P] [US1] Implement X-API-Key auth (service/admin key classes) in `ledger/src/m2_ledger/auth.py`
|
- [x] T011 [P] [US1] Implement X-API-Key auth (service/admin key classes) in `ledger/src/m2_ledger/auth.py`
|
||||||
- [x] T012 [US1] Implement API routes `/health`, `/balance/{op}`, `/tx/install`, `/tx/refund`, `/tx`, `/license/{op}/{listing}` in `ledger/src/m2_ledger/api.py` (402/409 semantics, all-or-nothing install tx, idempotent by ref)
|
- [x] T012 [US1] Implement API routes `/health`, `/balance/{op}`, `/tx/install`, `/tx/refund`, `/tx`, `/license/{op}/{listing}` in `ledger/src/m2_ledger/api.py` (402/409 semantics, all-or-nothing install tx, idempotent by ref)
|
||||||
- [x] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409
|
- [x] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409
|
||||||
- [x] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
|
- [ ] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network
|
||||||
- [x] T015 [US1] Create `market:catalog` partition and implement indexer `indexer/src/m2_market_indexer/reindex.py` + `watch.py` per contracts/catalog-index.md (registry → memory-api upsert, tenant_visibility payload, delist removal)
|
- [x] T015 [US1] Create `market:catalog` partition and implement indexer `indexer/src/m2_market_indexer/reindex.py` + `watch.py` per contracts/catalog-index.md (registry → memory-api upsert, tenant_visibility payload, delist removal)
|
||||||
|
|
||||||
### CLI (contracts/cli.md)
|
### CLI (contracts/cli.md)
|
||||||
|
|
@ -67,7 +67,7 @@ published listing
|
||||||
- [x] T023 [US1] `search` + `show` commands in `cli/src/m2_market/cli.py` (catalog query → table/JSON; show renders evidence/price/permissions diff)
|
- [x] T023 [US1] `search` + `show` commands in `cli/src/m2_market/cli.py` (catalog query → table/JSON; show renders evidence/price/permissions diff)
|
||||||
- [x] T024 [US1] `install` command in `cli/src/m2_market/cli.py`: the FR-005 sequence (confirm → debit → fetch → apply → verify → refund-on-failure → state → entrypoint hint) per contracts/cli.md
|
- [x] T024 [US1] `install` command in `cli/src/m2_market/cli.py`: the FR-005 sequence (confirm → debit → fetch → apply → verify → refund-on-failure → state → entrypoint hint) per contracts/cli.md
|
||||||
- [x] T025 [US1] `wallet` command in `cli/src/m2_market/cli.py` (balance + recent tx)
|
- [x] T025 [US1] `wallet` command in `cli/src/m2_market/cli.py` (balance + recent tx)
|
||||||
- [x] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
|
- [ ] T026 [US1] CLI integration tests in `cli/tests/test_install_flow.py` against a local ledger instance + fixture registry/catalog: funded install, insufficient funds (no side effects), idempotent re-run, apply-failure refund
|
||||||
|
|
||||||
**Checkpoint**: Scenario B/C pass end-to-end with a fixture listing (SC-003/005 held locally)
|
**Checkpoint**: Scenario B/C pass end-to-end with a fixture listing (SC-003/005 held locally)
|
||||||
|
|
||||||
|
|
@ -79,12 +79,12 @@ published listing
|
||||||
|
|
||||||
**Independent test**: quickstart.md Scenario A
|
**Independent test**: quickstart.md Scenario A
|
||||||
|
|
||||||
- [x] T027 [US2] `publish` command in `cli/src/m2_market/cli.py` + `cli/src/m2_market/publish.py`: validate bundle against schemas (exit 5 on violation incl. tenant firewall), push `listing/<id>` branch, open PR via Forgejo API with template
|
- [ ] T027 [US2] `publish` command in `cli/src/m2_market/cli.py` + `cli/src/m2_market/publish.py`: validate bundle against schemas (exit 5 on violation incl. tenant firewall), push `listing/<id>` branch, open PR via Forgejo API with template
|
||||||
- [x] T028 [US2] Registry CI hardening in `m2/market-registry` CI config: evidence-required, owner-initiated + double-scrub attestations for tenant-scoped submissions, content_hash-vs-release check (Story 2 AC-2/AC-4 mechanical rejection)
|
- [ ] T028 [US2] Registry CI hardening in `m2/market-registry` CI config: evidence-required, owner-initiated + double-scrub attestations for tenant-scoped submissions, content_hash-vs-release check (Story 2 AC-2/AC-4 mechanical rejection)
|
||||||
- [x] T029 [P] [US2] Package seed Solution `solutions/mm-pdf-report/` (from the proven mm-pdf outcome): solution.json, payload/, recipe.yaml, real evidence refs
|
- [ ] T029 [P] [US2] Package seed Solution `solutions/mm-pdf-report/` (from the proven mm-pdf outcome): solution.json, payload/, recipe.yaml, real evidence refs
|
||||||
- [x] T030 [P] [US2] Package seed Solution `solutions/agent-scaffold/` (from the agent-scaffold skill outcome): solution.json, payload/, recipe.yaml, real evidence
|
- [ ] T030 [P] [US2] Package seed Solution `solutions/agent-scaffold/` (from the agent-scaffold skill outcome): solution.json, payload/, recipe.yaml, real evidence
|
||||||
- [ ] T031 [P] [US2] BLOCKED-ON-OPERATOR: competitor-scan seed has no real evidence (constitution III) — t031 report recommends substituting Parlobyg email-triage (tenant-derived: needs owner-initiated + double-scrub sign-off) or another proven outcome
|
- [ ] T031 [P] [US2] Package seed Solution `solutions/competitor-scan/` (from the competitor-scan report outcome): solution.json, payload/, recipe.yaml, real evidence
|
||||||
- [x] T032 [US2] Publish all three seeds through the real protocol (PRs, review, merge), verify each discoverable via `m2-market search` (SC-002)
|
- [ ] T032 [US2] Publish all three seeds through the real protocol (PRs, review, merge), verify each discoverable via `m2-market search` (SC-002)
|
||||||
|
|
||||||
**Checkpoint**: ≥3 published evidence-backed listings live
|
**Checkpoint**: ≥3 published evidence-backed listings live
|
||||||
|
|
||||||
|
|
@ -96,13 +96,13 @@ published listing
|
||||||
|
|
||||||
**Independent test**: quickstart.md Scenario D + grant flow
|
**Independent test**: quickstart.md Scenario D + grant flow
|
||||||
|
|
||||||
- [x] T033 [US3] `/grant/starter` admin endpoint in `ledger/src/m2_ledger/api.py` + config-driven default amount (100 cr flagged-unconfirmed) with tests in `ledger/tests/test_grants.py`
|
- [ ] T033 [US3] `/grant/starter` admin endpoint in `ledger/src/m2_ledger/api.py` + config-driven default amount (100 cr flagged-unconfirmed) with tests in `ledger/tests/test_grants.py`
|
||||||
- [x] T034 [US3] Snapshot module `ledger/src/m2_ledger/snapshot.py`: balances dump → commit `audit/YYYY-MM-DD.json` to m2/market-registry via Forgejo API; `/snapshot` admin endpoint + daily cron (Coolify scheduled task or in-app scheduler)
|
- [ ] T034 [US3] Snapshot module `ledger/src/m2_ledger/snapshot.py`: balances dump → commit `audit/YYYY-MM-DD.json` to m2/market-registry via Forgejo API; `/snapshot` admin endpoint + daily cron (Coolify scheduled task or in-app scheduler)
|
||||||
- [x] T035 [US3] Issue starter grants to the two pilot operators (m2bd, sdjs-operator) and run Scenario D verification (snapshot committed, properties green)
|
- [ ] T035 [US3] Issue starter grants to the two pilot operators (m2bd, sdjs-operator) and run Scenario D verification (snapshot committed, properties green)
|
||||||
|
|
||||||
**🎯 WEDGE GATE (plan row 7, SC-001)**:
|
**🎯 WEDGE GATE (plan row 7, SC-001)**:
|
||||||
|
|
||||||
- [x] T036 [US3] Execute the real paid install between two operators: m2bd buys one seeded listing published by sdjs-operator via `m2-market install` on a real desktop; capture ledger rows, grant, applied bundle, and invoke the entrypoint; record evidence in `.m2herd/context/` and the run report
|
- [ ] T036 [US3] Execute the real paid install between two operators: m2bd buys one seeded listing published by sdjs-operator via `m2-market install` on a real desktop; capture ledger rows, grant, applied bundle, and invoke the entrypoint; record evidence in `.m2herd/context/` and the run report
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -113,10 +113,10 @@ branch `m2-store`; contracts referenced from this repo)
|
||||||
|
|
||||||
**Independent test**: quickstart.md Scenario F on chris-m2o
|
**Independent test**: quickstart.md Scenario F on chris-m2o
|
||||||
|
|
||||||
- [x] T037 [US4] Fork/branch `m2-store` on github.com/machine-machine/cargstore; swap catalog source from Flatpak JSON to `market:catalog` via memory-api HTTP (desktop-provisioned creds per research.md R7) in cargstore `src/` catalog service
|
- [ ] T037 [US4] Fork/branch `m2-store` on github.com/machine-machine/cargstore; swap catalog source from Flatpak JSON to `market:catalog` via memory-api HTTP (desktop-provisioned creds per research.md R7) in cargstore `src/` catalog service
|
||||||
- [x] T038 [US4] Add Solution install backend to cargstore that shells `m2-market install --json` and renders progress/result truthfully (incl. refund-on-failure path) in cargstore `src/` install manager
|
- [ ] T038 [US4] Add Solution install backend to cargstore that shells `m2-market install --json` and renders progress/result truthfully (incl. refund-on-failure path) in cargstore `src/` install manager
|
||||||
- [x] T039 [US4] Solution detail page: evidence, price, permissions diff (from `m2-market show --json`) in cargstore `src/` UI
|
- [ ] T039 [US4] Solution detail page: evidence, price, permissions diff (from `m2-market show --json`) in cargstore `src/` UI
|
||||||
- [x] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
|
- [ ] T040 [US4] Rebrand Clawdbot→M2 Store and deploy to canaries chris-m2o + gunnar-m2o only; run Scenario F
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -126,18 +126,18 @@ branch `m2-store`; contracts referenced from this repo)
|
||||||
|
|
||||||
**Independent test**: quickstart.md Scenario G
|
**Independent test**: quickstart.md Scenario G
|
||||||
|
|
||||||
- [x] T041 [US5] Scout host exploration spike → `scout/SPIKE.md`: compare standalone supervised watcher vs Hermes plugin vs herdr plugin/hook on: summary access, opt-in enforcement point, on-box summarization cost, notification surface, rollout story per image class (research.md R6; operator fork §14.4 "explore") — time-boxed, ends with a decision
|
- [ ] T041 [US5] Scout host exploration spike → `scout/SPIKE.md`: compare standalone supervised watcher vs Hermes plugin vs herdr plugin/hook on: summary access, opt-in enforcement point, on-box summarization cost, notification surface, rollout story per image class (research.md R6; operator fork §14.4 "explore") — time-boxed, ends with a decision
|
||||||
- [x] T042 [US5] Implement Scout v0 per SPIKE.md winner in `scout/` (or plugin location the spike picks): summary ingestion → embed → `market:catalog` match (tenant-scoped) → threshold → toast + deep link; opt-in via pull-policy config; rate cap (default 5/day)
|
- [ ] T042 [US5] Implement Scout v0 per SPIKE.md winner in `scout/` (or plugin location the spike picks): summary ingestion → embed → `market:catalog` match (tenant-scoped) → threshold → toast + deep link; opt-in via pull-policy config; rate cap (default 5/day)
|
||||||
- [x] T043 [US5] Proposal outcome recording (shown/dismissed/accepted/suppressed) locally + summarized to `market:evidence` per data-model.md Proposal; conversion counts folded to listing stats via indexer telemetry pass
|
- [ ] T043 [US5] Proposal outcome recording (shown/dismissed/accepted/suppressed) locally + summarized to `market:evidence` per data-model.md Proposal; conversion counts folded to listing stats via indexer telemetry pass
|
||||||
- [x] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
|
- [ ] T044 [US5] Deploy Scout v0 to ONE canary opt-in desktop; run Scenario G (incl. opt-out and rate-cap negative checks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8: Polish & Cross-Cutting
|
## Phase 8: Polish & Cross-Cutting
|
||||||
|
|
||||||
- [x] T045 [P] Install telemetry write-back (FR-014): CLI posts install outcome records to `market:evidence`; indexer folds into listing `stats.installs` — `cli/src/m2_market/telemetry.py` + indexer pass
|
- [ ] T045 [P] Install telemetry write-back (FR-014): CLI posts install outcome records to `market:evidence`; indexer folds into listing `stats.installs` — `cli/src/m2_market/telemetry.py` + indexer pass
|
||||||
- [x] T046 [P] `reindex` operational check task: run Scenario E, document recovery runbook in `docs/runbook.md` (incl. memory-api split-brain caveat from research.md R4)
|
- [ ] T046 [P] `reindex` operational check task: run Scenario E, document recovery runbook in `docs/runbook.md` (incl. memory-api split-brain caveat from research.md R4)
|
||||||
- [x] T047 Run full quickstart.md scenario sweep A–E on canaries; fix gaps; tick spec.md acceptance boxes in a verification note `specs/001-market-first-wedge/VERIFICATION.md`
|
- [ ] T047 Run full quickstart.md scenario sweep A–E on canaries; fix gaps; tick spec.md acceptance boxes in a verification note `specs/001-market-first-wedge/VERIFICATION.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Verification — 002-market-web
|
|
||||||
|
|
||||||
Date: 2026-07-02 · Live at https://market.machinemachine.ai (Coolify app s4jo1x35…, push CI/CD hook #15)
|
|
||||||
|
|
||||||
| SC | Status | Evidence |
|
|
||||||
|----|--------|----------|
|
|
||||||
| SC-101 browse→listing→wallet→governance, zero terminal | ✅ | Real-browser session on chris-m2o: login page → search results (both listings, prices) → nav to all panels. API walkthrough green (401 guard, 204 login, search, listing incl. install_command, wallet, economy, governance). |
|
|
||||||
| SC-102 wallet parity | ✅ | m2bd 60/60, sdjs-operator 226/226, chris-operator 0/0 — web vs ledger MATCH. |
|
|
||||||
| SC-103 zero key material served | ✅ | Scan of SPA assets + all API responses against all 5 live secrets: clean. Backend secrets-leak unit test enforces it in CI. |
|
|
||||||
| SC-104 health + degradation | ✅/design | /health healthy; upstream failure → 502 {error,panel} per contract (unit-tested per panel; live panels all up). |
|
|
||||||
| SC-105 responsive/fast | ✅ | SPA 50KB gz JS, loads instantly on fleet network; responsive layout (frontend build). |
|
|
||||||
|
|
||||||
Live-integration bugs found & fixed during T104 (all pushed, CI-deployed):
|
|
||||||
1. Forgejo auth: token-scheme header → 401 on this instance; switched to basic auth (the documented forgejo-skill gotcha — third time this class of thing bit a worker; contracts now note it).
|
|
||||||
2. Ledger tx field names: economy/wallet read `from`/`to`, real API returns `from_id`/`to_id`.
|
|
||||||
3. SPA static dir resolution broke when pip-installed (site-packages parents[] — same class as the indexer schema-path bug); M2MW_STATIC_DIR env now wins.
|
|
||||||
4. Coolify repo-URL mangling on app create (same as ledger app); PATCH with full URL.
|
|
||||||
|
|
||||||
Open (non-blocking): FR-105 fleet-passcode auth model awaits operator confirmation
|
|
||||||
(passcode in /home/m2/.m2-market-web-secrets, 0600); per-operator tokens are the upgrade path.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# Contract: m2-market-web /api (v1)
|
|
||||||
|
|
||||||
Config: GET /api/config → {auth_mode:"passcode"|"gpt"|"both"}.
|
|
||||||
Session: POST /api/login {passcode} OR {email,password} → 204 + Set-Cookie m2mw_session (signed, 7d) | 401.
|
|
||||||
Session identity: GET /api/session → {email,role,tenant_id,fleet_admin,operator_id,auth_method} with no upstream token.
|
|
||||||
All /api/* below require the cookie (401 otherwise). /health is open → {"status":"healthy"}.
|
|
||||||
|
|
||||||
- GET /api/search?q=&limit=10 → [{listing_id, name, summary, category, price:{amount,currency,model}, seller, stats, score}]
|
|
||||||
- GET /api/listing/{listing_id} → {listing..., evidence_summary, permissions[], install_ref, install_command: "m2-market install <id> --yes"}
|
|
||||||
- GET /api/wallet/{operator_id} → {operator_id, balance, as_of, transactions:[last 20 {ts,from,to,amount,reason,ref}]} | 403 for non-admin foreign wallet
|
|
||||||
- GET /api/economy → {operators:[{operator_id,balance}], audit:{date, url}} # url deep-links registry audit file
|
|
||||||
- GET /api/governance → {open_prs:[{number,title,age_days,labels,url}], listings:[{listing_id,status,url}]}
|
|
||||||
|
|
||||||
DTOs only — never raw upstream JSON. Upstream failure → 502 {"error","panel"} so the
|
|
||||||
frontend degrades per-panel. No key material in any response (tested).
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Implementation Plan: M2 Market Web
|
|
||||||
|
|
||||||
**Branch**: `002-market-web` | **Date**: 2026-07-02 | **Spec**: [spec.md](./spec.md)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
One Coolify-deployed container: FastAPI backend that proxies the three live rails
|
|
||||||
(memory-api catalog, m2-ledger, Forgejo registry) with keys server-side, serving a
|
|
||||||
React/Vite SPA. Read-only + install handoff. Rides the proven CI/CD (push → deploy),
|
|
||||||
http-scheme domain market.machinemachine.ai.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language**: Python 3.12 (FastAPI, httpx) + TypeScript React 18/Vite
|
|
||||||
**Storage**: none (stateless proxy; session cookie is signed, in-memory secret)
|
|
||||||
**Testing**: pytest for the backend proxy (httpx MockTransport per upstream, secrets-
|
|
||||||
leak test asserting no key strings in any response); vitest optional for components
|
|
||||||
**Deploy**: `web/Dockerfile` multi-stage (node build → python runtime), Coolify app
|
|
||||||
`m2-market-web`, env: FLEET_PASSCODE, SESSION_SECRET, MEMORY_API_URL/KEY,
|
|
||||||
LEDGER_URL/LEDGER_SERVICE_KEY, FORGEJO_URL/FORGEJO_TOKEN, REGISTRY_REPO
|
|
||||||
**Constraints**: constitution I (assembly: existing APIs only, no new rails), II (web is
|
|
||||||
a surface, never truth), IV (tenant filter server-side, same semantics as CLI reader), VI
|
|
||||||
(no secrets in image; runtime env)
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
Passes: no new services beyond the (sanctioned-as-surface) web app itself; all data
|
|
||||||
derived from registry/ledger/catalog; keys never leave the backend; read-only economy.
|
|
||||||
Deviation: none.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
web/
|
|
||||||
├── backend/
|
|
||||||
│ ├── src/m2_market_web/
|
|
||||||
│ │ ├── main.py # FastAPI app: static serve + /api/* + /health
|
|
||||||
│ │ ├── auth.py # FLEET_PASSCODE login -> signed session cookie
|
|
||||||
│ │ ├── catalog.py # /api/search, /api/listing/{id} (memory-api proxy,
|
|
||||||
│ │ │ # tenant filter — mirror cli catalog.py semantics)
|
|
||||||
│ │ ├── ledger.py # /api/wallet/{op}, /api/economy (balances+audit links)
|
|
||||||
│ │ └── registry.py # /api/governance (open PRs+labels, listings+status)
|
|
||||||
│ ├── tests/
|
|
||||||
│ └── pyproject.toml # uv workspace member
|
|
||||||
├── frontend/ # Vite + React + TS, dark navy/cyan (mm brand)
|
|
||||||
│ ├── src/{pages,components,api.ts}
|
|
||||||
│ └── package.json
|
|
||||||
└── Dockerfile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sequencing
|
|
||||||
|
|
||||||
1. Backend proxy + tests [P with 2] → 2. Frontend SPA [P with 1] (against a typed
|
|
||||||
/api contract defined in contracts/web-api.md) → 3. Dockerfile + Coolify app + domain +
|
|
||||||
webhook → 4. Live verification vs SC-101..105.
|
|
||||||
|
|
||||||
## Contract
|
|
||||||
|
|
||||||
`contracts/web-api.md` (written with tasks): /api/login {passcode}; /api/search?q=&limit=;
|
|
||||||
/api/listing/{id}; /api/wallet/{operator}; /api/economy; /api/governance; /health.
|
|
||||||
Every /api/* except /health and /login requires the session cookie. Responses are
|
|
||||||
minimal DTOs — never raw upstream bodies (leak surface).
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
# Feature Specification: M2 Market Web
|
|
||||||
|
|
||||||
**Feature Branch**: `002-market-web`
|
|
||||||
|
|
||||||
**Created**: 2026-07-02
|
|
||||||
|
|
||||||
**Status**: Draft
|
|
||||||
|
|
||||||
**Input**: Operator: "build the web app connecting everything" — one browser UI over the
|
|
||||||
wedge's live rails: catalog, listings+evidence, wallet/transactions, registry governance,
|
|
||||||
install handoff. Motivated by UAT: today money is curl-only and governance is raw Forgejo.
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Browse & inspect from any browser (Priority: P1)
|
|
||||||
|
|
||||||
An operator opens market.machinemachine.ai, browses/searches the live catalog
|
|
||||||
semantically, opens a listing, and sees everything the Store shows (evidence, price,
|
|
||||||
seller, version, permissions) plus stats.
|
|
||||||
|
|
||||||
**Independent Test**: open the URL, search "pdf report", open the listing, verify the
|
|
||||||
same data the CLI's `show` returns.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
1. **Given** the live catalog, **When** I search "branded pdf", **Then** published
|
|
||||||
listings render with name/summary/price/installs, ordered by relevance.
|
|
||||||
2. **Given** a listing page, **When** it loads, **Then** evidence summary, permissions,
|
|
||||||
seller, version, install_ref and stats are shown — same values as `m2-market show`.
|
|
||||||
3. **Given** a delisted or tenant-invisible listing, **Then** it does not appear.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Wallet & economy view (Priority: P1)
|
|
||||||
|
|
||||||
An operator picks their identity and sees their balance and transaction history; a fleet
|
|
||||||
view shows all operator balances and links each day to its committed audit snapshot.
|
|
||||||
|
|
||||||
**Independent Test**: select m2bd → balance matches `GET /balance/m2bd`; fleet view total
|
|
||||||
matches the latest audit/*.json in the registry.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
1. **Given** operator m2bd selected, **When** the wallet view loads, **Then** balance and
|
|
||||||
last 20 transactions match the ledger API exactly.
|
|
||||||
2. **Given** the fleet economy view, **Then** every operator balance is shown and the
|
|
||||||
"audit" link opens the registry's snapshot file for that day.
|
|
||||||
3. **Given** the browser, **Then** NO ledger/memory/forgejo key is ever present in
|
|
||||||
client-side code, network responses, or storage (server-side proxy only).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Governance view (Priority: P2)
|
|
||||||
|
|
||||||
Curation state at a glance: open listing PRs with their veto window, published/delisted
|
|
||||||
listings, links into Forgejo for the actual review actions.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
1. **Given** an open listing PR, **Then** it appears with title, age, labels, and a link
|
|
||||||
to the Forgejo PR (review/veto happens ON Forgejo — the web app never merges).
|
|
||||||
2. **Given** the listings index, **Then** each row shows status (published/delisted) and
|
|
||||||
links to its registry directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Install handoff (Priority: P2)
|
|
||||||
|
|
||||||
The web cannot apply bundles to a desktop (by design, v1). A listing page gives the
|
|
||||||
operator a copyable, correct install command and points at their desktops.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
1. **Given** a listing page, **When** I click "Install on my desktop", **Then** I get
|
|
||||||
`m2-market install <listing_id> --yes` copyable + a short "where to run it" note.
|
|
||||||
2. The page never claims an install happened.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
- Ledger or memory-api down → the affected panel shows a clear error, the rest works.
|
|
||||||
- Unknown operator id typed → empty wallet with a "no such wallet yet" note (balance 0).
|
|
||||||
- Catalog result whose registry entry vanished → row renders from catalog payload only.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
- **FR-101**: A web app MUST be served at market.machinemachine.ai (Coolify app,
|
|
||||||
`coolify` network, http-scheme domain per docs/runbook.md redirect-loop lesson).
|
|
||||||
- **FR-102**: All upstream calls (memory-api, ledger, Forgejo) MUST go through a
|
|
||||||
server-side backend holding the keys; the browser gets only proxied, minimal JSON.
|
|
||||||
- **FR-103**: Catalog search MUST use the same semantic path + tenant-visibility filter
|
|
||||||
as the CLI (shared reader semantics; server-side).
|
|
||||||
- **FR-104**: Wallet views MUST be read-only in v1 (no grants, no installs, no refunds
|
|
||||||
from the web).
|
|
||||||
- **FR-105**: Access MUST be gated by a fleet passcode (FLEET_PASSCODE env → session
|
|
||||||
cookie); operator identity within a session is a picker, not authentication —
|
|
||||||
UNCONFIRMED assumption, flagged for the operator (internal-trust model v1).
|
|
||||||
- **FR-106**: Governance data MUST come live from the Forgejo API (open PRs with labels,
|
|
||||||
listings dirs + status) with deep links; no write actions.
|
|
||||||
- **FR-107**: The app MUST degrade panel-by-panel when an upstream is down.
|
|
||||||
- **FR-108**: Deployment MUST ride the existing CI/CD pattern (push to m2/m2-market →
|
|
||||||
Coolify deploy; own app + webhook).
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
- **SC-101**: An operator completes browse → listing → wallet → governance in one
|
|
||||||
browser session with zero terminal usage.
|
|
||||||
- **SC-102**: Wallet numbers equal ledger API values at load time (spot-check 3 ops).
|
|
||||||
- **SC-103**: A secrets scan of served assets + responses shows zero key material.
|
|
||||||
- **SC-104**: App live at market.machinemachine.ai with health endpoint; panels degrade
|
|
||||||
gracefully when an upstream is stopped.
|
|
||||||
- **SC-105**: Lighthouse-basic usability: loads under 3s on the fleet network, usable on
|
|
||||||
a phone (the operator checks desktops from anywhere).
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- v1 auth = fleet passcode + operator picker (FR-105) — OPERATOR TO CONFIRM; upgrade path
|
|
||||||
is per-operator tokens issued by the ledger admin (post-v1).
|
|
||||||
- Install-from-web (remote apply to a desktop) is OUT of v1; handoff only (Story 4).
|
|
||||||
- Stack: FastAPI backend proxy + React/Vite frontend served by the same container —
|
|
||||||
matches fleet patterns (memory-api FastAPI; m2-gpt admin React) — monorepo `web/`.
|
|
||||||
- Reuses the wedge's frozen contracts; NO new schema, NO new ledger endpoints.
|
|
||||||
- cargstore `web/` (a static landing page) is superseded by this app.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Tasks: M2 Market Web
|
|
||||||
|
|
||||||
**Organization**: lean — the rails exist; this is a two-slice build + deploy.
|
|
||||||
|
|
||||||
- [x] T101 [P] Backend proxy in web/backend/: FastAPI app per plan.md structure + contracts/web-api.md — auth (FLEET_PASSCODE→signed cookie), catalog proxy (memory-api /memory/search, tenant_visibility filter mirroring cli/src/m2_market/catalog.py), wallet/economy (ledger API + audit links to registry), governance (Forgejo PRs+labels, listings+status), /health; pytest incl. a secrets-leak test (no env key value appears in any response body); uv workspace member
|
|
||||||
- [x] T102 [P] Frontend SPA in web/frontend/: Vite+React+TS, pages Search/Listing/Wallet/Economy/Governance + login gate + operator picker; install handoff block on Listing (copyable CLI command); dark navy + cyan (machine.machine brand); panel-level error states (FR-107); talks only to /api/*
|
|
||||||
- [x] T103 Dockerfile (multi-stage node build → python serve) + Coolify app m2-market-web on coolify network, domain http://market.machinemachine.ai, env per plan, Forgejo push webhook (CI/CD)
|
|
||||||
- [x] T104 Live verification: SC-101 walkthrough, SC-102 wallet spot-check (3 operators), SC-103 secrets scan of served assets+responses, SC-104 degradation (stop ledger briefly → wallet panel errors, catalog still works), SC-105 phone-width render; record in specs/002-market-web/VERIFICATION.md
|
|
||||||
|
|
||||||
Dependencies: T101 ∥ T102 (contract-first) → T103 → T104.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Verification — 003-gpt-identity (2026-07-02)
|
|
||||||
|
|
||||||
- Live: market.machinemachine.ai /api/config → {"auth_mode":"both"}; passcode login 204,
|
|
||||||
session now identity-shaped ({role: fleet_admin, operator_id: fleet, auth_method: passcode}).
|
|
||||||
- SC-201/202 covered by tests (13 green): gpt login happy path binds {email, role,
|
|
||||||
tenant_id, operator_id}; wrong password 401; foreign wallet 403 for non-admin;
|
|
||||||
fleet_admin picker retained. LIVE gpt-credential login awaits the operator's own
|
|
||||||
m2-gpt account (orchestrator holds no operator password — by design).
|
|
||||||
- SC-203: secrets-leak test extended with M2GPT secrets; passcode mode green.
|
|
||||||
- Introspection revalidation (FR-203) ships dark until m2-gpt PR #12 merges+deploys
|
|
||||||
(github.com/machine-machine/m2-gpt/pull/12) and M2GPT_INTROSPECTION_KEY is set on
|
|
||||||
both sides.
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# Feature Specification: m2-gpt identity for M2 Market Web
|
|
||||||
|
|
||||||
**Feature Branch**: `003-gpt-identity` · **Created**: 2026-07-02 · **Status**: Draft
|
|
||||||
**Input**: Operator: "we need a central credential system and ledger" — m2-gpt becomes the
|
|
||||||
credential authority; market-web stops using the shared fleet passcode.
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
1. (P1) An operator logs into market.machinemachine.ai with their m2-gpt admin
|
|
||||||
credentials (email+password). The session is BOUND to their identity (email, role,
|
|
||||||
tenant_id) — wallet defaults to their operator identity; no picker for non-admins.
|
|
||||||
2. (P1) fleet_admin sessions keep the operator picker (fleet oversight).
|
|
||||||
3. (P2) Transition: FLEET_PASSCODE login stays available behind AUTH_MODE=passcode|gpt|both
|
|
||||||
(default both) until the operator retires it.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
- FR-201: /api/login accepts {email,password} → backend forwards to m2-gpt
|
|
||||||
POST /admin/v1/auth/login (M2GPT_URL env) over TLS; on 200 binds the session cookie to
|
|
||||||
{email, role, tenant_id, fleet_admin} from the token response. Credentials are never
|
|
||||||
stored; the m2-gpt JWT is kept server-side in the session only if needed for refresh.
|
|
||||||
- FR-202: operator_id mapping: tenant_id when set, else email local-part; fleet_admin may
|
|
||||||
view any wallet (picker), others only their own (403 otherwise).
|
|
||||||
- FR-203: when M2GPT_INTROSPECTION_KEY env is set, the backend revalidates sessions via
|
|
||||||
POST /admin/v1/auth/introspect (the m2-gpt PR in flight) at most once per 10 min.
|
|
||||||
- FR-204: passcode mode unchanged when enabled; UI shows both forms per AUTH_MODE.
|
|
||||||
- FR-205: secrets discipline unchanged (SC-103 scan must stay green; no credential or JWT
|
|
||||||
in client code/storage beyond the opaque session cookie).
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
- SC-201: login with real m2-gpt operator credentials → wallet shows THEIR operator_id
|
|
||||||
without a picker; wrong password → 401.
|
|
||||||
- SC-202: non-admin requesting another wallet → 403.
|
|
||||||
- SC-203: secrets scan green; AUTH_MODE=passcode still works.
|
|
||||||
|
|
||||||
## Assumptions (flagged)
|
|
||||||
- Ledger wallets keyed by tenant_id going forward; existing ad-hoc wallet ids (m2bd,
|
|
||||||
sdjs-operator, chris-operator) remain and are reachable by fleet_admin — a formal
|
|
||||||
identity migration is deferred to the ledger-federation proposal (m2-gpt PR step 2/3).
|
|
||||||
- m2-gpt admin login is reachable from the web container (same coolify network / public).
|
|
||||||
620
uv.lock
Normal file
620
uv.lock
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[manifest]
|
||||||
|
members = [
|
||||||
|
"m2-ledger",
|
||||||
|
"m2-market",
|
||||||
|
"m2-market-indexer",
|
||||||
|
"m2-market-workspace",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.139.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hypothesis"
|
||||||
|
version = "6.155.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "sortedcontainers" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema"
|
||||||
|
version = "4.26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "jsonschema-specifications" },
|
||||||
|
{ name = "referencing" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema-specifications"
|
||||||
|
version = "2025.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "referencing" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m2-ledger"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "ledger" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "hypothesis" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.30" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "hypothesis", specifier = ">=6" },
|
||||||
|
{ name = "pytest", specifier = ">=8" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m2-market"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "cli" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "jsonschema" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "click", specifier = ">=8.1" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "jsonschema", specifier = ">=4.23" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m2-market-indexer"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "indexer" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "jsonschema" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "jsonschema", specifier = ">=4.23" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m2-market-workspace"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "referencing"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpds-py"
|
||||||
|
version = "2026.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sortedcontainers"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.49.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||||
|
]
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# m2-market-web — FastAPI proxy + React SPA in one container (specs/002-market-web).
|
|
||||||
# Build context: repo root (needs web/backend + web/frontend).
|
|
||||||
|
|
||||||
FROM node:20-slim AS frontend
|
|
||||||
WORKDIR /fe
|
|
||||||
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
|
||||||
RUN npm install --no-audit --no-fund
|
|
||||||
COPY web/frontend/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM python:3.12-slim
|
|
||||||
WORKDIR /app
|
|
||||||
COPY web/backend/pyproject.toml ./backend/pyproject.toml
|
|
||||||
COPY web/backend/src ./backend/src
|
|
||||||
RUN pip install --no-cache-dir ./backend
|
|
||||||
COPY --from=frontend /fe/dist ./frontend/dist
|
|
||||||
ENV M2MW_STATIC_DIR=/app/frontend/dist
|
|
||||||
|
|
||||||
RUN groupadd --system web && useradd --system --gid web web
|
|
||||||
USER web
|
|
||||||
EXPOSE 8000
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" || exit 1
|
|
||||||
CMD ["uvicorn", "m2_market_web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "m2-market-web"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "FastAPI backend proxy for the M2 Market web app"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.111",
|
|
||||||
"httpx>=0.27",
|
|
||||||
"itsdangerous>=2.2",
|
|
||||||
"uvicorn>=0.30",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/m2_market_web"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""M2 Market web backend."""
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from typing import Annotated, Any, Literal
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
|
||||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
from .http import client
|
|
||||||
|
|
||||||
COOKIE_NAME = "m2mw_session"
|
|
||||||
SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
|
||||||
INTROSPECTION_TTL_SECONDS = 10 * 60
|
|
||||||
AUTH_MODES = {"passcode", "gpt", "both"}
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
_introspection_cache: dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class LoginBody(BaseModel):
|
|
||||||
passcode: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SessionIdentity(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore")
|
|
||||||
|
|
||||||
email: str | None = None
|
|
||||||
role: str
|
|
||||||
tenant_id: str | None = None
|
|
||||||
fleet_admin: bool = False
|
|
||||||
operator_id: str
|
|
||||||
auth_method: Literal["passcode", "gpt"]
|
|
||||||
m2gpt_token: str | None = None
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return self.model_dump(exclude={"m2gpt_token"})
|
|
||||||
|
|
||||||
|
|
||||||
def _serializer() -> URLSafeTimedSerializer:
|
|
||||||
secret = os.environ.get("SESSION_SECRET")
|
|
||||||
if not secret:
|
|
||||||
raise HTTPException(status_code=500, detail="session_not_configured")
|
|
||||||
return URLSafeTimedSerializer(secret, salt="m2-market-web-session")
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_mode() -> str:
|
|
||||||
mode = os.environ.get("AUTH_MODE", "both").lower()
|
|
||||||
return mode if mode in AUTH_MODES else "both"
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_passcode() -> str:
|
|
||||||
passcode = os.environ.get("FLEET_PASSCODE")
|
|
||||||
if not passcode:
|
|
||||||
raise HTTPException(status_code=500, detail="auth_not_configured")
|
|
||||||
return passcode
|
|
||||||
|
|
||||||
|
|
||||||
def _operator_id(email: str | None, tenant_id: str | None) -> str:
|
|
||||||
if tenant_id:
|
|
||||||
return tenant_id
|
|
||||||
if email and "@" in email:
|
|
||||||
return email.split("@", 1)[0]
|
|
||||||
if email:
|
|
||||||
return email
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fleet_admin(role: str | None, fleet_admin: Any) -> bool:
|
|
||||||
if isinstance(fleet_admin, bool):
|
|
||||||
return fleet_admin
|
|
||||||
return str(role or "").lower() in {"fleet_admin", "admin", "owner", "superadmin"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
||||||
parts = token.split(".")
|
|
||||||
if len(parts) < 2:
|
|
||||||
return {}
|
|
||||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
||||||
try:
|
|
||||||
# This decode is intentionally unverified: the token arrived from m2-gpt over
|
|
||||||
# the direct TLS login exchange, and we only need identity claims for binding.
|
|
||||||
decoded = base64.urlsafe_b64decode(payload.encode()).decode("utf-8")
|
|
||||||
body = json.loads(decoded)
|
|
||||||
return body if isinstance(body, dict) else {}
|
|
||||||
except (ValueError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _token_from_response(body: dict[str, Any]) -> str | None:
|
|
||||||
for key in ("token", "access_token", "jwt", "id_token"):
|
|
||||||
token = body.get(key)
|
|
||||||
if isinstance(token, str) and token:
|
|
||||||
return token
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _identity_from_gpt_response(body: dict[str, Any], login_email: str) -> SessionIdentity:
|
|
||||||
token = _token_from_response(body)
|
|
||||||
claims = _decode_jwt_payload(token) if token else {}
|
|
||||||
user = body.get("user") if isinstance(body.get("user"), dict) else {}
|
|
||||||
source = {**claims, **user, **body}
|
|
||||||
|
|
||||||
email = str(source.get("email") or source.get("sub") or login_email)
|
|
||||||
role = str(source.get("role") or source.get("admin_role") or "operator")
|
|
||||||
tenant_id = source.get("tenant_id") or source.get("tenant")
|
|
||||||
tenant = str(tenant_id) if tenant_id else None
|
|
||||||
fleet_admin = _is_fleet_admin(role, source.get("fleet_admin"))
|
|
||||||
|
|
||||||
return SessionIdentity(
|
|
||||||
email=email,
|
|
||||||
role=role,
|
|
||||||
tenant_id=tenant,
|
|
||||||
fleet_admin=fleet_admin,
|
|
||||||
operator_id=_operator_id(email, tenant),
|
|
||||||
auth_method="gpt",
|
|
||||||
m2gpt_token=token if os.environ.get("M2GPT_INTROSPECTION_KEY") else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _passcode_identity() -> SessionIdentity:
|
|
||||||
return SessionIdentity(
|
|
||||||
email=None,
|
|
||||||
role="fleet_admin",
|
|
||||||
tenant_id=None,
|
|
||||||
fleet_admin=True,
|
|
||||||
operator_id="fleet",
|
|
||||||
auth_method="passcode",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_session(response: Response, identity: SessionIdentity) -> None:
|
|
||||||
token = _serializer().dumps(identity.model_dump())
|
|
||||||
response.set_cookie(
|
|
||||||
COOKIE_NAME,
|
|
||||||
token,
|
|
||||||
max_age=SESSION_MAX_AGE_SECONDS,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="lax",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _gpt_login(email: str, password: str) -> SessionIdentity:
|
|
||||||
base_url = os.environ.get("M2GPT_URL")
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
|
|
||||||
try:
|
|
||||||
async with client(base_url) as http:
|
|
||||||
response = await http.post(
|
|
||||||
"/admin/v1/auth/login", json={"email": email, "password": password}
|
|
||||||
)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if response.status_code in {401, 403}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable")
|
|
||||||
return _identity_from_gpt_response(body, email)
|
|
||||||
|
|
||||||
|
|
||||||
async def _revalidate_with_m2gpt(identity: SessionIdentity) -> None:
|
|
||||||
introspection_key = os.environ.get("M2GPT_INTROSPECTION_KEY")
|
|
||||||
if not introspection_key or identity.auth_method != "gpt" or not identity.m2gpt_token:
|
|
||||||
return
|
|
||||||
now = time.time()
|
|
||||||
cached_at = _introspection_cache.get(identity.m2gpt_token)
|
|
||||||
if cached_at and now - cached_at < INTROSPECTION_TTL_SECONDS:
|
|
||||||
return
|
|
||||||
base_url = os.environ.get("M2GPT_URL")
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(status_code=500, detail="gpt_auth_not_configured")
|
|
||||||
try:
|
|
||||||
async with client(base_url, {"X-Introspection-Key": introspection_key}) as http:
|
|
||||||
response = await http.post(
|
|
||||||
"/admin/v1/auth/introspect", json={"token": identity.m2gpt_token}
|
|
||||||
)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if response.status_code in {401, 403}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
try:
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError):
|
|
||||||
raise HTTPException(status_code=502, detail="gpt_auth_unavailable") from None
|
|
||||||
if isinstance(body, dict) and body.get("active") is False:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_introspection_cache[identity.m2gpt_token] = now
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
|
||||||
def config() -> dict[str, str]:
|
|
||||||
return {"auth_mode": _auth_mode()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def login(body: LoginBody, response: Response) -> None:
|
|
||||||
mode = _auth_mode()
|
|
||||||
if body.passcode is not None:
|
|
||||||
if mode not in {"passcode", "both"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
if not hmac.compare_digest(body.passcode, _configured_passcode()):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_set_session(response, _passcode_identity())
|
|
||||||
return
|
|
||||||
|
|
||||||
if body.email and body.password:
|
|
||||||
if mode not in {"gpt", "both"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
_set_session(response, await _gpt_login(body.email, body.password))
|
|
||||||
return
|
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_login_body")
|
|
||||||
|
|
||||||
|
|
||||||
async def require_session(
|
|
||||||
session_cookie: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
|
|
||||||
) -> SessionIdentity:
|
|
||||||
if not session_cookie:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
||||||
try:
|
|
||||||
payload = _serializer().loads(session_cookie, max_age=SESSION_MAX_AGE_SECONDS)
|
|
||||||
except (BadSignature, SignatureExpired):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
|
||||||
) from None
|
|
||||||
|
|
||||||
if payload.get("scope") == "fleet":
|
|
||||||
identity = _passcode_identity()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
identity = SessionIdentity.model_validate(payload)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
|
|
||||||
) from None
|
|
||||||
await _revalidate_with_m2gpt(identity)
|
|
||||||
return identity
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session")
|
|
||||||
async def session(identity: Annotated[SessionIdentity, Depends(require_session)]) -> dict[str, Any]:
|
|
||||||
return identity.public()
|
|
||||||
|
|
||||||
|
|
||||||
SessionDependency = Depends(require_session)
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Query
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from .auth import SessionDependency
|
|
||||||
from .http import client, upstream_error
|
|
||||||
|
|
||||||
PARTITION = os.environ.get("M2_MARKET_CATALOG_PARTITION", "market:catalog")
|
|
||||||
TENANT_DEFAULT = "m2-core"
|
|
||||||
|
|
||||||
router = APIRouter(dependencies=[SessionDependency])
|
|
||||||
|
|
||||||
|
|
||||||
def _tenant() -> str:
|
|
||||||
return os.environ.get("TENANT", TENANT_DEFAULT)
|
|
||||||
|
|
||||||
|
|
||||||
def _visible(listing: dict[str, Any]) -> bool:
|
|
||||||
visibility = listing.get("tenant_visibility") or []
|
|
||||||
return "*" in visibility or _tenant() in visibility
|
|
||||||
|
|
||||||
|
|
||||||
def _price(listing: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
price = listing.get("price")
|
|
||||||
if isinstance(price, dict):
|
|
||||||
return {
|
|
||||||
"amount": price.get("amount", 0),
|
|
||||||
"currency": price.get("currency", "credits"),
|
|
||||||
"model": price.get("model", "fixed"),
|
|
||||||
}
|
|
||||||
return {"amount": listing.get("price_amount", 0), "currency": "credits", "model": "fixed"}
|
|
||||||
|
|
||||||
|
|
||||||
def _listing_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"listing_id": listing_id,
|
|
||||||
"name": listing.get("name") or listing_id,
|
|
||||||
"summary": listing.get("summary") or "",
|
|
||||||
"category": listing.get("category") or "",
|
|
||||||
"price": _price(listing),
|
|
||||||
"seller": listing.get("seller") or listing.get("seller_id") or "",
|
|
||||||
"stats": listing.get("stats") or {},
|
|
||||||
"score": score,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _detail_dto(listing_id: str, listing: dict[str, Any], score: Any = None) -> dict[str, Any]:
|
|
||||||
dto = _listing_dto(listing_id, listing, score)
|
|
||||||
dto.update(
|
|
||||||
{
|
|
||||||
"evidence_summary": listing.get("evidence_summary") or "",
|
|
||||||
"permissions": listing.get("permissions") or [],
|
|
||||||
"install_ref": listing.get("install_ref") or "",
|
|
||||||
"install_command": f"m2-market install {listing_id} --yes",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return dto
|
|
||||||
|
|
||||||
|
|
||||||
async def _memory_search(query_text: str, limit: int) -> list[dict[str, Any]]:
|
|
||||||
base_url = os.environ["MEMORY_API_URL"]
|
|
||||||
api_key = os.environ["MEMORY_API_KEY"]
|
|
||||||
async with client(base_url, {"X-API-Key": api_key}) as http:
|
|
||||||
response = await http.post(
|
|
||||||
"/memory/search",
|
|
||||||
json={
|
|
||||||
"query": query_text,
|
|
||||||
"agent_id": PARTITION,
|
|
||||||
"memory_types": ["semantic"],
|
|
||||||
"limit": max(limit * 3, limit),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for result in response.json().get("results", []):
|
|
||||||
meta = result.get("metadata") or {}
|
|
||||||
listing = meta.get("listing")
|
|
||||||
listing_id = meta.get("listing_id")
|
|
||||||
if not isinstance(listing, dict) or not listing_id:
|
|
||||||
continue
|
|
||||||
if not _visible(listing):
|
|
||||||
continue
|
|
||||||
items.append(
|
|
||||||
{"listing_id": listing_id, "score": result.get("score"), "listing": listing}
|
|
||||||
)
|
|
||||||
if len(items) >= limit:
|
|
||||||
break
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=None)
|
|
||||||
async def search(
|
|
||||||
q: str = "", limit: int = Query(10, ge=1, le=50)
|
|
||||||
) -> list[dict[str, Any]] | JSONResponse:
|
|
||||||
try:
|
|
||||||
return [
|
|
||||||
_listing_dto(i["listing_id"], i["listing"], i["score"])
|
|
||||||
for i in await _memory_search(q, limit)
|
|
||||||
]
|
|
||||||
except (httpx.HTTPError, KeyError):
|
|
||||||
return upstream_error("catalog")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/listing/{listing_id}", response_model=None)
|
|
||||||
async def listing(listing_id: str) -> dict[str, Any] | JSONResponse:
|
|
||||||
try:
|
|
||||||
for item in await _memory_search(listing_id, 10):
|
|
||||||
if item["listing_id"] == listing_id:
|
|
||||||
return _detail_dto(item["listing_id"], item["listing"], item["score"])
|
|
||||||
return upstream_error("catalog")
|
|
||||||
except (httpx.HTTPError, KeyError):
|
|
||||||
return upstream_error("catalog")
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
|
|
||||||
def upstream_error(panel: str) -> JSONResponse:
|
|
||||||
return JSONResponse(status_code=502, content={"error": "upstream_failure", "panel": panel})
|
|
||||||
|
|
||||||
|
|
||||||
def async_transport() -> httpx.AsyncBaseTransport | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def client(base_url: str, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
base_url=base_url.rstrip("/"),
|
|
||||||
headers=headers or {},
|
|
||||||
timeout=20.0,
|
|
||||||
transport=async_transport(),
|
|
||||||
)
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from datetime import date
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from .auth import SessionDependency, SessionIdentity, require_session
|
|
||||||
from .http import client, upstream_error
|
|
||||||
|
|
||||||
router = APIRouter(dependencies=[SessionDependency])
|
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
|
||||||
return {"X-API-Key": os.environ["LEDGER_SERVICE_KEY"]}
|
|
||||||
|
|
||||||
|
|
||||||
def _tx_dto(tx: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"ts": tx.get("ts"),
|
|
||||||
"from": tx.get("from_id", tx.get("from")),
|
|
||||||
"to": tx.get("to_id", tx.get("to")),
|
|
||||||
"amount": tx.get("amount"),
|
|
||||||
"reason": tx.get("reason"),
|
|
||||||
"ref": tx.get("ref"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _registry_raw_url(path: str) -> str:
|
|
||||||
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
|
|
||||||
repo = os.environ["REGISTRY_REPO"].strip("/")
|
|
||||||
return f"{forgejo}/{repo}/raw/branch/main/{path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(raw: str) -> str:
|
|
||||||
import base64
|
|
||||||
|
|
||||||
return base64.b64encode(raw.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
async def _latest_audit_date() -> str:
|
|
||||||
today = date.today().isoformat()
|
|
||||||
forgejo = os.environ["FORGEJO_URL"].rstrip("/")
|
|
||||||
repo = os.environ["REGISTRY_REPO"].strip("/")
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
|
||||||
user = os.environ.get("FORGEJO_USER", "m2")
|
|
||||||
# this Forgejo instance rejects token-scheme headers; HTTP basic only (forgejo skill)
|
|
||||||
headers = {"Authorization": "Basic " + _b64(f"{user}:{token}")} if token else {}
|
|
||||||
async with client(forgejo, headers) as http:
|
|
||||||
response = await http.get(f"/api/v1/repos/{repo}/contents/audit", params={"ref": "main"})
|
|
||||||
response.raise_for_status()
|
|
||||||
dates = sorted(
|
|
||||||
item["name"].removesuffix(".json")
|
|
||||||
for item in response.json()
|
|
||||||
if isinstance(item, dict) and str(item.get("name", "")).endswith(".json")
|
|
||||||
)
|
|
||||||
return today if today in dates else (dates[-1] if dates else today)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wallet/{operator_id}", response_model=None)
|
|
||||||
async def wallet(
|
|
||||||
operator_id: str, session: SessionIdentity = Depends(require_session)
|
|
||||||
) -> dict[str, Any] | JSONResponse:
|
|
||||||
if not session.fleet_admin and operator_id != session.operator_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
|
|
||||||
try:
|
|
||||||
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
|
||||||
balance = await http.get(f"/balance/{operator_id}")
|
|
||||||
balance.raise_for_status()
|
|
||||||
tx_log = await http.get("/tx", params={"operator_id": operator_id})
|
|
||||||
tx_log.raise_for_status()
|
|
||||||
balance_body = balance.json()
|
|
||||||
transactions = tx_log.json().get("transactions", [])[:20]
|
|
||||||
return {
|
|
||||||
"operator_id": operator_id,
|
|
||||||
"balance": balance_body.get("balance", 0),
|
|
||||||
"as_of": balance_body.get("as_of"),
|
|
||||||
"transactions": [_tx_dto(tx) for tx in transactions],
|
|
||||||
}
|
|
||||||
except (httpx.HTTPError, KeyError):
|
|
||||||
return upstream_error("wallet")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/economy", response_model=None)
|
|
||||||
async def economy() -> dict[str, Any] | JSONResponse:
|
|
||||||
try:
|
|
||||||
async with client(os.environ["LEDGER_URL"], _headers()) as http:
|
|
||||||
tx_log = await http.get("/tx")
|
|
||||||
tx_log.raise_for_status()
|
|
||||||
transactions = tx_log.json().get("transactions", [])
|
|
||||||
operators = sorted(
|
|
||||||
{
|
|
||||||
party
|
|
||||||
for tx in transactions
|
|
||||||
for party in (tx.get("from_id", tx.get("from")), tx.get("to_id", tx.get("to")))
|
|
||||||
if party and party != "mint"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
balances = []
|
|
||||||
for operator_id in operators:
|
|
||||||
response = await http.get(f"/balance/{operator_id}")
|
|
||||||
response.raise_for_status()
|
|
||||||
body = response.json()
|
|
||||||
balances.append(
|
|
||||||
{"operator_id": operator_id, "balance": body.get("balance", 0)}
|
|
||||||
)
|
|
||||||
audit_date = await _latest_audit_date()
|
|
||||||
return {
|
|
||||||
"operators": balances,
|
|
||||||
"audit": {"date": audit_date, "url": _registry_raw_url(f"audit/{audit_date}.json")},
|
|
||||||
}
|
|
||||||
except (httpx.HTTPError, KeyError):
|
|
||||||
return upstream_error("economy")
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue