Files
wehub-resource-sync bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:44 +08:00

168 lines
5.8 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
import subprocess
import sys
import time
from urllib.parse import urlparse
import httpx
from mirage.cli.env import ENV_AUTH_MODE, ENV_AUTH_TOKEN
from mirage.cli.settings import DaemonSettings, load_daemon_settings
from mirage.server.auth import AuthMode
from mirage.server.auth import storage as auth_storage
from mirage.server.daemon_config import (read_daemon_table,
validate_daemon_table)
from mirage.server.env import ENV_DAEMON_PORT
from mirage.server.paths import mirage_home
class DaemonUnreachable(RuntimeError):
pass
class DaemonClient:
"""Thin httpx wrapper around the Mirage daemon REST API.
Constructs once per CLI invocation and is reused across calls.
Handles auth header injection, daemon auto-spawn on first
``workspace --create``, and discovery via the settings chain.
"""
def __init__(self, settings: DaemonSettings) -> None:
self.settings = settings
self._client = httpx.Client(base_url=settings.url, timeout=60.0)
def __enter__(self) -> "DaemonClient":
return self
def __exit__(self, *exc_info) -> None:
self._client.close()
def _headers(self) -> dict[str, str]:
if self.settings.auth_token:
return {"Authorization": f"Bearer {self.settings.auth_token}"}
return {}
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
headers = {**self._headers(), **kwargs.pop("headers", {})}
return self._client.request(method, path, headers=headers, **kwargs)
def is_reachable(self, timeout: float = 0.5) -> bool:
try:
r = self._client.get("/v1/health",
timeout=timeout,
headers=self._headers())
return r.status_code == 200
except httpx.RequestError:
return False
def ensure_running(self,
startup_timeout: float = 5.0,
allow_spawn: bool = True) -> None:
"""Ensure the daemon is reachable, optionally spawning it.
Args:
startup_timeout (float): seconds to wait for the spawned
daemon to answer ``/v1/health``.
allow_spawn (bool): if True and the daemon is unreachable,
fork-execs ``uvicorn mirage.server.app:app`` detached.
Raises:
DaemonUnreachable: daemon is not reachable and either
``allow_spawn=False`` or the spawned daemon failed to
come up within ``startup_timeout``.
"""
if self.is_reachable():
return
if not allow_spawn:
raise DaemonUnreachable(
f"daemon not reachable at {self.settings.url}; "
"run `mirage workspace --create CONFIG.yaml` to spawn one")
self._spawn_daemon()
deadline = time.monotonic() + startup_timeout
while time.monotonic() < deadline:
if self.is_reachable(timeout=0.3):
return
time.sleep(0.1)
raise DaemonUnreachable(
f"daemon spawned but did not answer /v1/health within "
f"{startup_timeout:.1f}s")
def _spawn_daemon(self) -> None:
table = read_daemon_table(mirage_home())
validate_daemon_table(table)
port = self._resolve_port(table)
env = dict(os.environ)
if not self.settings.auth_token:
self.settings.auth_token = auth_storage.ensure_token_file(
auth_storage.default_token_file())
env[ENV_AUTH_TOKEN] = self.settings.auth_token
if ENV_AUTH_MODE not in env and not table.get("auth_mode"):
env[ENV_AUTH_MODE] = AuthMode.LOCAL.value
cmd = [
sys.executable,
"-m",
"uvicorn",
"mirage.server.daemon:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--log-level",
"warning",
]
log_dir = mirage_home()
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "daemon.log"
with open(log_file, "ab") as f:
subprocess.Popen(
cmd,
env=env,
stdout=f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def _resolve_port(self, table: dict) -> int:
"""Resolve the listen port for a spawned daemon.
Priority: ``$MIRAGE_DAEMON_PORT``, then the ``port`` key in
``config.toml`` ``[daemon]``, then the port embedded in the
client ``url`` setting, then 8765.
Args:
table (dict): the already-read ``[daemon]`` table.
Returns:
int: the port to bind.
"""
env_port = os.environ.get(ENV_DAEMON_PORT)
if env_port:
return int(env_port)
config_port = table.get("port")
if config_port:
return int(config_port)
return self._port_from_url()
def _port_from_url(self) -> int:
parsed = urlparse(self.settings.url)
return parsed.port or 8765
def make_client() -> DaemonClient:
return DaemonClient(load_daemon_settings())