1206 lines
47 KiB
Python
1206 lines
47 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
|
if row is None:
|
|
return None
|
|
return {key: row[key] for key in row.keys()}
|
|
|
|
|
|
def json_loads(value: str | None, default: Any) -> Any:
|
|
if not value:
|
|
return default
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return default
|
|
|
|
|
|
def json_dumps(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
|
|
|
|
def as_int(value: Any, default: int = 0) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def as_float(value: Any, default: float = 0.0) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
class AlreadyRegisteredError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class ManagedConnection(sqlite3.Connection):
|
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
try:
|
|
return super().__exit__(exc_type, exc_value, exc_traceback)
|
|
finally:
|
|
self.close()
|
|
|
|
|
|
DEFAULT_MODEL_CONFIG = {
|
|
"target_system_url": "",
|
|
"vllm_base_url": "",
|
|
"vllm_model": "",
|
|
"api_key": "",
|
|
"grounding_url": "",
|
|
"grounding_model": "",
|
|
"ground_coordinate_mode": "absolute",
|
|
"resolution": "1920x1080",
|
|
"screenshot_max_width": 1920,
|
|
"screenshot_max_height": 1200,
|
|
"max_steps": 40,
|
|
"request_timeout_seconds": 30,
|
|
"temperature": 0,
|
|
}
|
|
|
|
|
|
DEFAULT_TOOLS = [
|
|
{
|
|
"id": "browser_control",
|
|
"name": "Browser Control",
|
|
"type": "interaction",
|
|
"version": "0.1",
|
|
"source": "controller",
|
|
"capability": "Open pages, click, type, navigate, and inspect Web UI state.",
|
|
"scenario": "Web / ERP / management system operation",
|
|
"available": True,
|
|
},
|
|
{
|
|
"id": "screen_capture",
|
|
"name": "Screen Capture",
|
|
"type": "observation",
|
|
"version": "0.1",
|
|
"source": "agent",
|
|
"capability": "Capture controlled-device screenshots for model reasoning and evidence.",
|
|
"scenario": "Live monitoring and task evidence",
|
|
"available": True,
|
|
},
|
|
{
|
|
"id": "keyboard_mouse",
|
|
"name": "Keyboard Mouse",
|
|
"type": "interaction",
|
|
"version": "0.1",
|
|
"source": "agent",
|
|
"capability": "Send keyboard and pointer actions to the controlled desktop.",
|
|
"scenario": "Desktop and browser automation",
|
|
"available": True,
|
|
},
|
|
{
|
|
"id": "log_reader",
|
|
"name": "Log Reader",
|
|
"type": "evidence",
|
|
"version": "0.1",
|
|
"source": "agent",
|
|
"capability": "Read task logs and expose trace evidence for troubleshooting.",
|
|
"scenario": "Failure analysis and report evidence",
|
|
"available": True,
|
|
},
|
|
{
|
|
"id": "report_generator",
|
|
"name": "Report Generator",
|
|
"type": "report",
|
|
"version": "0.1",
|
|
"source": "controller",
|
|
"capability": "Build run summaries from task status, duration, logs, and screenshots.",
|
|
"scenario": "Acceptance report and evidence package",
|
|
"available": True,
|
|
},
|
|
]
|
|
|
|
|
|
DEFAULT_TOOL_CONFIG = {
|
|
"global_enabled": {tool["id"]: True for tool in DEFAULT_TOOLS},
|
|
"agent_overrides": {},
|
|
"plan_overrides": {},
|
|
}
|
|
|
|
|
|
class Database:
|
|
def __init__(self, path: Path, agent_stale_after_seconds: int = 5):
|
|
self.path = path
|
|
self.agent_stale_after_seconds = agent_stale_after_seconds
|
|
|
|
def connect(self) -> sqlite3.Connection:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(self.path, factory=ManagedConnection)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
return connection
|
|
|
|
def init(self) -> None:
|
|
with self.connect() as db:
|
|
db.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
public_id TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
deployment_type TEXT NOT NULL,
|
|
machine_fingerprint TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
agent_version TEXT NOT NULL,
|
|
registered_at TEXT NOT NULL,
|
|
last_seen_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'registered',
|
|
reported_host TEXT,
|
|
reported_ip TEXT,
|
|
reported_log_web_port INTEGER,
|
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_agents_last_seen
|
|
ON agents(last_seen_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS heartbeats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id INTEGER NOT NULL,
|
|
received_at TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
external_id TEXT UNIQUE,
|
|
title TEXT,
|
|
plan_name TEXT,
|
|
plan_version INTEGER NOT NULL DEFAULT 1,
|
|
module TEXT,
|
|
parent_external_id TEXT,
|
|
root_external_id TEXT,
|
|
depth INTEGER NOT NULL DEFAULT 0,
|
|
task_level TEXT,
|
|
risk_level TEXT,
|
|
status TEXT NOT NULL DEFAULT 'ready',
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
assigned_agent_id INTEGER,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
failure_policy TEXT NOT NULL DEFAULT 'skip',
|
|
max_retries INTEGER NOT NULL DEFAULT 0,
|
|
timeout_seconds INTEGER,
|
|
dispatch_mode TEXT NOT NULL DEFAULT 'auto',
|
|
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
attempt INTEGER NOT NULL DEFAULT 0,
|
|
result_json TEXT,
|
|
last_error TEXT,
|
|
duration_seconds REAL,
|
|
model_duration_seconds REAL,
|
|
action_duration_seconds REAL,
|
|
operator_note TEXT,
|
|
created_at TEXT NOT NULL,
|
|
claimed_at TEXT,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY(assigned_agent_id) REFERENCES agents(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status_priority
|
|
ON tasks(status, priority, created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value_json TEXT NOT NULL,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_type TEXT NOT NULL,
|
|
entity_type TEXT NOT NULL,
|
|
entity_id TEXT,
|
|
actor TEXT NOT NULL DEFAULT 'system',
|
|
reason TEXT,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
self._migrate(db)
|
|
self._ensure_setting(db, "model_config", DEFAULT_MODEL_CONFIG)
|
|
self._ensure_setting(
|
|
db,
|
|
"tool_config",
|
|
{
|
|
"tools": DEFAULT_TOOLS,
|
|
"config": DEFAULT_TOOL_CONFIG,
|
|
},
|
|
)
|
|
|
|
def _migrate(self, db: sqlite3.Connection) -> None:
|
|
self._ensure_column(db, "agents", "reported_log_web_port", "INTEGER")
|
|
self._ensure_column(db, "agents", "config_revision", "INTEGER NOT NULL DEFAULT 0")
|
|
self._ensure_column(db, "agents", "config_applied_at", "TEXT")
|
|
self._ensure_column(db, "agents", "config_restart_requested_at", "TEXT")
|
|
self._ensure_column(db, "agents", "config_json", "TEXT NOT NULL DEFAULT '{}'")
|
|
task_columns = {
|
|
"title": "TEXT",
|
|
"plan_name": "TEXT",
|
|
"plan_version": "INTEGER NOT NULL DEFAULT 1",
|
|
"module": "TEXT",
|
|
"parent_external_id": "TEXT",
|
|
"root_external_id": "TEXT",
|
|
"depth": "INTEGER NOT NULL DEFAULT 0",
|
|
"task_level": "TEXT",
|
|
"risk_level": "TEXT",
|
|
"failure_policy": "TEXT NOT NULL DEFAULT 'skip'",
|
|
"max_retries": "INTEGER NOT NULL DEFAULT 0",
|
|
"timeout_seconds": "INTEGER",
|
|
"dispatch_mode": "TEXT NOT NULL DEFAULT 'auto'",
|
|
"dependencies_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"tags_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
"attempt": "INTEGER NOT NULL DEFAULT 0",
|
|
"result_json": "TEXT",
|
|
"last_error": "TEXT",
|
|
"duration_seconds": "REAL",
|
|
"model_duration_seconds": "REAL",
|
|
"action_duration_seconds": "REAL",
|
|
"operator_note": "TEXT",
|
|
"started_at": "TEXT",
|
|
"finished_at": "TEXT",
|
|
}
|
|
for name, definition in task_columns.items():
|
|
self._ensure_column(db, "tasks", name, definition)
|
|
|
|
def _ensure_column(
|
|
self,
|
|
db: sqlite3.Connection,
|
|
table_name: str,
|
|
column_name: str,
|
|
column_sql: str,
|
|
) -> None:
|
|
columns = {
|
|
row["name"]
|
|
for row in db.execute(f"PRAGMA table_info({table_name})").fetchall()
|
|
}
|
|
if column_name not in columns:
|
|
db.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}")
|
|
|
|
def _ensure_setting(self, db: sqlite3.Connection, key: str, value: Any) -> None:
|
|
existing = db.execute("SELECT key FROM settings WHERE key = ?", (key,)).fetchone()
|
|
if existing is None:
|
|
db.execute(
|
|
"""
|
|
INSERT INTO settings (key, value_json, version, updated_at)
|
|
VALUES (?, ?, 1, ?)
|
|
""",
|
|
(key, json_dumps(value), utc_now()),
|
|
)
|
|
|
|
def _audit(
|
|
self,
|
|
db: sqlite3.Connection,
|
|
event_type: str,
|
|
entity_type: str,
|
|
entity_id: str | None,
|
|
payload: dict[str, Any] | None = None,
|
|
reason: str | None = None,
|
|
actor: str = "system",
|
|
) -> None:
|
|
db.execute(
|
|
"""
|
|
INSERT INTO audit_events (
|
|
event_type, entity_type, entity_id, actor, reason, payload_json, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_type,
|
|
entity_type,
|
|
entity_id,
|
|
actor,
|
|
reason,
|
|
json_dumps(payload or {}),
|
|
utc_now(),
|
|
),
|
|
)
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
try:
|
|
with self.connect() as db:
|
|
db.execute("SELECT 1").fetchone()
|
|
agent_count = db.execute("SELECT COUNT(*) FROM agents").fetchone()[0]
|
|
task_count = db.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
|
|
return {
|
|
"ok": True,
|
|
"agent_count": agent_count,
|
|
"task_count": task_count,
|
|
"path": str(self.path),
|
|
}
|
|
except sqlite3.Error as exc:
|
|
return {"ok": False, "error": str(exc), "path": str(self.path)}
|
|
|
|
def register_agent(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
public_id = str(payload["public_id"])
|
|
now = utc_now()
|
|
token = secrets.token_urlsafe(32)
|
|
metadata = payload.get("metadata") or {}
|
|
|
|
with self.connect() as db:
|
|
existing = db.execute(
|
|
"SELECT id, machine_fingerprint, registered_at FROM agents WHERE public_id = ?",
|
|
(public_id,),
|
|
).fetchone()
|
|
if existing is not None:
|
|
incoming_fingerprint = str(payload.get("machine_fingerprint") or "unknown")
|
|
existing_fingerprint = str(existing["machine_fingerprint"] or "unknown")
|
|
if existing_fingerprint != incoming_fingerprint:
|
|
raise AlreadyRegisteredError(
|
|
"Agent public_id is already registered by another machine fingerprint."
|
|
)
|
|
|
|
agent_id = int(existing["id"])
|
|
db.execute(
|
|
"""
|
|
UPDATE agents
|
|
SET display_name = ?,
|
|
deployment_type = ?,
|
|
machine_fingerprint = ?,
|
|
token_hash = ?,
|
|
agent_version = ?,
|
|
status = 'registered',
|
|
reported_host = ?,
|
|
reported_ip = ?,
|
|
reported_log_web_port = ?,
|
|
metadata_json = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
str(payload.get("display_name") or public_id),
|
|
str(payload.get("deployment_type") or "unknown"),
|
|
incoming_fingerprint,
|
|
hash_token(token),
|
|
str(payload.get("agent_version") or "unknown"),
|
|
str(payload.get("reported_host") or ""),
|
|
str(payload.get("reported_ip") or ""),
|
|
payload.get("reported_log_web_port"),
|
|
json_dumps(metadata),
|
|
agent_id,
|
|
),
|
|
)
|
|
self._audit(db, "token_rotated", "agent", str(agent_id), {"public_id": public_id})
|
|
return {
|
|
"agent_id": agent_id,
|
|
"public_id": public_id,
|
|
"token": token,
|
|
"registered": False,
|
|
"token_rotated": True,
|
|
"registered_at": str(existing["registered_at"]),
|
|
}
|
|
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO agents (
|
|
public_id,
|
|
display_name,
|
|
deployment_type,
|
|
machine_fingerprint,
|
|
token_hash,
|
|
agent_version,
|
|
registered_at,
|
|
status,
|
|
reported_host,
|
|
reported_ip,
|
|
reported_log_web_port,
|
|
metadata_json
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'registered', ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
public_id,
|
|
str(payload.get("display_name") or public_id),
|
|
str(payload.get("deployment_type") or "unknown"),
|
|
str(payload.get("machine_fingerprint") or "unknown"),
|
|
hash_token(token),
|
|
str(payload.get("agent_version") or "unknown"),
|
|
now,
|
|
str(payload.get("reported_host") or ""),
|
|
str(payload.get("reported_ip") or ""),
|
|
payload.get("reported_log_web_port"),
|
|
json_dumps(metadata),
|
|
),
|
|
)
|
|
agent_id = cursor.lastrowid
|
|
self._audit(db, "registered", "agent", str(agent_id), {"public_id": public_id})
|
|
|
|
return {
|
|
"agent_id": agent_id,
|
|
"public_id": public_id,
|
|
"token": token,
|
|
"registered": True,
|
|
"registered_at": now,
|
|
}
|
|
|
|
def validate_agent(self, agent_id: int, token: str) -> dict[str, Any] | None:
|
|
with self.connect() as db:
|
|
row = db.execute(
|
|
"SELECT * FROM agents WHERE id = ? AND token_hash = ?",
|
|
(agent_id, hash_token(token)),
|
|
).fetchone()
|
|
return row_to_dict(row)
|
|
|
|
def record_heartbeat(self, agent_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
now = utc_now()
|
|
deployment_type = str(payload.get("deployment_type") or "unknown")
|
|
display_name = str(payload.get("display_name") or "")
|
|
metadata = payload.get("metadata") or {}
|
|
status = str(payload.get("status") or "online")
|
|
reported_revision = as_int(
|
|
payload.get("config_revision", payload.get("model_config_version")),
|
|
0,
|
|
)
|
|
model_setting = self.get_model_config(public=False)
|
|
model_revision = as_int(model_setting.get("version"), 1)
|
|
update_available = reported_revision < model_revision
|
|
config_applied_at = now if reported_revision >= model_revision else None
|
|
config_restart_requested_at = now if update_available else None
|
|
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""
|
|
UPDATE agents
|
|
SET last_seen_at = ?,
|
|
status = ?,
|
|
deployment_type = ?,
|
|
display_name = COALESCE(NULLIF(?, ''), display_name),
|
|
reported_host = COALESCE(NULLIF(?, ''), reported_host),
|
|
reported_ip = COALESCE(NULLIF(?, ''), reported_ip),
|
|
reported_log_web_port = ?,
|
|
metadata_json = ?,
|
|
config_json = ?,
|
|
config_revision = ?,
|
|
config_applied_at = COALESCE(?, config_applied_at),
|
|
config_restart_requested_at = COALESCE(?, config_restart_requested_at)
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
now,
|
|
status,
|
|
deployment_type,
|
|
display_name,
|
|
str(payload.get("reported_host") or ""),
|
|
str(payload.get("reported_ip") or ""),
|
|
payload.get("reported_log_web_port"),
|
|
json_dumps(metadata),
|
|
json_dumps(
|
|
{
|
|
"model_config_version": reported_revision,
|
|
"runtime_generation": payload.get("runtime_generation"),
|
|
}
|
|
),
|
|
reported_revision,
|
|
config_applied_at,
|
|
config_restart_requested_at,
|
|
agent_id,
|
|
),
|
|
)
|
|
db.execute(
|
|
"""
|
|
INSERT INTO heartbeats (agent_id, received_at, payload_json)
|
|
VALUES (?, ?, ?)
|
|
""",
|
|
(agent_id, now, json_dumps(payload)),
|
|
)
|
|
|
|
receipt: dict[str, Any] = {
|
|
"ok": True,
|
|
"agent_id": agent_id,
|
|
"received_at": now,
|
|
"model_config_revision": model_revision,
|
|
"agent_config_revision": reported_revision,
|
|
"config_update_available": update_available,
|
|
}
|
|
if update_available:
|
|
receipt["model_config"] = {
|
|
"version": model_revision,
|
|
"updated_at": model_setting.get("updated_at"),
|
|
"requires_agent_restart": True,
|
|
"value": self._public_model_config_value(model_setting.get("value") or DEFAULT_MODEL_CONFIG),
|
|
}
|
|
return receipt
|
|
|
|
def _agent_status_with_stale_check(self, status: str, last_seen_at: str | None) -> str:
|
|
if not last_seen_at:
|
|
return status
|
|
try:
|
|
last_seen = datetime.fromisoformat(last_seen_at)
|
|
except ValueError:
|
|
return status
|
|
if last_seen.tzinfo is None:
|
|
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
elapsed = (datetime.now(timezone.utc) - last_seen).total_seconds()
|
|
if elapsed > self.agent_stale_after_seconds:
|
|
return "offline"
|
|
return status
|
|
|
|
def list_agents(self) -> list[dict[str, Any]]:
|
|
with self.connect() as db:
|
|
rows = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
public_id,
|
|
display_name,
|
|
deployment_type,
|
|
machine_fingerprint,
|
|
agent_version,
|
|
registered_at,
|
|
last_seen_at,
|
|
status,
|
|
reported_host,
|
|
reported_ip,
|
|
reported_log_web_port,
|
|
config_revision,
|
|
config_applied_at,
|
|
config_restart_requested_at,
|
|
config_json,
|
|
metadata_json
|
|
FROM agents
|
|
ORDER BY last_seen_at DESC NULLS LAST, registered_at DESC
|
|
"""
|
|
).fetchall()
|
|
agents: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
item = row_to_dict(row) or {}
|
|
item["config"] = json_loads(item.pop("config_json", "{}"), {})
|
|
item["metadata"] = json_loads(item.pop("metadata_json", "{}"), {})
|
|
item["raw_status"] = item.get("status")
|
|
item["status"] = self._agent_status_with_stale_check(
|
|
str(item.get("status") or "unknown"),
|
|
item.get("last_seen_at"),
|
|
)
|
|
agents.append(item)
|
|
return agents
|
|
|
|
def get_setting(self, key: str, default: Any) -> dict[str, Any]:
|
|
with self.connect() as db:
|
|
row = db.execute(
|
|
"SELECT value_json, version, updated_at FROM settings WHERE key = ?",
|
|
(key,),
|
|
).fetchone()
|
|
if row is None:
|
|
return {"value": default, "version": 1, "updated_at": None}
|
|
return {
|
|
"value": json_loads(row["value_json"], default),
|
|
"version": row["version"],
|
|
"updated_at": row["updated_at"],
|
|
}
|
|
|
|
def set_setting(self, key: str, value: Any, actor: str = "web") -> dict[str, Any]:
|
|
now = utc_now()
|
|
with self.connect() as db:
|
|
existing = db.execute(
|
|
"SELECT version FROM settings WHERE key = ?",
|
|
(key,),
|
|
).fetchone()
|
|
version = int(existing["version"]) + 1 if existing else 1
|
|
db.execute(
|
|
"""
|
|
INSERT INTO settings (key, value_json, version, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value_json = excluded.value_json,
|
|
version = excluded.version,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(key, json_dumps(value), version, now),
|
|
)
|
|
self._audit(db, "updated", "setting", key, {"version": version}, actor=actor)
|
|
return {"value": value, "version": version, "updated_at": now}
|
|
|
|
def _public_model_config_value(self, value: dict[str, Any]) -> dict[str, Any]:
|
|
public_value = {**DEFAULT_MODEL_CONFIG, **value}
|
|
public_value["api_key_set"] = bool(public_value.get("api_key"))
|
|
public_value["api_key"] = ""
|
|
return public_value
|
|
|
|
def _normalize_model_config(
|
|
self,
|
|
payload: dict[str, Any],
|
|
current: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
current_value = {**DEFAULT_MODEL_CONFIG, **(current or {})}
|
|
merged = {**current_value}
|
|
ignored = {"api_key_set", "version", "updated_at"}
|
|
for key, value in payload.items():
|
|
if key in ignored or key == "api_key":
|
|
continue
|
|
if key in DEFAULT_MODEL_CONFIG:
|
|
merged[key] = value
|
|
|
|
raw_api_key = str(payload.get("api_key") or "")
|
|
if payload.get("clear_api_key"):
|
|
merged["api_key"] = ""
|
|
elif raw_api_key:
|
|
merged["api_key"] = raw_api_key
|
|
else:
|
|
merged["api_key"] = str(current_value.get("api_key") or "")
|
|
|
|
text_fields = [
|
|
"target_system_url",
|
|
"vllm_base_url",
|
|
"vllm_model",
|
|
"grounding_url",
|
|
"grounding_model",
|
|
"ground_coordinate_mode",
|
|
"resolution",
|
|
"api_key",
|
|
]
|
|
for field in text_fields:
|
|
merged[field] = str(merged.get(field) or "").strip()
|
|
|
|
for field in ["target_system_url", "vllm_base_url", "grounding_url"]:
|
|
value = merged.get(field)
|
|
if value and not str(value).startswith(("http://", "https://")):
|
|
raise ValueError(f"{field} must start with http:// or https://")
|
|
|
|
if not re.fullmatch(r"\d{3,5}x\d{3,5}", str(merged.get("resolution") or "")):
|
|
raise ValueError("resolution must use WIDTHxHEIGHT format, for example 1920x1080")
|
|
|
|
numeric_ranges = {
|
|
"screenshot_max_width": (1, 7680),
|
|
"screenshot_max_height": (1, 7680),
|
|
"max_steps": (1, 500),
|
|
"request_timeout_seconds": (1, 300),
|
|
}
|
|
for field, (minimum, maximum) in numeric_ranges.items():
|
|
value = as_int(merged.get(field), as_int(DEFAULT_MODEL_CONFIG[field]))
|
|
if not minimum <= value <= maximum:
|
|
raise ValueError(f"{field} must be between {minimum} and {maximum}")
|
|
merged[field] = value
|
|
|
|
temperature = as_float(merged.get("temperature"), as_float(DEFAULT_MODEL_CONFIG["temperature"]))
|
|
if not 0 <= temperature <= 2:
|
|
raise ValueError("temperature must be between 0 and 2")
|
|
merged["temperature"] = temperature
|
|
|
|
if merged["ground_coordinate_mode"] not in {"absolute", "normalized"}:
|
|
raise ValueError("ground_coordinate_mode must be absolute or normalized")
|
|
|
|
return merged
|
|
|
|
def get_model_config(self, public: bool = True) -> dict[str, Any]:
|
|
setting = self.get_setting("model_config", DEFAULT_MODEL_CONFIG)
|
|
value = {**DEFAULT_MODEL_CONFIG, **(setting.get("value") or {})}
|
|
if public:
|
|
value = self._public_model_config_value(value)
|
|
return {
|
|
"value": value,
|
|
"version": setting["version"],
|
|
"updated_at": setting["updated_at"],
|
|
}
|
|
|
|
def save_model_config(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
current = self.get_model_config(public=False)["value"]
|
|
merged = self._normalize_model_config(payload, current)
|
|
saved = self.set_setting("model_config", merged, actor="web")
|
|
return {
|
|
"value": self._public_model_config_value(saved["value"]),
|
|
"version": saved["version"],
|
|
"updated_at": saved["updated_at"],
|
|
}
|
|
|
|
def get_tool_config(self) -> dict[str, Any]:
|
|
value = self.get_setting(
|
|
"tool_config",
|
|
{"tools": DEFAULT_TOOLS, "config": DEFAULT_TOOL_CONFIG},
|
|
)
|
|
tool_payload = value["value"]
|
|
tools = tool_payload.get("tools") or DEFAULT_TOOLS
|
|
config = {**DEFAULT_TOOL_CONFIG, **(tool_payload.get("config") or {})}
|
|
return {
|
|
"tools": tools,
|
|
"config": config,
|
|
"version": value["version"],
|
|
"updated_at": value["updated_at"],
|
|
}
|
|
|
|
def save_tool_config(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
current = self.get_tool_config()
|
|
tools = payload.get("tools") or current["tools"]
|
|
config = {**DEFAULT_TOOL_CONFIG, **current["config"], **(payload.get("config") or {})}
|
|
saved = self.set_setting(
|
|
"tool_config",
|
|
{"tools": tools, "config": config},
|
|
actor="web",
|
|
)
|
|
return {
|
|
"tools": tools,
|
|
"config": config,
|
|
"version": saved["version"],
|
|
"updated_at": saved["updated_at"],
|
|
}
|
|
|
|
def _dedupe_external_id(self, db: sqlite3.Connection, external_id: str | None) -> str | None:
|
|
if not external_id:
|
|
return None
|
|
candidate = external_id
|
|
suffix = 2
|
|
while db.execute("SELECT 1 FROM tasks WHERE external_id = ?", (candidate,)).fetchone():
|
|
candidate = f"{external_id}-{suffix}"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
def _normalize_task_payload(
|
|
self,
|
|
payload: dict[str, Any],
|
|
defaults: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
defaults = defaults or {}
|
|
task_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
|
|
external_id = (
|
|
payload.get("external_id")
|
|
or payload.get("task_id")
|
|
or payload.get("id")
|
|
or task_payload.get("task_id")
|
|
or task_payload.get("id")
|
|
)
|
|
instruction = task_payload.get("instruction") or payload.get("instruction") or ""
|
|
title = (
|
|
payload.get("title")
|
|
or task_payload.get("title")
|
|
or task_payload.get("name")
|
|
or (str(instruction)[:64] if instruction else external_id)
|
|
or "Untitled task"
|
|
)
|
|
return {
|
|
"external_id": str(external_id) if external_id else None,
|
|
"title": str(title),
|
|
"plan_name": str(payload.get("plan_name") or task_payload.get("plan_name") or defaults.get("plan_name") or "Default Plan"),
|
|
"plan_version": as_int(payload.get("plan_version") or defaults.get("plan_version"), 1),
|
|
"module": str(payload.get("module") or task_payload.get("module") or task_payload.get("category") or ""),
|
|
"parent_external_id": (
|
|
payload.get("parent_external_id")
|
|
or payload.get("parent_id")
|
|
or task_payload.get("parent_external_id")
|
|
or task_payload.get("parent_id")
|
|
),
|
|
"root_external_id": (
|
|
payload.get("root_external_id")
|
|
or payload.get("root_id")
|
|
or task_payload.get("root_external_id")
|
|
or task_payload.get("root_id")
|
|
),
|
|
"depth": as_int(payload.get("depth") or task_payload.get("depth"), 0),
|
|
"task_level": str(payload.get("task_level") or payload.get("level") or task_payload.get("task_level") or task_payload.get("level") or ""),
|
|
"risk_level": str(payload.get("risk_level") or task_payload.get("risk_level") or "low"),
|
|
"status": str(payload.get("status") or defaults.get("status") or "ready"),
|
|
"priority": as_int(
|
|
payload.get("priority")
|
|
or task_payload.get("priority")
|
|
or task_payload.get("task_priority")
|
|
or defaults.get("priority"),
|
|
100,
|
|
),
|
|
"assigned_agent_id": payload.get("assigned_agent_id") or defaults.get("assigned_agent_id"),
|
|
"payload": task_payload,
|
|
"failure_policy": str(
|
|
payload.get("failure_policy")
|
|
or task_payload.get("failure_policy")
|
|
or task_payload.get("task_failure_policy")
|
|
or defaults.get("failure_policy")
|
|
or "skip"
|
|
),
|
|
"max_retries": as_int(payload.get("max_retries") or task_payload.get("retries") or defaults.get("max_retries"), 0),
|
|
"timeout_seconds": payload.get("timeout_seconds") or task_payload.get("timeout") or defaults.get("timeout_seconds"),
|
|
"dispatch_mode": str(payload.get("dispatch_mode") or defaults.get("dispatch_mode") or "auto"),
|
|
"dependencies": payload.get("dependencies") or task_payload.get("depends_on") or [],
|
|
"tags": payload.get("tags") or task_payload.get("tags") or [],
|
|
}
|
|
|
|
def create_task(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
task = self._normalize_task_payload(payload)
|
|
now = utc_now()
|
|
with self.connect() as db:
|
|
task["external_id"] = self._dedupe_external_id(db, task["external_id"])
|
|
if not task["root_external_id"]:
|
|
task["root_external_id"] = task["external_id"]
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO tasks (
|
|
external_id, title, plan_name, plan_version, module,
|
|
parent_external_id, root_external_id, depth, task_level, risk_level,
|
|
status, priority,
|
|
assigned_agent_id, payload_json, failure_policy, max_retries,
|
|
timeout_seconds, dispatch_mode, dependencies_json, tags_json,
|
|
created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
task["external_id"],
|
|
task["title"],
|
|
task["plan_name"],
|
|
task["plan_version"],
|
|
task["module"],
|
|
task["parent_external_id"],
|
|
task["root_external_id"],
|
|
task["depth"],
|
|
task["task_level"],
|
|
task["risk_level"],
|
|
task["status"],
|
|
task["priority"],
|
|
task["assigned_agent_id"],
|
|
json_dumps(task["payload"]),
|
|
task["failure_policy"],
|
|
task["max_retries"],
|
|
task["timeout_seconds"],
|
|
task["dispatch_mode"],
|
|
json_dumps(task["dependencies"]),
|
|
json_dumps(task["tags"]),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
task_id = cursor.lastrowid
|
|
self._audit(db, "created", "task", str(task_id), {"external_id": task["external_id"]}, actor="web")
|
|
return self.get_task(int(task_id)) or {}
|
|
|
|
def _row_to_task(self, row: sqlite3.Row) -> dict[str, Any]:
|
|
task = row_to_dict(row) or {}
|
|
task["payload"] = json_loads(task.pop("payload_json", "{}"), {})
|
|
task["dependencies"] = json_loads(task.pop("dependencies_json", "[]"), [])
|
|
task["tags"] = json_loads(task.pop("tags_json", "[]"), [])
|
|
task["result"] = json_loads(task.pop("result_json", None), None)
|
|
return task
|
|
|
|
def get_task(self, task_id: int) -> dict[str, Any] | None:
|
|
with self.connect() as db:
|
|
row = db.execute(
|
|
"""
|
|
SELECT t.*, a.display_name AS assigned_agent_name
|
|
FROM tasks t
|
|
LEFT JOIN agents a ON a.id = t.assigned_agent_id
|
|
WHERE t.id = ?
|
|
""",
|
|
(task_id,),
|
|
).fetchone()
|
|
return self._row_to_task(row) if row else None
|
|
|
|
def list_tasks(self, status: str | None = None, plan_name: str | None = None) -> list[dict[str, Any]]:
|
|
where: list[str] = []
|
|
params: list[Any] = []
|
|
if status:
|
|
where.append("t.status = ?")
|
|
params.append(status)
|
|
if plan_name:
|
|
where.append("t.plan_name = ?")
|
|
params.append(plan_name)
|
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
|
with self.connect() as db:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT t.*, a.display_name AS assigned_agent_name
|
|
FROM tasks t
|
|
LEFT JOIN agents a ON a.id = t.assigned_agent_id
|
|
{where_sql}
|
|
ORDER BY t.priority ASC, t.created_at DESC
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
return [self._row_to_task(row) for row in rows]
|
|
|
|
def import_tasks(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
raw = payload.get("content", payload.get("tasks", payload))
|
|
if isinstance(raw, str):
|
|
raw = json.loads(raw)
|
|
|
|
plan_name = payload.get("plan_name")
|
|
plan_version = as_int(payload.get("plan_version"), 1)
|
|
entries: list[dict[str, Any]] = []
|
|
|
|
if isinstance(raw, list):
|
|
for item in raw:
|
|
if isinstance(item, dict) and isinstance(item.get("tasks"), list):
|
|
name = item.get("plan_name") or item.get("name") or plan_name
|
|
for child in item["tasks"]:
|
|
if isinstance(child, dict):
|
|
entries.append({**child, "plan_name": child.get("plan_name") or name})
|
|
elif isinstance(item, dict):
|
|
entries.append(item)
|
|
elif isinstance(raw, dict):
|
|
plan_name = raw.get("plan_name") or raw.get("name") or plan_name
|
|
if isinstance(raw.get("plans"), list):
|
|
for plan in raw["plans"]:
|
|
if not isinstance(plan, dict):
|
|
continue
|
|
name = plan.get("plan_name") or plan.get("name") or plan_name
|
|
for child in plan.get("tasks") or []:
|
|
if isinstance(child, dict):
|
|
entries.append({**child, "plan_name": child.get("plan_name") or name})
|
|
elif isinstance(raw.get("tasks"), list):
|
|
for child in raw["tasks"]:
|
|
if isinstance(child, dict):
|
|
entries.append({**child, "plan_name": child.get("plan_name") or plan_name})
|
|
elif isinstance(raw.get("tasks"), dict):
|
|
for key, child in raw["tasks"].items():
|
|
if isinstance(child, dict):
|
|
entries.append({**child, "task_id": child.get("task_id") or key, "plan_name": child.get("plan_name") or plan_name})
|
|
else:
|
|
entries.append(raw)
|
|
else:
|
|
raise ValueError("Unsupported task JSON shape")
|
|
|
|
defaults = {
|
|
"plan_name": plan_name or "Imported Plan",
|
|
"plan_version": plan_version,
|
|
"priority": payload.get("priority") or 100,
|
|
}
|
|
created = [self.create_task({**entry, **{"plan_name": entry.get("plan_name") or defaults["plan_name"]}, "plan_version": plan_version}) for entry in entries]
|
|
return {"count": len(created), "tasks": created}
|
|
|
|
def claim_task(self, agent_id: int) -> dict[str, Any] | None:
|
|
now = utc_now()
|
|
with self.connect() as db:
|
|
row = db.execute(
|
|
"""
|
|
SELECT *
|
|
FROM tasks
|
|
WHERE status IN ('ready', 'routed')
|
|
AND (assigned_agent_id IS NULL OR assigned_agent_id = ?)
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT 1
|
|
""",
|
|
(agent_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
|
|
db.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET status = 'running',
|
|
assigned_agent_id = ?,
|
|
claimed_at = ?,
|
|
started_at = COALESCE(started_at, ?),
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(agent_id, now, now, now, row["id"]),
|
|
)
|
|
self._audit(db, "claimed", "task", str(row["id"]), {"agent_id": agent_id})
|
|
|
|
return self.get_task(int(row["id"]))
|
|
|
|
def assign_task(self, task_id: int, agent_id: int | None, reason: str | None = None) -> dict[str, Any]:
|
|
now = utc_now()
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET assigned_agent_id = ?,
|
|
dispatch_mode = CASE WHEN ? IS NULL THEN 'auto' ELSE 'manual' END,
|
|
status = CASE WHEN ? IS NULL THEN 'ready' ELSE 'routed' END,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(agent_id, agent_id, agent_id, now, task_id),
|
|
)
|
|
self._audit(db, "assigned", "task", str(task_id), {"agent_id": agent_id}, reason=reason, actor="web")
|
|
return self.get_task(task_id) or {}
|
|
|
|
def update_task_status(
|
|
self,
|
|
task_id: int,
|
|
status: str,
|
|
reason: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
now = utc_now()
|
|
finish_statuses = {"succeeded", "failed", "cancelled"}
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET status = ?,
|
|
last_error = COALESCE(?, last_error),
|
|
result_json = COALESCE(?, result_json),
|
|
finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
status,
|
|
reason,
|
|
json_dumps(payload) if payload is not None else None,
|
|
1 if status in finish_statuses else 0,
|
|
now,
|
|
now,
|
|
task_id,
|
|
),
|
|
)
|
|
self._audit(db, "status_changed", "task", str(task_id), {"status": status}, reason=reason, actor="web")
|
|
return self.get_task(task_id) or {}
|
|
|
|
def retry_task(self, task_id: int, reason: str | None = None) -> dict[str, Any]:
|
|
now = utc_now()
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET status = 'ready',
|
|
attempt = attempt + 1,
|
|
assigned_agent_id = NULL,
|
|
claimed_at = NULL,
|
|
started_at = NULL,
|
|
finished_at = NULL,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(now, task_id),
|
|
)
|
|
self._audit(db, "retried", "task", str(task_id), {}, reason=reason, actor="web")
|
|
return self.get_task(task_id) or {}
|
|
|
|
def record_task_result(
|
|
self,
|
|
task_id: int,
|
|
result: dict[str, Any],
|
|
status: str | None = None,
|
|
) -> dict[str, Any]:
|
|
now = utc_now()
|
|
final_status = status or ("succeeded" if result.get("success") else "failed")
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET status = ?,
|
|
result_json = ?,
|
|
last_error = ?,
|
|
duration_seconds = ?,
|
|
model_duration_seconds = ?,
|
|
action_duration_seconds = ?,
|
|
finished_at = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
final_status,
|
|
json_dumps(result),
|
|
result.get("message") if not result.get("success") else None,
|
|
result.get("duration_seconds") or result.get("duration"),
|
|
result.get("model_duration_seconds"),
|
|
result.get("action_duration_seconds"),
|
|
now,
|
|
now,
|
|
task_id,
|
|
),
|
|
)
|
|
self._audit(db, "result_reported", "task", str(task_id), {"status": final_status})
|
|
return self.get_task(task_id) or {}
|
|
|
|
def dashboard(self) -> dict[str, Any]:
|
|
agents = self.list_agents()
|
|
tasks = self.list_tasks()
|
|
metrics = self.metrics()
|
|
return {
|
|
"agents": agents,
|
|
"tasks": tasks[:50],
|
|
"metrics": metrics,
|
|
"model_config": self.get_model_config(),
|
|
"tool_config": self.get_tool_config(),
|
|
}
|
|
|
|
def metrics(self) -> dict[str, Any]:
|
|
agents = self.list_agents()
|
|
tasks = self.list_tasks()
|
|
total = len(tasks)
|
|
status_counts: dict[str, int] = {}
|
|
for task in tasks:
|
|
status_counts[task["status"]] = status_counts.get(task["status"], 0) + 1
|
|
succeeded = status_counts.get("succeeded", 0)
|
|
failed = status_counts.get("failed", 0)
|
|
cancelled = status_counts.get("cancelled", 0)
|
|
completed = succeeded + failed + cancelled
|
|
running = status_counts.get("running", 0)
|
|
ready = status_counts.get("ready", 0) + status_counts.get("routed", 0)
|
|
active_agents = len([a for a in agents if a.get("status") in {"online", "idle", "running"}])
|
|
durations = [as_float(t.get("duration_seconds")) for t in tasks if t.get("duration_seconds")]
|
|
avg_duration = round(sum(durations) / len(durations), 2) if durations else 0
|
|
interventions = len([t for t in tasks if t.get("dispatch_mode") == "manual" or t.get("operator_note")])
|
|
retried = len([t for t in tasks if as_int(t.get("attempt")) > 0])
|
|
plan_names = sorted({t.get("plan_name") or "Default Plan" for t in tasks})
|
|
|
|
return {
|
|
"total_tasks": total,
|
|
"ready_tasks": ready,
|
|
"running_tasks": running,
|
|
"completed_tasks": completed,
|
|
"succeeded_tasks": succeeded,
|
|
"failed_tasks": failed,
|
|
"cancelled_tasks": cancelled,
|
|
"agent_total": len(agents),
|
|
"agent_active": active_agents,
|
|
"pass_rate": round((succeeded / (succeeded + failed)) * 100, 1) if succeeded + failed else 0,
|
|
"fail_rate": round((failed / (succeeded + failed)) * 100, 1) if succeeded + failed else 0,
|
|
"coverage_rate": round((completed / total) * 100, 1) if total else 0,
|
|
"avg_duration_seconds": avg_duration,
|
|
"concurrency_utilization": round((running / active_agents) * 100, 1) if active_agents else 0,
|
|
"manual_intervention_rate": round((interventions / total) * 100, 1) if total else 0,
|
|
"recovery_rate": round((retried / max(failed + retried, 1)) * 100, 1) if (failed or retried) else 0,
|
|
"tool_consistency_rate": 100.0 if self.get_tool_config()["tools"] else 0,
|
|
"status_counts": status_counts,
|
|
"plans": plan_names,
|
|
}
|
|
|
|
def report_summary(self) -> dict[str, Any]:
|
|
tasks = self.list_tasks()
|
|
metrics = self.metrics()
|
|
failed = [t for t in tasks if t.get("status") == "failed"]
|
|
plans: dict[str, dict[str, Any]] = {}
|
|
for task in tasks:
|
|
name = task.get("plan_name") or "Default Plan"
|
|
item = plans.setdefault(
|
|
name,
|
|
{"plan_name": name, "total": 0, "succeeded": 0, "failed": 0, "cancelled": 0},
|
|
)
|
|
item["total"] += 1
|
|
if task["status"] in item:
|
|
item[task["status"]] += 1
|
|
return {
|
|
"generated_at": utc_now(),
|
|
"metrics": metrics,
|
|
"plans": list(plans.values()),
|
|
"failed_tasks": [
|
|
{
|
|
"id": task["id"],
|
|
"external_id": task.get("external_id"),
|
|
"title": task.get("title"),
|
|
"plan_name": task.get("plan_name"),
|
|
"reason": task.get("last_error"),
|
|
"evidence": (task.get("result") or {}).get("screenshots")
|
|
or (task.get("result") or {}).get("logs"),
|
|
}
|
|
for task in failed[:50]
|
|
],
|
|
}
|