feat(core): consolidate config hub, unify daemon scheduling across all entrypoints, and fix step replay
- Unified configuration and paths under artemis.config as single source of truth - Consolidated execution across CLI, SDK, MCP, and Admin Console to default to Artemis Daemon - Fixed frontend step replay to preserve failure steps with proper sequential numbering - Implemented task deduplication, debouncing, and multi-device lock isolation - Added comprehensive end-to-end multi-port integration tests for live phone and emulator
This commit is contained in:
1 parent
e984d17635
commit
5f1ee18bc1
38 files changed
+1426
-203
No files matched your search
@@ -12,63 +12,32 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
"""Admin Console configuration facade.
|
||||
|
||||
All configuration and path management is centralized in `artemis.config`.
|
||||
This module re-exports common constants for full backward compatibility.
|
||||
"""
|
||||
|
||||
# Automatically write ANTIGRAVITY_LS_ADDRESS to shared synchronization file on startup
|
||||
# to allow decoupled background processes to communicate with Jetski
|
||||
def init_ls_address():
|
||||
ls_addr = os.environ.get("ANTIGRAVITY_LS_ADDRESS")
|
||||
if ls_addr:
|
||||
try:
|
||||
from artemis.config import write_ls_address
|
||||
from artemis.config import (
|
||||
DB_PATH,
|
||||
IMAGES_DIR,
|
||||
PAUSE_FILE,
|
||||
REPLAY_BASE_DIR,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
TRACES_PATH,
|
||||
WORKSPACE_ROOT,
|
||||
init_ls_address,
|
||||
)
|
||||
|
||||
write_ls_address(ls_addr)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to write .jetski_ls_address on startup: {e}\n")
|
||||
|
||||
|
||||
# Compute real WORKSPACE_ROOT (find repo root containing pyproject.toml or artemis)
|
||||
_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.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)
|
||||
|
||||
# Try to import settings from artemis
|
||||
try:
|
||||
from artemis.config import settings
|
||||
|
||||
TRACES_PATH = Path(settings.TRACES_PATH)
|
||||
DB_PATH = Path(settings.DATA_ENGINE_DB_PATH)
|
||||
except ImportError:
|
||||
# Fallback to default path if run outside project environment or imports fail
|
||||
TRACES_PATH = WORKSPACE_ROOT / "traces"
|
||||
DB_PATH = TRACES_PATH / "data_engine.db"
|
||||
|
||||
# Step Replay Directories (Stored within traces/replay to avoid workspace clutter)
|
||||
REPLAY_BASE_DIR = TRACES_PATH / "replay"
|
||||
TEST_DATA_DIR = REPLAY_BASE_DIR / "data"
|
||||
TEST_OUTPUTS_DIR = REPLAY_BASE_DIR / "outputs"
|
||||
IMAGES_DIR = TRACES_PATH / "images"
|
||||
PAUSE_FILE = WORKSPACE_ROOT / ".artemis_paused"
|
||||
|
||||
# Ensure all essential runtime directories exist automatically
|
||||
for _d in (TRACES_PATH, REPLAY_BASE_DIR, TEST_DATA_DIR, TEST_OUTPUTS_DIR, IMAGES_DIR):
|
||||
try:
|
||||
_d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
__all__ = [
|
||||
"DB_PATH",
|
||||
"IMAGES_DIR",
|
||||
"PAUSE_FILE",
|
||||
"REPLAY_BASE_DIR",
|
||||
"TEST_DATA_DIR",
|
||||
"TEST_OUTPUTS_DIR",
|
||||
"TRACES_PATH",
|
||||
"WORKSPACE_ROOT",
|
||||
"init_ls_address",
|
||||
]
|
||||
@@ -16,10 +16,7 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from admin_console.core.config import PAUSE_FILE
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import PAUSE_FILE
|
||||
from artemis.config import PAUSE_FILE
|
||||
|
||||
|
||||
class ServerState:
|
||||
|
||||
@@ -16,10 +16,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from admin_console.core.config import DB_PATH
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import DB_PATH
|
||||
from artemis.config import DB_PATH
|
||||
|
||||
|
||||
_initialized_dbs = set()
|
||||
|
||||
@@ -285,6 +285,17 @@ class SessionRepository:
|
||||
("failed", now, row["session_id"], "running"),
|
||||
)
|
||||
count += cursor.rowcount
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
if trace_store.read_status(str(row["session_id"])):
|
||||
trace_store.update_trace_status(
|
||||
str(row["session_id"]),
|
||||
"failed",
|
||||
error="Process terminated prior to server startup.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
return count
|
||||
except Exception:
|
||||
|
||||
@@ -16,12 +16,12 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
|
||||
|
||||
try:
|
||||
from admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
import traceback
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from artemis.config import WORKSPACE_ROOT
|
||||
|
||||
try:
|
||||
from admin_console.core.config import WORKSPACE_ROOT
|
||||
from admin_console.schemas.task_schema import ReplayRequest
|
||||
from admin_console.replay_manager import ReplayManager
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import WORKSPACE_ROOT
|
||||
from apps.admin_console.schemas.task_schema import ReplayRequest
|
||||
from apps.admin_console.replay_manager import ReplayManager
|
||||
|
||||
|
||||
@@ -17,15 +17,15 @@ import time
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from artemis.config import DB_PATH, TRACES_PATH
|
||||
|
||||
try:
|
||||
from admin_console.core.config import DB_PATH, TRACES_PATH
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
from admin_console.services.model_service import model_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import DB_PATH, TRACES_PATH
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
@@ -137,6 +137,18 @@ async def list_sessions():
|
||||
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}")
|
||||
|
||||
@@ -145,6 +157,15 @@ async def list_sessions():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}")
|
||||
async def get_session_details(session_id: str):
|
||||
"""Retrieve details for a single automation session."""
|
||||
row = session_repo.get_session_by_id(session_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/tree")
|
||||
async def get_tree(session_id: str):
|
||||
try:
|
||||
|
||||
@@ -17,13 +17,13 @@ import traceback
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from artemis.config import TEST_OUTPUTS_DIR
|
||||
|
||||
try:
|
||||
from admin_console.core.config import TEST_OUTPUTS_DIR
|
||||
from admin_console.database.repositories.step_repository import step_repo
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import TEST_OUTPUTS_DIR
|
||||
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.services.media_service import media_service
|
||||
|
||||
@@ -298,6 +298,7 @@ async def get_status():
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"active_tasks": active_tasks,
|
||||
"model_info": model_info,
|
||||
"ipc_port": state.ipc_port,
|
||||
}
|
||||
|
||||
|
||||
@@ -55,17 +55,18 @@ from artemis.runtime import (
|
||||
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,
|
||||
)
|
||||
|
||||
try:
|
||||
from admin_console.core.config import (
|
||||
DB_PATH,
|
||||
IMAGES_DIR,
|
||||
REPLAY_BASE_DIR,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
TRACES_PATH,
|
||||
WORKSPACE_ROOT,
|
||||
init_ls_address,
|
||||
)
|
||||
from admin_console.core.state import ServerState, state
|
||||
from admin_console.database.connection import db_session, get_db
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
@@ -78,9 +79,6 @@ try:
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.task_queue_service import task_queue_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import (
|
||||
init_ls_address,
|
||||
)
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
||||
|
||||
@@ -22,10 +22,7 @@ from typing import Any
|
||||
import urllib.parse
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
from admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
|
||||
|
||||
class MediaService:
|
||||
|
||||
@@ -24,27 +24,21 @@ from typing import Any
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from admin_console.core.config import (
|
||||
PAUSE_FILE,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import (
|
||||
PAUSE_FILE,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
|
||||
from artemis.config import (
|
||||
PAUSE_FILE,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
WORKSPACE_ROOT,
|
||||
)
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
|
||||
|
||||
class TaskQueueService:
|
||||
@@ -314,6 +308,7 @@ class TaskQueueService:
|
||||
if sess_id:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
|
||||
@@ -354,6 +349,16 @@ class TaskQueueService:
|
||||
)
|
||||
state.current_process = proc
|
||||
task_item["pid"] = proc.pid
|
||||
if sess_id:
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
st = trace_store.read_status(str(sess_id))
|
||||
if st:
|
||||
st["pid"] = proc.pid
|
||||
trace_store.write_status(str(sess_id), st)
|
||||
except Exception:
|
||||
pass
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "process_ready", "Execution process started"
|
||||
)
|
||||
@@ -398,6 +403,17 @@ class TaskQueueService:
|
||||
f"[QueueWorker] Preserved authoritative session {sess_id} "
|
||||
f"status '{new_status}'"
|
||||
)
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
if trace_store.read_status(str(sess_id)):
|
||||
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status
|
||||
trace_store.update_trace_status(
|
||||
str(sess_id),
|
||||
canonical_mcp_status,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
||||
rec_status = (rec_info or {}).get("status")
|
||||
recovered_video_path = None
|
||||
@@ -428,6 +444,7 @@ class TaskQueueService:
|
||||
"recording_failed",
|
||||
{"session_id": sess_id, "error": recording_error},
|
||||
)
|
||||
|
||||
state.active_connections.pop(sess_id, None)
|
||||
cls._broadcast_event(
|
||||
"session_ended",
|
||||
@@ -439,16 +456,21 @@ class TaskQueueService:
|
||||
)
|
||||
|
||||
conversation_id = task_item.get("conversation_id") if task_item else None
|
||||
if conversation_id:
|
||||
if conversation_id or task_item.get("ingress") == "mcp":
|
||||
try:
|
||||
from mcp_server.notifiers import notify
|
||||
|
||||
notify(
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=conversation_id or "",
|
||||
message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}",
|
||||
title=f"Task {new_status.capitalize()}: {goal[:40]}",
|
||||
event_type=new_status,
|
||||
payload={"session_id": sess_id, "status": new_status, "goal": goal},
|
||||
payload={
|
||||
"trace_id": sess_id,
|
||||
"session_id": sess_id,
|
||||
"status": new_status,
|
||||
"goal": goal,
|
||||
},
|
||||
)
|
||||
except Exception as notif_err:
|
||||
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
|
||||
@@ -492,6 +514,56 @@ class TaskQueueService:
|
||||
|
||||
enqueued_tasks = []
|
||||
now = time.time()
|
||||
|
||||
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
|
||||
if session_id and len(goals) == 1:
|
||||
target_sid = str(session_id)
|
||||
if state.active_session_id and str(state.active_session_id) == target_sid:
|
||||
return {
|
||||
"status": "started",
|
||||
"tasks": [{"session_id": target_sid, "status": "running"}],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
existing_item = next(
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and str(item.get("session_id")) == target_sid
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
return {
|
||||
"status": existing_item.get("status", "queued"),
|
||||
"tasks": [existing_item],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
|
||||
# 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s)
|
||||
if len(goals) == 1:
|
||||
first_goal = goals[0]
|
||||
recent_duplicate = next(
|
||||
(
|
||||
item
|
||||
for item in reversed(state.queue_items)
|
||||
if isinstance(item, dict)
|
||||
and item.get("status") == "pending"
|
||||
and item.get("goal") == first_goal
|
||||
and (not device_serial or item.get("device_serial") == device_serial)
|
||||
and (now - float(item.get("created_at", 0))) < 1.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if recent_duplicate:
|
||||
return {
|
||||
"status": "queued",
|
||||
"tasks": [recent_duplicate],
|
||||
"enqueued_count": 0,
|
||||
"total_queued": len(state.queue_tasks),
|
||||
}
|
||||
|
||||
for i, goal in enumerate(goals):
|
||||
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
|
||||
assigned_serial = device_serial
|
||||
@@ -597,17 +669,18 @@ class TaskQueueService:
|
||||
sid = dev_owner.session_id
|
||||
if sid:
|
||||
session_repo.update_session_status(str(sid), "cancelled", time.time())
|
||||
if dev_owner.ingress == "mcp":
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
is_mcp = bool(dev_owner and dev_owner.ingress == "mcp")
|
||||
if is_mcp or trace_store.read_status(str(sid)):
|
||||
trace_store.update_trace_status(
|
||||
str(sid),
|
||||
"cancelled",
|
||||
error="Task stopped from the Artemis frontend.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
cls._broadcast_event(
|
||||
"session_ended",
|
||||
{
|
||||
@@ -791,17 +864,18 @@ class TaskQueueService:
|
||||
session_repo.update_session_status(
|
||||
str(stopped_session_id), "cancelled", time.time()
|
||||
)
|
||||
if owner and owner.ingress == "mcp":
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
is_mcp = bool(owner and owner.ingress == "mcp")
|
||||
if is_mcp or trace_store.read_status(str(stopped_session_id)):
|
||||
trace_store.update_trace_status(
|
||||
str(stopped_session_id),
|
||||
"cancelled",
|
||||
error="Task stopped from the Artemis frontend.",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to update MCP cancellation status: {exc}")
|
||||
except Exception as exc:
|
||||
print(f"Failed to update MCP cancellation status: {exc}")
|
||||
cls._broadcast_event(
|
||||
"session_ended",
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user