feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -12,11 +12,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
|
||||
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
|
||||
try:
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
@@ -27,6 +28,48 @@ except ImportError:
|
||||
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
_VIDEO_MEDIA_TYPES = {
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
}
|
||||
|
||||
|
||||
def _allowed_media_roots() -> list[Path]:
|
||||
"""Directories the media endpoints are permitted to serve from."""
|
||||
roots = []
|
||||
for root in (WORKSPACE_ROOT, TRACES_PATH):
|
||||
try:
|
||||
roots.append(Path(root).resolve())
|
||||
except OSError:
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def _resolve_media_path(raw_path: str, allowed_suffixes: set[str]) -> Path:
|
||||
"""Resolve a client-supplied path strictly inside the allowed media roots.
|
||||
|
||||
Every candidate is fully resolved (symlinks, `..`, mixed separators) and
|
||||
then re-checked against the allowed roots and an extension allowlist, so a
|
||||
crafted URL can never address source code, databases, or dotfiles.
|
||||
"""
|
||||
roots = _allowed_media_roots()
|
||||
candidate = Path(raw_path)
|
||||
attempts = [candidate] if candidate.is_absolute() else [root / candidate for root in roots]
|
||||
for attempt in attempts:
|
||||
try:
|
||||
resolved = attempt.resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not resolved.is_file():
|
||||
continue
|
||||
if not any(resolved.is_relative_to(root) for root in roots):
|
||||
continue
|
||||
if resolved.suffix.lower() not in allowed_suffixes:
|
||||
raise HTTPException(status_code=403, detail="File type is not served by the media API.")
|
||||
return resolved
|
||||
raise HTTPException(status_code=404, detail="Media file not found")
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
@router.get("/debug", response_class=HTMLResponse)
|
||||
@@ -47,8 +90,12 @@ async def get_admin_index():
|
||||
async def get_image(image_name: str):
|
||||
if not image_name.endswith(".jpg"):
|
||||
image_name += ".jpg"
|
||||
image_path = IMAGES_DIR / image_name
|
||||
if not image_path.exists():
|
||||
try:
|
||||
images_root = IMAGES_DIR.resolve()
|
||||
image_path = (images_root / image_name).resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
if not image_path.is_file() or not image_path.is_relative_to(images_root):
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
return FileResponse(image_path, media_type="image/jpeg")
|
||||
@@ -56,34 +103,18 @@ async def get_image(image_name: str):
|
||||
|
||||
@router.get("/videos/{video_path:path}")
|
||||
async def get_video(video_path: str):
|
||||
path = Path(video_path)
|
||||
if not path.exists():
|
||||
if not path.is_absolute():
|
||||
candidate = WORKSPACE_ROOT / path
|
||||
if candidate.exists():
|
||||
path = candidate
|
||||
else:
|
||||
candidate_root = Path("/" + video_path.lstrip("/"))
|
||||
if candidate_root.exists():
|
||||
path = candidate_root
|
||||
else:
|
||||
path = candidate
|
||||
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Video not found at {path}")
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
media_type = "video/mp4"
|
||||
if suffix == ".webm":
|
||||
media_type = "video/webm"
|
||||
elif suffix == ".mkv":
|
||||
media_type = "video/x-matroska"
|
||||
|
||||
return FileResponse(path, media_type=media_type)
|
||||
path = _resolve_media_path(video_path, set(_VIDEO_MEDIA_TYPES))
|
||||
return FileResponse(path, media_type=_VIDEO_MEDIA_TYPES[path.suffix.lower()])
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/video")
|
||||
async def get_session_video(session_id: str):
|
||||
# Blocking work (sqlite, filesystem scan, possible ffmpeg conversion) runs
|
||||
# off the event loop.
|
||||
return await asyncio.to_thread(_get_session_video_sync, session_id)
|
||||
|
||||
|
||||
def _get_session_video_sync(session_id: str):
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
video_idx = media_service.build_video_index()
|
||||
row = session_repo.get_session_by_id(session_id)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from uuid import UUID
|
||||
@@ -38,125 +39,133 @@ router = APIRouter(tags=["sessions"])
|
||||
|
||||
@router.get("/api/sessions")
|
||||
async def list_sessions():
|
||||
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg
|
||||
# conversion on first sight of a new recording), so run it off the event
|
||||
# loop — the frontend polls this endpoint and it must not stall other
|
||||
# requests.
|
||||
try:
|
||||
rows = session_repo.get_all_sessions()
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||
video_idx = media_service.build_video_index()
|
||||
default_model_info = model_service.get_active_model_info()
|
||||
|
||||
orphaned_ids = []
|
||||
unresolved_profiles = []
|
||||
result = []
|
||||
|
||||
try:
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
active_owner_sids = {
|
||||
str(owner.session_id)
|
||||
for owner in active_owners.values()
|
||||
if owner.session_id
|
||||
}
|
||||
except Exception:
|
||||
active_owner_sids = set()
|
||||
|
||||
for row_dict in rows:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
recording = latest_recordings.get(s_id)
|
||||
d_info_raw = row_dict.get("device_info")
|
||||
device_id = None
|
||||
if d_info_raw:
|
||||
try:
|
||||
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
|
||||
if isinstance(d_info, dict):
|
||||
device_id = d_info.get("device_id") or d_info.get("device_serial")
|
||||
except Exception:
|
||||
pass
|
||||
if not device_id and recording:
|
||||
device_id = recording.get("device_id")
|
||||
row_dict["device_id"] = device_id
|
||||
row_dict["device_serial"] = device_id
|
||||
|
||||
if row_dict.get("status") == "running":
|
||||
is_active = (
|
||||
s_id in active_owner_sids
|
||||
or s_id in state.active_connections
|
||||
or (state.is_running and s_id == str(state.active_session_id))
|
||||
)
|
||||
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
|
||||
if not is_active and not worker_is_alive:
|
||||
row_dict["status"] = "failed"
|
||||
row_dict["end_time"] = time.time()
|
||||
orphaned_ids.append(s_id)
|
||||
|
||||
recording_status = str((recording or {}).get("status") or "unavailable")
|
||||
resolved_v_url = (
|
||||
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
|
||||
if recording_status != "recording"
|
||||
else None
|
||||
)
|
||||
if resolved_v_url:
|
||||
recording_status = "ready"
|
||||
row_dict["video_url"] = resolved_v_url
|
||||
else:
|
||||
row_dict["video_url"] = None
|
||||
row_dict["recording_status"] = recording_status
|
||||
|
||||
agent_names = agent_names_by_session.get(s_id, [])
|
||||
sess_profile = model_service.resolve_session_profile(
|
||||
row_dict, None, state.current_profile, agent_names=agent_names
|
||||
)
|
||||
row_dict["model_info"] = (
|
||||
model_service.get_active_model_info(sess_profile)
|
||||
if sess_profile
|
||||
else default_model_info
|
||||
)
|
||||
if not sess_profile:
|
||||
unresolved_profiles.append(row_dict)
|
||||
result.append(row_dict)
|
||||
|
||||
# Most sessions carry a profile in device_info or an agent trace name.
|
||||
# Only inspect the much larger LLM payload for legacy rows that remain
|
||||
# ambiguous after those cheap checks.
|
||||
if unresolved_profiles:
|
||||
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
|
||||
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
|
||||
for row_dict in unresolved_profiles:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
llm_traces = llm_traces_by_session.get(s_id)
|
||||
if not llm_traces:
|
||||
continue
|
||||
sess_profile = model_service.resolve_session_profile(
|
||||
row_dict, llm_traces, state.current_profile
|
||||
)
|
||||
if sess_profile:
|
||||
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
|
||||
|
||||
if orphaned_ids:
|
||||
try:
|
||||
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
|
||||
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
for o_id in orphaned_ids:
|
||||
if trace_store.read_status(str(o_id)):
|
||||
trace_store.update_trace_status(
|
||||
str(o_id),
|
||||
"failed",
|
||||
error="Process terminated unexpectedly (auto-harvested).",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
|
||||
|
||||
return result
|
||||
return await asyncio.to_thread(_list_sessions_sync)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _list_sessions_sync():
|
||||
rows = session_repo.get_all_sessions()
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||
video_idx = media_service.build_video_index()
|
||||
default_model_info = model_service.get_active_model_info()
|
||||
|
||||
orphaned_ids = []
|
||||
unresolved_profiles = []
|
||||
result = []
|
||||
|
||||
try:
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
active_owner_sids = {
|
||||
str(owner.session_id)
|
||||
for owner in active_owners.values()
|
||||
if owner.session_id
|
||||
}
|
||||
except Exception:
|
||||
active_owner_sids = set()
|
||||
|
||||
for row_dict in rows:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
recording = latest_recordings.get(s_id)
|
||||
d_info_raw = row_dict.get("device_info")
|
||||
device_id = None
|
||||
if d_info_raw:
|
||||
try:
|
||||
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
|
||||
if isinstance(d_info, dict):
|
||||
device_id = d_info.get("device_id") or d_info.get("device_serial")
|
||||
except Exception:
|
||||
pass
|
||||
if not device_id and recording:
|
||||
device_id = recording.get("device_id")
|
||||
row_dict["device_id"] = device_id
|
||||
row_dict["device_serial"] = device_id
|
||||
|
||||
if row_dict.get("status") == "running":
|
||||
is_active = (
|
||||
s_id in active_owner_sids
|
||||
or s_id in state.active_connections
|
||||
or (state.is_running and s_id == str(state.active_session_id))
|
||||
)
|
||||
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
|
||||
if not is_active and not worker_is_alive:
|
||||
row_dict["status"] = "failed"
|
||||
row_dict["end_time"] = time.time()
|
||||
orphaned_ids.append(s_id)
|
||||
|
||||
recording_status = str((recording or {}).get("status") or "unavailable")
|
||||
resolved_v_url = (
|
||||
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
|
||||
if recording_status != "recording"
|
||||
else None
|
||||
)
|
||||
if resolved_v_url:
|
||||
recording_status = "ready"
|
||||
row_dict["video_url"] = resolved_v_url
|
||||
else:
|
||||
row_dict["video_url"] = None
|
||||
row_dict["recording_status"] = recording_status
|
||||
|
||||
agent_names = agent_names_by_session.get(s_id, [])
|
||||
sess_profile = model_service.resolve_session_profile(
|
||||
row_dict, None, state.current_profile, agent_names=agent_names
|
||||
)
|
||||
row_dict["model_info"] = (
|
||||
model_service.get_active_model_info(sess_profile)
|
||||
if sess_profile
|
||||
else default_model_info
|
||||
)
|
||||
if not sess_profile:
|
||||
unresolved_profiles.append(row_dict)
|
||||
result.append(row_dict)
|
||||
|
||||
# Most sessions carry a profile in device_info or an agent trace name.
|
||||
# Only inspect the much larger LLM payload for legacy rows that remain
|
||||
# ambiguous after those cheap checks.
|
||||
if unresolved_profiles:
|
||||
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
|
||||
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
|
||||
for row_dict in unresolved_profiles:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
llm_traces = llm_traces_by_session.get(s_id)
|
||||
if not llm_traces:
|
||||
continue
|
||||
sess_profile = model_service.resolve_session_profile(
|
||||
row_dict, llm_traces, state.current_profile
|
||||
)
|
||||
if sess_profile:
|
||||
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
|
||||
|
||||
if orphaned_ids:
|
||||
try:
|
||||
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
|
||||
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
for o_id in orphaned_ids:
|
||||
if trace_store.read_status(str(o_id)):
|
||||
trace_store.update_trace_status(
|
||||
str(o_id),
|
||||
"failed",
|
||||
error="Process terminated unexpectedly (auto-harvested).",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}")
|
||||
async def get_session_details(session_id: str):
|
||||
"""Retrieve details for a single automation session."""
|
||||
|
||||
@@ -14,15 +14,84 @@
|
||||
|
||||
"""System Readiness & Diagnostics Router for Artemis Admin Console."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from artemis.core.diagnostics.adb_server_connection import (
|
||||
InvalidAdbServerEndpoint,
|
||||
adb_server_connection,
|
||||
)
|
||||
from artemis.core.diagnostics.schema import SystemReadinessReport
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
|
||||
def _require_local_admin_request(request: Request) -> None:
|
||||
"""Keep endpoint probing and mutation on the local administration boundary."""
|
||||
client_host = request.client.host if request.client else None
|
||||
allow_remote = os.getenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
if client_host and not allow_remote:
|
||||
try:
|
||||
is_loopback = ipaddress.ip_address(client_host).is_loopback
|
||||
except ValueError:
|
||||
is_loopback = client_host.lower() == "localhost"
|
||||
if not is_loopback:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"ADB server settings are local-only. Set "
|
||||
"ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=true to manage them from another "
|
||||
"computer."
|
||||
),
|
||||
)
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
host = request.headers.get("host")
|
||||
if not origin or not host:
|
||||
return
|
||||
origin_host = urlsplit(origin).netloc.lower()
|
||||
if origin_host != host.lower():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="ADB server settings can only be changed from the Artemis console.",
|
||||
)
|
||||
|
||||
|
||||
def _require_loopback_request(request: Request, detail: str) -> None:
|
||||
"""Reject requests whose TCP peer is not the local machine."""
|
||||
client_host = request.client.host if request.client else None
|
||||
try:
|
||||
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
|
||||
except ValueError:
|
||||
is_loopback = bool(client_host and client_host.lower() == "localhost")
|
||||
if not is_loopback:
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
|
||||
|
||||
def _require_local_lifecycle_request(request: Request) -> None:
|
||||
"""Authorize a process-lifecycle request from the local CLI only."""
|
||||
_require_loopback_request(request, "Server lifecycle controls are local-only.")
|
||||
|
||||
expected = getattr(request.app.state, "lifecycle_token", None)
|
||||
supplied = request.headers.get("x-artemis-lifecycle-token")
|
||||
if not (
|
||||
isinstance(expected, str)
|
||||
and isinstance(supplied, str)
|
||||
and secrets.compare_digest(expected, supplied)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Invalid server lifecycle token.")
|
||||
|
||||
|
||||
class SelectDeviceRequest(BaseModel):
|
||||
"""Payload to select an active target Android device."""
|
||||
|
||||
@@ -94,6 +163,66 @@ async def connect_wireless_adb(request: ConnectAdbRequest):
|
||||
}
|
||||
|
||||
|
||||
class ConnectAdbServerRequest(BaseModel):
|
||||
"""Payload to select an ADB server endpoint accessible from this computer."""
|
||||
|
||||
host: str = Field(description="Host name or IP address of the ADB server")
|
||||
port: int = Field(default=5037, ge=1, le=65535, description="ADB server port")
|
||||
persist: bool = Field(default=True, description="Persist the endpoint for future launches")
|
||||
|
||||
|
||||
@router.get("/adb/server")
|
||||
async def get_adb_server_status():
|
||||
"""Return the process-wide ADB server endpoint currently used by Artemis."""
|
||||
return adb_server_connection.status()
|
||||
|
||||
|
||||
@router.post("/adb/server/connect")
|
||||
async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request):
|
||||
"""Validate and activate an ADB server endpoint."""
|
||||
_require_local_admin_request(request)
|
||||
try:
|
||||
connection_result = await adb_server_connection.connect(
|
||||
payload.host,
|
||||
payload.port,
|
||||
persist=payload.persist,
|
||||
)
|
||||
except InvalidAdbServerEndpoint as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
response: dict[str, object] = {"connection_result": connection_result}
|
||||
if connection_result["success"]:
|
||||
readiness_engine.set_active_device_serial(None)
|
||||
readiness_engine.invalidate_cache()
|
||||
response["report"] = await readiness_engine.run_all(force_refresh=True)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/adb/server/probe")
|
||||
async def probe_adb_server(payload: ConnectAdbServerRequest, request: Request):
|
||||
"""Test an ADB server endpoint without changing the active endpoint."""
|
||||
_require_local_admin_request(request)
|
||||
try:
|
||||
connection_result = await adb_server_connection.probe(payload.host, payload.port)
|
||||
except InvalidAdbServerEndpoint as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"connection_result": connection_result}
|
||||
|
||||
|
||||
@router.post("/adb/server/local")
|
||||
async def use_local_adb_server(request: Request, persist: bool = True):
|
||||
"""Restore the standard local ADB server without touching a remote daemon."""
|
||||
_require_local_admin_request(request)
|
||||
connection_result = await adb_server_connection.use_local_server(persist=persist)
|
||||
readiness_engine.set_active_device_serial(None)
|
||||
readiness_engine.invalidate_cache()
|
||||
updated_report = await readiness_engine.run_all(force_refresh=True)
|
||||
return {
|
||||
"connection_result": connection_result,
|
||||
"report": updated_report,
|
||||
}
|
||||
|
||||
|
||||
class LaunchEmulatorRequest(BaseModel):
|
||||
"""Payload to launch a local Android Virtual Device (AVD)."""
|
||||
|
||||
@@ -157,22 +286,18 @@ class ValidateCredentialsRequest(BaseModel):
|
||||
|
||||
@router.get("/credentials")
|
||||
async def get_credentials():
|
||||
"""Retrieve current configured API keys for management."""
|
||||
"""Report which providers have an API key configured.
|
||||
|
||||
Secret values never leave the process: this endpoint intentionally returns
|
||||
presence booleans only. Keys are written via POST /credentials and used
|
||||
server-side.
|
||||
"""
|
||||
from artemis.config import settings
|
||||
|
||||
gemini_k = settings.get_api_key("google")
|
||||
openai_k = settings.get_api_key("openai")
|
||||
anthropic_k = settings.get_api_key("anthropic")
|
||||
openrouter_k = settings.get_api_key("openrouter")
|
||||
ocr_k = settings.get_api_key("ocr")
|
||||
return {
|
||||
"google": gemini_k.get_secret_value() if gemini_k else None,
|
||||
"gemini": gemini_k.get_secret_value() if gemini_k else None,
|
||||
"openai": openai_k.get_secret_value() if openai_k else None,
|
||||
"anthropic": anthropic_k.get_secret_value() if anthropic_k else None,
|
||||
"openrouter": openrouter_k.get_secret_value() if openrouter_k else None,
|
||||
"ocr": ocr_k.get_secret_value() if ocr_k else None,
|
||||
}
|
||||
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
|
||||
status = {name: bool(settings.get_api_key(name)) for name in providers}
|
||||
status["gemini"] = status["google"]
|
||||
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
|
||||
|
||||
|
||||
@router.post("/credentials/test")
|
||||
@@ -278,11 +403,13 @@ async def get_model_config_and_env():
|
||||
return None
|
||||
|
||||
def mask_key(k: str | None) -> str | None:
|
||||
# Expose only enough to recognize which key is active, never a usable
|
||||
# fragment of the secret itself.
|
||||
if not k:
|
||||
return None
|
||||
if len(k) <= 8:
|
||||
return "****"
|
||||
return f"{k[:4]}...{k[-4:]}"
|
||||
return f"****{k[-4:]}"
|
||||
|
||||
gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value(
|
||||
"GOOGLE_API_KEY", "google"
|
||||
@@ -375,18 +502,30 @@ async def get_server_runtime_status():
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
status = get_server_status(port=port)
|
||||
status["current_pid"] = os.getpid()
|
||||
return status
|
||||
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||
return {
|
||||
"running": status["running"],
|
||||
"port": status["port"],
|
||||
"pids": status["pids"],
|
||||
"active_pid": status["active_pid"],
|
||||
"uptime_seconds": status["uptime_seconds"],
|
||||
"url": status["url"],
|
||||
"admin_url": status["admin_url"],
|
||||
"current_pid": os.getpid(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_server_endpoint():
|
||||
async def restart_server_endpoint(request: Request):
|
||||
"""Request a graceful restart of the Artemis server from thin clients/UI."""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
_require_loopback_request(request, "Server lifecycle controls are local-only.")
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
@@ -423,33 +562,33 @@ async def restart_server_endpoint():
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shutdown")
|
||||
async def shutdown_server_endpoint():
|
||||
@router.post("/shutdown", status_code=202)
|
||||
async def shutdown_server_endpoint(request: Request):
|
||||
"""Request a graceful shutdown of the Artemis server."""
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
|
||||
current_pid = os.getpid()
|
||||
_require_local_lifecycle_request(request)
|
||||
server = getattr(request.app.state, "uvicorn_server", None)
|
||||
if server is None:
|
||||
raise HTTPException(status_code=503, detail="Server lifecycle controller is unavailable.")
|
||||
|
||||
def _shutdown_worker():
|
||||
import time
|
||||
async def _shutdown_after_response() -> None:
|
||||
# Let Starlette flush the accepted response before Uvicorn leaves its
|
||||
# main loop and invokes the FastAPI shutdown lifecycle.
|
||||
await asyncio.sleep(0.05)
|
||||
state.is_shutting_down = True
|
||||
state.shutdown_event.set()
|
||||
server.should_exit = True
|
||||
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(current_pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_shutdown_worker, daemon=True).start()
|
||||
asyncio.create_task(_shutdown_after_response())
|
||||
|
||||
return {
|
||||
"status": "shutting_down",
|
||||
"message": "Artemis server is shutting down.",
|
||||
"pid": current_pid,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
@@ -80,6 +80,62 @@ async def run_task(request: RunRequest):
|
||||
detail="Either 'goal' or 'goals' list must be provided.",
|
||||
)
|
||||
|
||||
# Idempotent SDK retries must never re-run device readiness checks. A task
|
||||
# can hold the device while its admission response is lost in transit; in
|
||||
# that state, probing the same device again may fail or block even though
|
||||
# the original task was accepted successfully.
|
||||
if request.session_id and len(incoming_goals) == 1:
|
||||
requested_sid = str(request.session_id)
|
||||
existing_item = next(
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("session_id")) == requested_sid
|
||||
),
|
||||
None,
|
||||
)
|
||||
persisted_session = session_repo.get_session_by_id(requested_sid)
|
||||
is_active = (
|
||||
str(state.active_session_id) == requested_sid
|
||||
or requested_sid in state.active_connections
|
||||
)
|
||||
if existing_item or persisted_session or is_active:
|
||||
task_payload = dict(existing_item or persisted_session or {})
|
||||
task_payload.setdefault("session_id", requested_sid)
|
||||
task_payload.setdefault("goal", incoming_goals[0])
|
||||
task_payload.setdefault("profile", request.profile or "flash")
|
||||
task_payload.setdefault("device_serial", request.device_serial)
|
||||
task_payload.setdefault("status", "running" if is_active else "queued")
|
||||
return {
|
||||
"status": task_payload["status"],
|
||||
"tasks": [task_payload],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
|
||||
# Reject an explicit unknown/offline target before running the more
|
||||
# expensive readiness probe. Besides producing a stable SDK response,
|
||||
# this avoids probing the currently active device for a serial that can
|
||||
# never be selected. Only a successful, non-empty enumeration may reject:
|
||||
# an indeterminate one (adb blip, startup) lets the submission queue and
|
||||
# fail downstream with a clear error instead.
|
||||
if request.device_serial:
|
||||
try:
|
||||
rejection = await device_pool.validate_explicit_serial_async(
|
||||
request.device_serial
|
||||
)
|
||||
except Exception:
|
||||
rejection = None
|
||||
if rejection:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"error": rejection,
|
||||
"tasks": [],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
|
||||
# Re-check immediately before enqueueing so a device locked between UI
|
||||
# polling intervals cannot start through a stale Ready state. Use the
|
||||
# bounded submission probe: the full diagnostics path also scans packages,
|
||||
|
||||
Reference in new issue
Block a user