Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
587 lines
21 KiB
Python
587 lines
21 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# 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.
|
|
|
|
"""Artemis Admin & Trace Console Server
|
|
|
|
Modular entrypoint for full trace inspection, step replay, and task execution management.
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import signal
|
|
import socket
|
|
import sys
|
|
from types import FrameType
|
|
|
|
# Bootstrap sys.path to allow running from any CWD
|
|
_current_p = Path(__file__).resolve().parent
|
|
while _current_p != _current_p.parent:
|
|
if (_current_p / "pyproject.toml").exists() or (_current_p / "artemis").is_dir():
|
|
_workspace_root = _current_p
|
|
break
|
|
_current_p = _current_p.parent
|
|
else:
|
|
_workspace_root = Path(__file__).resolve().parent.parent.parent
|
|
|
|
_apps_dir = _workspace_root / "apps"
|
|
_admin_console_dir = _apps_dir / "admin_console"
|
|
_cloud_service_dir = _apps_dir / "cloud_service"
|
|
|
|
for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_cloud_service_dir)):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
import uvicorn
|
|
|
|
from artemis.runtime import (
|
|
DeviceExecutionLock,
|
|
clear_server_info,
|
|
device_pool,
|
|
shutdown_awake_service,
|
|
start_awake_service,
|
|
write_server_info,
|
|
)
|
|
|
|
from artemis.config import (
|
|
DB_PATH,
|
|
IMAGES_DIR,
|
|
REPLAY_BASE_DIR,
|
|
TEST_DATA_DIR,
|
|
TEST_OUTPUTS_DIR,
|
|
TRACES_PATH,
|
|
WORKSPACE_ROOT,
|
|
init_ls_address,
|
|
)
|
|
from artemis.resources import get_bundled_showcase_dist
|
|
|
|
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
|
|
from apps.admin_console.core.state import ServerState, state
|
|
from apps.admin_console.database.connection import db_session, get_db
|
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
|
from apps.admin_console.database.repositories.step_repository import step_repo
|
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
|
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
|
from apps.admin_console.routers.replay import replay_manager
|
|
from apps.admin_console.services.ipc_service import ipc_service
|
|
from apps.admin_console.services.media_service import media_service
|
|
from apps.admin_console.services.model_service import model_service
|
|
from apps.admin_console.services.task_queue_service import task_queue_service
|
|
|
|
# Initialize language server synchronization address
|
|
init_ls_address()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _lifespan(_app: "FastAPI"):
|
|
"""Start server resources and release them on shutdown."""
|
|
from artemis.config.paths import get_app_dir, get_traces_dir
|
|
from artemis.runtime.daemon_client import configure_local_daemon
|
|
from artemis.service_registry import ServiceRegistration
|
|
|
|
registration = ServiceRegistration(
|
|
port=state.port,
|
|
token=LIFECYCLE_TOKEN,
|
|
app_dir=get_app_dir(),
|
|
traces_dir=get_traces_dir(),
|
|
)
|
|
registration.acquire()
|
|
app.state.service_registration = registration
|
|
configure_local_daemon(state.port)
|
|
try:
|
|
from artemis.integrations.codex_skill import sync_on_startup
|
|
from artemis.config.paths import ROOT_DIR
|
|
|
|
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
|
|
shared_tool_executor.start()
|
|
async with shared_mcp.session_manager.run():
|
|
await on_startup()
|
|
try:
|
|
yield
|
|
finally:
|
|
await on_shutdown()
|
|
finally:
|
|
try:
|
|
await shared_tool_executor.stop()
|
|
finally:
|
|
configure_local_daemon(None)
|
|
registration.close()
|
|
|
|
|
|
# Initialize FastAPI application
|
|
app = FastAPI(title="Artemis Admin & Trace Console", lifespan=_lifespan)
|
|
LIFECYCLE_TOKEN = os.environ.get("ARTEMIS_LIFECYCLE_TOKEN") or secrets.token_urlsafe(32)
|
|
app.state.lifecycle_token = LIFECYCLE_TOKEN
|
|
|
|
# The console UI is served same-origin from this process, so no CORS grants
|
|
# exist at all; the boundary middleware rejects cross-origin browser traffic
|
|
# and unrecognized Host headers (DNS rebinding) instead.
|
|
app.add_middleware(SameOriginBoundaryMiddleware)
|
|
|
|
from mcp_server.shared import LocalMcpBoundary, create_shared_mcp
|
|
|
|
shared_mcp, shared_tool_executor = create_shared_mcp()
|
|
app.mount("/api/mcp", LocalMcpBoundary(shared_mcp.streamable_http_app(), LIFECYCLE_TOKEN))
|
|
|
|
|
|
@app.get("/api/system/shared-service")
|
|
async def shared_service_identity(request: Request):
|
|
"""Authenticated identity probe for local connection-only MCP bridges."""
|
|
import hmac
|
|
|
|
from artemis.service_registry import TOKEN_HEADER
|
|
|
|
if not hmac.compare_digest(request.headers.get(TOKEN_HEADER, ""), LIFECYCLE_TOKEN):
|
|
raise HTTPException(status_code=403, detail="Invalid service token")
|
|
registration = getattr(app.state, "service_registration", None)
|
|
if registration is None:
|
|
raise HTTPException(status_code=503, detail="Service is starting")
|
|
return {key: registration.info[key] for key in ("protocol", "installation", "instance", "pid")}
|
|
|
|
|
|
async def on_startup():
|
|
"""Startup lifecycle hooks."""
|
|
state.is_shutting_down = False
|
|
state.shutdown_event.clear()
|
|
write_server_info(
|
|
port=getattr(state, "port", 8000),
|
|
host=getattr(state, "host", "127.0.0.1"),
|
|
lifecycle_token=LIFECYCLE_TOKEN,
|
|
)
|
|
await asyncio.to_thread(start_awake_service)
|
|
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
|
|
if cleaned_device_locks:
|
|
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
|
|
|
|
# Warm the adb server and complete one enumeration before requests are
|
|
# served, so submissions in the first seconds never race an adb cold start
|
|
# and get a false "no devices attached" rejection.
|
|
try:
|
|
adb_warmed = await device_pool.warm_up_async()
|
|
except Exception as exc:
|
|
adb_warmed = False
|
|
print(f"[ServerStartup] ADB warm-up failed: {exc}")
|
|
if not adb_warmed:
|
|
print(
|
|
"[ServerStartup] ADB warm-up did not confirm a responding adb server; "
|
|
"device enumeration will retry on demand."
|
|
)
|
|
asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch))
|
|
asyncio.create_task(
|
|
asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager)
|
|
)
|
|
|
|
cleaned = session_repo.cleanup_orphans_on_startup()
|
|
if cleaned > 0:
|
|
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as interrupted.")
|
|
# Workers killed together with a previous daemon never remuxed their
|
|
# recordings; publish whatever raw files they left behind.
|
|
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
|
|
|
await ipc_service.start_server()
|
|
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
|
|
from apps.admin_console.services.automation_service import batch_loop
|
|
|
|
app.state.batch_worker = asyncio.create_task(batch_loop())
|
|
|
|
|
|
async def on_shutdown():
|
|
"""Stop task and IPC children before the UI server exits."""
|
|
state.is_shutting_down = True
|
|
state.shutdown_event.set()
|
|
batch_worker = getattr(app.state, "batch_worker", None)
|
|
if batch_worker:
|
|
batch_worker.cancel()
|
|
await asyncio.gather(batch_worker, return_exceptions=True)
|
|
task_queue_service._broadcast_event("server_shutdown", {"status": "stopping"})
|
|
owned_session_ids = {
|
|
str(item["session_id"])
|
|
for item in state.queue_items
|
|
if isinstance(item, dict)
|
|
and item.get("status") in {"running", "task_paused", "resuming"}
|
|
and item.get("session_id")
|
|
}
|
|
|
|
worker = state.worker_task
|
|
if worker is not None and not worker.done():
|
|
worker.cancel()
|
|
try:
|
|
await asyncio.wait_for(worker, timeout=5.0)
|
|
except (asyncio.CancelledError, TimeoutError):
|
|
pass
|
|
state.worker_task = None
|
|
|
|
# Cancel in-flight run coroutines and wait for their finalizers (DB status,
|
|
# session_ended broadcast, trace sync, recording recovery) to run.
|
|
run_tasks = [t for t in list(task_queue_service._run_tasks) if not t.done()]
|
|
for run_task in run_tasks:
|
|
run_task.cancel()
|
|
if run_tasks:
|
|
try:
|
|
await asyncio.wait_for(asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0)
|
|
except (TimeoutError, asyncio.CancelledError):
|
|
pass
|
|
for run in list(state.active_runs.values()):
|
|
run_proc = run.get("process")
|
|
if run_proc is not None and run_proc.returncode is None:
|
|
await task_queue_service._terminate_worker_process(run_proc)
|
|
state.active_runs.clear()
|
|
if state.current_process is not None and state.current_process.returncode is None:
|
|
await task_queue_service._terminate_worker_process(state.current_process)
|
|
for item in state.queue_items:
|
|
if isinstance(item, dict):
|
|
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
|
DeviceExecutionLock.cleanup_stale_locks()
|
|
state.current_process = None
|
|
state.queue_items.clear()
|
|
for session_id in owned_session_ids:
|
|
session_repo.update_session_status(session_id, "interrupted")
|
|
|
|
from apps.admin_console.services.automation_service import background_tasks
|
|
|
|
if background_tasks:
|
|
await asyncio.gather(*list(background_tasks), return_exceptions=True)
|
|
await ipc_service.stop_server()
|
|
state.ipc_subscribers.clear()
|
|
await asyncio.to_thread(shutdown_awake_service)
|
|
clear_server_info(
|
|
port=getattr(state, "port", 8000),
|
|
lifecycle_token=LIFECYCLE_TOKEN,
|
|
)
|
|
|
|
|
|
# Mount modular routers
|
|
app.include_router(stream.router)
|
|
app.include_router(media.router)
|
|
app.include_router(sessions.router)
|
|
app.include_router(steps.router)
|
|
app.include_router(tasks.router)
|
|
app.include_router(replay.router)
|
|
app.include_router(system.router)
|
|
from apps.admin_console.routers.automation import router as automation_router
|
|
|
|
app.include_router(automation_router)
|
|
|
|
from apps.admin_console.routers.plugins import router as plugins_router
|
|
|
|
app.include_router(plugins_router)
|
|
|
|
from apps.admin_console.routers.exploration import router as exploration_router
|
|
|
|
app.include_router(exploration_router)
|
|
|
|
# Mount cloud gateway router for Frappe / Cloud integration if present
|
|
try:
|
|
from cloud_service.gateway import cloud_router
|
|
|
|
app.include_router(cloud_router)
|
|
except ImportError:
|
|
try:
|
|
from apps.cloud_service.gateway import cloud_router
|
|
|
|
app.include_router(cloud_router)
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Showcase UI (Angular 19) Unified Single-Port SPA Static Hosting
|
|
# ------------------------------------------------------------------------------
|
|
def _get_showcase_dist() -> Path:
|
|
"""Return the source build, or the immutable build bundled in a wheel."""
|
|
base_dist = _workspace_root / "apps" / "showcase_ui" / "dist"
|
|
candidates = [
|
|
base_dist / "frontend" / "browser",
|
|
base_dist / "browser",
|
|
base_dist / "frontend",
|
|
base_dist,
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.is_dir() and (candidate / "index.html").exists():
|
|
return candidate
|
|
bundled_dist = get_bundled_showcase_dist()
|
|
if bundled_dist is not None:
|
|
return bundled_dist
|
|
return base_dist / "frontend" / "browser"
|
|
|
|
|
|
def _resolve_static_file(root: Path, relative_path: str) -> Path | None:
|
|
"""Return the requested file only when it truly lives under ``root``."""
|
|
try:
|
|
root = root.resolve()
|
|
target = (root / relative_path).resolve()
|
|
except (OSError, ValueError):
|
|
return None
|
|
if target.is_file() and target.is_relative_to(root):
|
|
return target
|
|
return None
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def serve_showcase_root():
|
|
"""Explicitly serve the Showcase UI at the root path by default."""
|
|
return await serve_showcase_spa("")
|
|
|
|
|
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
async def serve_showcase_spa(full_path: str):
|
|
# Do not intercept API, media, or replay paths
|
|
if (
|
|
full_path.startswith("api/")
|
|
or full_path.startswith("images/")
|
|
or full_path.startswith("videos/")
|
|
or full_path.startswith("local_file")
|
|
or full_path == "docs"
|
|
or full_path == "openapi.json"
|
|
or full_path.startswith("redoc")
|
|
):
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status_code=404, detail="Endpoint not found")
|
|
|
|
clean_path = full_path.strip("/")
|
|
|
|
# Admin / Debug Console routes
|
|
if (
|
|
clean_path in ("admin", "debug")
|
|
or clean_path.startswith("admin/")
|
|
or clean_path.startswith("debug/")
|
|
):
|
|
admin_index = _admin_console_dir / "index.html"
|
|
if admin_index.exists():
|
|
return HTMLResponse(admin_index.read_text(encoding="utf-8"))
|
|
|
|
showcase_dist = _get_showcase_dist()
|
|
|
|
# Exact static file match (js, css, images, fonts, favicon, logo).
|
|
# Resolve and re-anchor under the static root so encoded traversal
|
|
# sequences cannot address files outside the build directories.
|
|
if clean_path:
|
|
target_file = _resolve_static_file(showcase_dist, clean_path)
|
|
if target_file is not None:
|
|
return FileResponse(target_file)
|
|
|
|
# Fallback for icons/logos if showcase UI hasn't been built yet
|
|
public_root = _workspace_root / "apps" / "showcase_ui" / "public"
|
|
public_file = _resolve_static_file(public_root, clean_path)
|
|
if public_file is not None:
|
|
return FileResponse(public_file)
|
|
|
|
# Serve Showcase UI Angular SPA index.html
|
|
index_file = showcase_dist / "index.html"
|
|
if index_file.exists():
|
|
return HTMLResponse(index_file.read_text(encoding="utf-8"))
|
|
|
|
# Fallback message if Showcase UI is not built
|
|
fallback_html = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Artemis Showcase UI - Not Built</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #0f172a;
|
|
color: #f8fafc;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
margin: 0;
|
|
}
|
|
.card {
|
|
background: #1e293b;
|
|
padding: 2.5rem;
|
|
border-radius: 1rem;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
|
max-width: 500px;
|
|
text-align: center;
|
|
border: 1px solid #334155;
|
|
}
|
|
h1 { color: #38bdf8; margin-top: 0; }
|
|
code {
|
|
background: #0f172a;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.375rem;
|
|
color: #e2e8f0;
|
|
font-size: 0.9em;
|
|
}
|
|
.btn {
|
|
display: inline-block;
|
|
margin-top: 1.5rem;
|
|
padding: 0.75rem 1.5rem;
|
|
background: #38bdf8;
|
|
color: #0f172a;
|
|
font-weight: bold;
|
|
text-decoration: none;
|
|
border-radius: 0.5rem;
|
|
transition: background 0.2s;
|
|
}
|
|
.btn:hover { background: #0284c7; color: #fff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>✨ Artemis Showcase UI</h1>
|
|
<p>The Showcase UI (Angular frontend) has not been built yet.</p>
|
|
<p>To compile the Showcase UI, run:</p>
|
|
<p><code>cd apps/showcase_ui && npm install && npm run build</code></p>
|
|
<p>Or launch using <code>./start.sh</code> or <code>artemis ui</code> to build automatically.</p>
|
|
<a class="btn" href="/admin">Go to Admin Debug Console →</a>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
return HTMLResponse(fallback_html)
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Backward compatibility exports
|
|
# ------------------------------------------------------------------------------
|
|
get_active_model_info = model_service.get_active_model_info
|
|
start_ipc_server = ipc_service.start_server
|
|
queue_worker = task_queue_service.queue_worker
|
|
active_connections = state.active_connections
|
|
ipc_subscribers = state.ipc_subscribers
|
|
task_queue = state.task_queue
|
|
queue_goals = state.queue_goals
|
|
|
|
|
|
class ArtemisUvicornServer(uvicorn.Server):
|
|
"""Notify application streams before Uvicorn waits for them to close."""
|
|
|
|
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
|
|
# Reload workers start in a fresh interpreter; restore the actual bind
|
|
# address there before lifespan publishes runtime metadata.
|
|
state.host = self.config.host
|
|
state.port = self.config.port
|
|
app.state.uvicorn_server = self
|
|
await super().startup(sockets=sockets)
|
|
|
|
@staticmethod
|
|
def _task_is_active() -> bool:
|
|
"""Check task state without doing process I/O from a signal handler."""
|
|
proc = state.current_process
|
|
if proc is not None and proc.returncode is None:
|
|
return True
|
|
return any(
|
|
isinstance(item, dict) and item.get("status") == "running" for item in state.queue_items
|
|
)
|
|
|
|
@staticmethod
|
|
def _schedule_signal_report(message: str) -> None:
|
|
"""Defer console output so a signal cannot re-enter Colorama writes."""
|
|
try:
|
|
asyncio.get_running_loop().call_soon(print, message)
|
|
except RuntimeError:
|
|
try:
|
|
os.write(2, f"{message}\n".encode(errors="replace"))
|
|
except OSError:
|
|
pass
|
|
|
|
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
|
try:
|
|
signal_name = signal.Signals(sig).name
|
|
except ValueError:
|
|
signal_name = "UNKNOWN"
|
|
frame_location = "unknown"
|
|
if frame is not None:
|
|
frame_location = f"{frame.f_code.co_filename}:{frame.f_lineno}"
|
|
signal_report = (
|
|
f"[ServerSignal] Received {signal_name} ({sig}); "
|
|
f"server PID={os.getpid()}, parent PID={os.getppid()}, frame={frame_location}"
|
|
)
|
|
self._schedule_signal_report(signal_report)
|
|
|
|
if sys.platform == "win32" and sig == signal.SIGINT and self._task_is_active():
|
|
self._schedule_signal_report(
|
|
"[ServerSignal] Ignored Windows SIGINT while a mobile task is active. "
|
|
"Stop the task first, or press Ctrl+Break to force server shutdown."
|
|
)
|
|
return
|
|
|
|
state.is_shutting_down = True
|
|
state.shutdown_event.set()
|
|
super().handle_exit(sig, frame)
|
|
|
|
|
|
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
|
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
|
# Reserve a listening socket before publishing metadata or running lifespan.
|
|
# Uvicorn normally runs application startup *before* binding its port; a
|
|
# duplicate daemon would otherwise recover jobs owned by the real server.
|
|
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
try:
|
|
listener = socket.create_server((host, port), family=family)
|
|
except OSError as exc:
|
|
print(f"无法启动服务:{host}:{port} 端口不可用({exc})。现有服务和任务保持不变。")
|
|
raise SystemExit(1) from exc
|
|
listener.set_inheritable(True)
|
|
port = listener.getsockname()[1]
|
|
state.host = host
|
|
state.port = port
|
|
|
|
try:
|
|
config = uvicorn.Config(
|
|
"apps.admin_console.server:app" if reload else app,
|
|
host=host,
|
|
port=port,
|
|
reload=reload,
|
|
timeout_graceful_shutdown=5,
|
|
)
|
|
if reload:
|
|
from uvicorn.supervisors import ChangeReload
|
|
|
|
server = ArtemisUvicornServer(config)
|
|
ChangeReload(config, target=server.run, sockets=[listener]).run()
|
|
return
|
|
|
|
server = ArtemisUvicornServer(config)
|
|
app.state.uvicorn_server = server
|
|
server.run(sockets=[listener])
|
|
finally:
|
|
listener.close()
|
|
app.state.uvicorn_server = None
|
|
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
"""Parse the daemon/standalone server bind address and run Uvicorn."""
|
|
parser = argparse.ArgumentParser(description="Run the Artemis Admin Console server")
|
|
parser.add_argument(
|
|
"--host",
|
|
default=os.environ.get("ARTEMIS_SERVER_HOST", "127.0.0.1"),
|
|
help=(
|
|
"Address to bind the HTTP server to. Defaults to loopback; expose "
|
|
"remotely via a Tailscale/SSH tunnel rather than a wide bind."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", "8000")),
|
|
help="TCP port for the HTTP server.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
run_ui_server(host=args.host, port=args.port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|