"""Operator commands for the optional Mission Core artifact gateway.""" from __future__ import annotations import json import mimetypes import os from pathlib import Path from typing import Annotated import typer from rich.console import Console from k1link.artifact_gateway import ( CACHE_MAX_BYTES_ENV, CACHE_RESERVE_BYTES_ENV, DEFAULT_CACHE_MAX_BYTES, DEFAULT_FREE_SPACE_RESERVE_BYTES, STORE_ROOT_ENV, ArtifactGateway, ArtifactGatewayError, CentralArtifactStore, LocalArtifactCache, ) from k1link.sessions.store import resolve_missioncore_data_dir app = typer.Typer( help="Publish, pin, inspect, and resolve verified central Mission Core artifacts.", no_args_is_help=True, ) console = Console() def _positive_environment_bytes(name: str, default: int) -> int: value = os.environ.get(name, "").strip() if not value: return default if not value.isascii() or not value.isdecimal() or int(value) <= 0: raise ValueError(f"{name} must be a positive integer") return int(value) def _non_negative_environment_bytes(name: str, default: int) -> int: value = os.environ.get(name, "").strip() if not value: return default if not value.isascii() or not value.isdecimal(): raise ValueError(f"{name} must be a non-negative integer") return int(value) def _gateway(repository_root: Path, *, create_store: bool = False) -> ArtifactGateway: configured = os.environ.get(STORE_ROOT_ENV, "").strip() if not configured: raise ValueError(f"{STORE_ROOT_ENV} is not configured") data_dir = resolve_missioncore_data_dir(repository_root) return ArtifactGateway( CentralArtifactStore(Path(configured), create=create_store), LocalArtifactCache( data_dir / "artifact-cache", max_bytes=_positive_environment_bytes( CACHE_MAX_BYTES_ENV, DEFAULT_CACHE_MAX_BYTES, ), free_space_reserve_bytes=_non_negative_environment_bytes( CACHE_RESERVE_BYTES_ENV, DEFAULT_FREE_SPACE_RESERVE_BYTES, ), ), ) def _parse_assignment(value: str, *, label: str) -> tuple[str, str]: key, separator, item = value.partition("=") if not separator or not key or not item: raise ValueError(f"{label} must use KEY=VALUE") return key, item def _media_type(path: Path) -> str: explicit = { ".rrd": "application/vnd.rerun.rrd", ".jsonl": "application/x-ndjson", ".npz": "application/x-numpy", ".k1mqtt": "application/vnd.nodedc.k1mqtt", ".tar": "application/x-tar", ".tgz": "application/gzip", ".m4s": "video/iso.segment", }.get(path.suffix.lower()) if explicit is not None: return explicit guessed, _encoding = mimetypes.guess_type(path.name) return guessed or "application/octet-stream" @app.command("publish") def publish( namespace: Annotated[str, typer.Option("--namespace")], key: Annotated[str, typer.Option("--key")], artifact_type: Annotated[str, typer.Option("--artifact-type")], subject_id: Annotated[str, typer.Option("--subject-id")], member: Annotated[ list[str], typer.Option( "--member", help="Repeat ROLE=/absolute/file/path for every immutable bundle member.", ), ], metadata: Annotated[ list[str] | None, typer.Option("--metadata", help="Optional repeatable KEY=VALUE metadata."), ] = None, ) -> None: """Publish exact files, then atomically advance one named central reference.""" repository_root = Path(__file__).resolve().parents[2] try: sources = [] for raw in member: role, raw_path = _parse_assignment(raw, label="member") path = Path(raw_path).expanduser().resolve(strict=True) sources.append((role, _media_type(path), path)) metadata_values = dict( _parse_assignment(raw, label="metadata") for raw in (metadata or []) ) manifest = _gateway(repository_root, create_store=True).publish( namespace=namespace, key=key, artifact_type=artifact_type, subject_id=subject_id, sources=sources, metadata=metadata_values, ) except (ArtifactGatewayError, OSError, ValueError) as exc: console.print(f"[red]Artifact publication failed:[/red] {exc}") raise typer.Exit(code=2) from exc total_bytes = sum(item.byte_length for item in manifest.members) console.print( "[green]Central artifact published.[/green] " f"reference={namespace}/{key}; manifest={manifest.manifest_id}; " f"objects={len(manifest.members)}; logical_bytes={total_bytes}" ) @app.command("pin") def pin( namespace: Annotated[str, typer.Option("--namespace")], key: Annotated[str, typer.Option("--key")], pin_id: Annotated[str | None, typer.Option("--pin-id")] = None, ) -> None: """Fetch and persistently pin every object in one named bundle.""" repository_root = Path(__file__).resolve().parents[2] selected_pin = pin_id or f"{namespace}:{key}" try: gateway = _gateway(repository_root) manifest = gateway.pin_reference(namespace, key, pin_id=selected_pin) status = gateway.cache.status() except (ArtifactGatewayError, OSError, ValueError) as exc: console.print(f"[red]Artifact pin failed:[/red] {exc}") raise typer.Exit(code=2) from exc console.print( "[green]Artifact bundle pinned.[/green] " f"manifest={manifest.manifest_id}; pin={selected_pin}; " f"pinned_objects={status.pinned_object_count}; pinned_bytes={status.pinned_bytes}" ) @app.command("unpin") def unpin(pin_id: Annotated[str, typer.Option("--pin-id")]) -> None: """Remove one persistent pin and immediately enforce local LRU limits.""" repository_root = Path(__file__).resolve().parents[2] try: gateway = _gateway(repository_root) gateway.cache.unpin(pin_id) status = gateway.cache.status() except (ArtifactGatewayError, OSError, ValueError) as exc: console.print(f"[red]Artifact unpin failed:[/red] {exc}") raise typer.Exit(code=2) from exc console.print( "[green]Artifact pin removed.[/green] " f"pin={pin_id}; cached_bytes={status.total_bytes}; " f"pinned_bytes={status.pinned_bytes}" ) @app.command("resolve") def resolve( namespace: Annotated[str, typer.Option("--namespace")], key: Annotated[str, typer.Option("--key")], role: Annotated[str, typer.Option("--role")], json_output: Annotated[bool, typer.Option("--json")] = False, ) -> None: """Resolve one role to a verified local path, fetching it when online.""" repository_root = Path(__file__).resolve().parents[2] try: artifact = _gateway(repository_root).resolve_role(namespace, key, role) except (ArtifactGatewayError, OSError, ValueError) as exc: console.print(f"[red]Artifact resolution failed:[/red] {exc}") raise typer.Exit(code=2) from exc document = { "manifest_id": artifact.manifest.manifest_id, "role": artifact.member.role, "sha256": artifact.member.sha256, "byte_length": artifact.member.byte_length, "path": str(artifact.path), "cache_hit": artifact.cache_hit, "central_available": artifact.central_available, } if json_output: typer.echo(json.dumps(document, ensure_ascii=False, indent=2)) return console.print( "[green]Artifact resolved.[/green] " f"role={role}; path={artifact.path}; cache_hit={artifact.cache_hit}; " f"central_available={artifact.central_available}" ) @app.command("status") def status(json_output: Annotated[bool, typer.Option("--json")] = False) -> None: """Report local working-set quota and persistent pin usage.""" repository_root = Path(__file__).resolve().parents[2] try: cache_status = _gateway(repository_root).cache.status() except (ArtifactGatewayError, OSError, ValueError) as exc: console.print(f"[red]Artifact status failed:[/red] {exc}") raise typer.Exit(code=2) from exc document = { "object_count": cache_status.object_count, "total_bytes": cache_status.total_bytes, "pinned_object_count": cache_status.pinned_object_count, "pinned_bytes": cache_status.pinned_bytes, "cache_max_bytes": cache_status.cache_max_bytes, "free_space_reserve_bytes": cache_status.free_space_reserve_bytes, } if json_output: typer.echo(json.dumps(document, ensure_ascii=False, indent=2)) return console.print( f"objects={cache_status.object_count}; bytes={cache_status.total_bytes}; " f"pinned_objects={cache_status.pinned_object_count}; " f"pinned_bytes={cache_status.pinned_bytes}; " f"quota={cache_status.cache_max_bytes}" )