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
168 lines
5.8 KiB
Python
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())
|