diff --git a/CONTEXT.md b/CONTEXT.md index 2fd0a8db6..5efdfd723 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,3 +1,13 @@ +# Personal robot registration + +A non-executable record in the profile-scoped store implemented by +`raven.ops.robots.RobotStore`, exposed through `robots.*` RPC methods. It is +separate from the machine registry and its SSH/Raven nodes. Only confirmed +personal records are supported; optional status checks are explicit, bounded +GET requests and expire after 60 seconds. Registration or a reachable status +endpoint is not acceptance of physical robot capabilities. See +`docs/robot-registration.md` for the storage and network boundaries. + # Raven Runtime The Python agent runtime: receives messages from chat channels, runs the agent loop diff --git a/docs/robot-registration.md b/docs/robot-registration.md new file mode 100644 index 000000000..099a02ba0 --- /dev/null +++ b/docs/robot-registration.md @@ -0,0 +1,55 @@ +# Personal robot registrations + +The Connections hub separates **Machines**, where Raven may run an agent through +its existing SSH/node support, from **Robots**, which are device records without +an execution node. Existing machine IDs, configuration, SSH credentials, CLI +commands, `machines.*` methods, and the Machines label are unchanged. + +The Robots tab supports adding, searching, filtering, editing, moving records to +trash, and restoring them. Registration does not establish a connection. +Company devices and credentials are out of scope: the form blocks company +registration, and the runtime accepts only explicitly confirmed personal records. +Use independently governed instances for other security domains rather than +mixing their data into the personal registry. + +## Optional read-only status + +SparkBot and generic HTTP devices can have a status URL. Robot dogs, AGVs, mobile +manipulators, and other device types support metadata only. No robot is treated +as a Machine and no executable node is installed. + +Only **Check status** contacts a device. It sends one GET, without credentials, +proxies, or redirects, to a literal RFC1918 IPv4 address whose path is `/health` +or `/status`. Ports are limited to 80, 443, or 1024-65535. DNS names, public, +loopback, and link-local addresses, credentials, query parameters, and fragments +are rejected. The timeout is three seconds and the uncompressed JSON response limit is 16 KiB. +Compressed responses and non-standard numeric constants are rejected. +An optional expected `project` value prevents accepting a different device. +Only a bounded allowlist of health fields is retained. + +The states are unknown, not connected, error, and status endpoint reachable. +`ok: true` confirms the status endpoint only: it does not establish chassis, +audio, or motion acceptance. Results expire after 60 seconds, including when a +page remains open. Failed and malformed responses are never shown as online. +There is no scanning or background device polling and no control, shell, ROS, +MQTT, motion, or media endpoint. + +## Persistence and compatibility + +The existing authenticated RPC transport serves `robots.list`, `robots.save`, +`robots.remove`, `robots.restore`, and `robots.check`. Web clients use the same +typed transport and source seam as the other hub tabs. An older gateway without +these methods produces an unavailable message rather than invented records. +Offline fixtures never contact a real device or claim it is connected. + +Records live under `robots//personal/devices.sqlite3` beside the +resolved configuration file. Separate configuration files have separate stores; +the default is therefore under the instance's Raven home. The directory is +owner-only and the database is owner-readable/writable on POSIX. Records are +limited to 100, including trash. Removal is recoverable; restoring or editing a +record clears its old health observation. Revisions prevent a stale editor or +an overlapping health request from overwriting a later edit or removal. + +The feature does not modify the Machine registry or model/provider configuration. +Reverting the code leaves the separate database intact and stops exposing the +tab and RPC methods. Reapplying the feature reopens those registrations. diff --git a/i18n/messages.json b/i18n/messages.json index f3547095e..4b3f29a58 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -13160,6 +13160,57 @@ "gui.export.stop_report_label": { "en": "Discard", "zh": "放弃" - } + }, + "gui.extAgents.robot_title": {"en": "Personal robots", "zh": "个人机器人"}, + "gui.extAgents.robot_add": {"en": "Register robot", "zh": "登记机器人"}, + "gui.extAgents.robot_edit": {"en": "Edit registration", "zh": "编辑登记"}, + "gui.extAgents.robot_name": {"en": "Device name", "zh": "设备名称"}, + "gui.extAgents.robot_type": {"en": "Device type", "zh": "设备类型"}, + "gui.extAgents.robot_model": {"en": "Model", "zh": "型号"}, + "gui.extAgents.robot_location": {"en": "Location", "zh": "位置"}, + "gui.extAgents.robot_note": {"en": "Note", "zh": "备注"}, + "gui.extAgents.robot_mode": {"en": "Connection mode", "zh": "连接方式"}, + "gui.extAgents.robot_metadata": {"en": "Metadata only", "zh": "仅登记信息"}, + "gui.extAgents.robot_http": {"en": "Read-only HTTP status", "zh": "HTTP 只读状态"}, + "gui.extAgents.robot_status_url": {"en": "Status URL", "zh": "状态地址"}, + "gui.extAgents.robot_identity": {"en": "Expected project identity (optional)", "zh": "预期设备标识(可选)"}, + "gui.extAgents.robot_url_hint": {"en": "Private IPv4 /health or /status only; no credentials, query parameters, or redirects.", "zh": "仅支持局域网 IPv4 的 /health 或 /status;不接受凭据、查询参数或重定向。"}, + "gui.extAgents.robot_domain": {"en": "Security domain", "zh": "安全域"}, + "gui.extAgents.robot_personal": {"en": "Personal devices", "zh": "个人设备"}, + "gui.extAgents.robot_company": {"en": "Company devices (unsupported here)", "zh": "公司设备(此实例不接收)"}, + "gui.extAgents.robot_company_blocked": {"en": "Company devices require a separate governed instance and are not supported by this personal registry.", "zh": "公司设备需要独立的受管实例,此个人登记库不接收公司设备。"}, + "gui.extAgents.robot_boundary": {"en": "Personal devices only. Do not enter company addresses, accounts, or credentials. Registration does not establish a connection.", "zh": "只登记个人设备,请勿填写公司地址、账号或凭据。登记不等于已连接。"}, + "gui.extAgents.robot_personal_confirm": {"en": "I confirm this is a personal device and contains no company data or credentials.", "zh": "我确认这是个人设备,登记信息不含公司数据或凭据。"}, + "gui.extAgents.robot_readonly_confirm": {"en": "I confirm this endpoint only reports status and does not trigger control or motion.", "zh": "我确认此地址只提供状态,不会触发控制或动作。"}, + "gui.extAgents.robot_metadata_only": {"en": "This device type supports metadata only; no connection or control is attempted.", "zh": "此设备类型仅支持信息登记,不建立连接或执行控制。"}, + "gui.extAgents.robot_safety": {"en": "Check status sends one read-only GET on demand. Results expire after 60 seconds. A reachable endpoint does not verify chassis, audio, or motion. No executable node is installed.", "zh": "仅在点击检查时发送一次只读 GET,结果 60 秒后过期。接口可达不代表底盘、音频或动作能力已验收;不安装执行节点。"}, + "gui.extAgents.robot_search": {"en": "Search devices", "zh": "搜索设备"}, + "gui.extAgents.robot_filter": {"en": "Filter device types", "zh": "筛选设备类型"}, + "gui.extAgents.robot_all_types": {"en": "All types", "zh": "全部类型"}, + "gui.extAgents.robot_trash": {"en": "Trash", "zh": "回收站"}, + "gui.extAgents.robot_back": {"en": "Back to devices", "zh": "返回设备"}, + "gui.extAgents.robot_restore": {"en": "Restore", "zh": "恢复登记"}, + "gui.extAgents.robot_remove": {"en": "Move to trash", "zh": "移入回收站"}, + "gui.extAgents.robot_check": {"en": "Check status", "zh": "检查状态"}, + "gui.extAgents.robot_checking": {"en": "Checking...", "zh": "检查中…"}, + "gui.extAgents.robot_checked": {"en": "Last checked", "zh": "最近检查"}, + "gui.extAgents.robot_loading": {"en": "Loading registrations...", "zh": "正在读取登记…"}, + "gui.extAgents.robot_empty": {"en": "No matching registrations.", "zh": "没有匹配的登记。"}, + "gui.extAgents.robot_save_failed": {"en": "Could not save. Check the fields, or refresh if another client edited this record.", "zh": "保存失败。请检查字段;如其他页面已修改此记录,请刷新后重试。"}, + "gui.extAgents.robot_operation_failed": {"en": "The operation failed. The device has not been assumed online.", "zh": "操作未完成,未将设备认定为在线。"}, + "gui.extAgents.robot_unavailable": {"en": "Robot registration is unavailable on this gateway. Existing registrations have not been changed.", "zh": "此网关的机器人登记暂不可用,原记录未被修改。"}, + "gui.extAgents.robot_unverified": {"en": "No verified connection. Registration alone does not establish availability.", "zh": "连接尚未验证,登记不代表设备可用。"}, + "gui.extAgents.robot_reachable": {"en": "Status endpoint reachable", "zh": "状态接口可达"}, + "gui.extAgents.robot_disconnected": {"en": "Not connected", "zh": "未连接"}, + "gui.extAgents.robot_unknown": {"en": "Unknown", "zh": "未知"}, + "gui.extAgents.robot_error": {"en": "Error", "zh": "错误"}, + "gui.extAgents.robot_sparkbot": {"en": "SparkBot", "zh": "SparkBot"}, + "gui.extAgents.robot_http_device": {"en": "HTTP device", "zh": "HTTP 设备"}, + "gui.extAgents.robot_dog": {"en": "Robot dog", "zh": "机器狗"}, + "gui.extAgents.robot_agv": {"en": "AGV", "zh": "AGV"}, + "gui.extAgents.robot_mobile_manipulator": {"en": "Mobile manipulator", "zh": "轮臂"}, + "gui.extAgents.robot_other": {"en": "Other device", "zh": "其他设备"}, + "gui.hub.robots": {"en": "Robots", "zh": "机器人"}, + "gui.page.robots_sub": {"en": "Register personal devices and read status; no executable nodes or robot control.", "zh": "登记个人设备、读取状态;不安装执行节点,不提供机器人控制"} } } diff --git a/raven/ops/robots.py b/raven/ops/robots.py new file mode 100644 index 000000000..1a701d115 --- /dev/null +++ b/raven/ops/robots.py @@ -0,0 +1,299 @@ +"""Profile-scoped personal robot records and bounded, explicit read-only health checks.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import math +import os +import sqlite3 +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit + +from aiohttp import ClientError, ClientSession, ClientTimeout + +TYPES = {"sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"} +FIELDS = { + "name", + "type", + "model", + "location", + "note", + "connection_mode", + "status_url", + "expected_project", + "domain", + "confirm_personal", + "revision", +} +OBSERVED = {"ok", "project", "version", "uptime_seconds", "wifi_rssi", "chassis_online"} +TTL = 60 +MAX_RESPONSE = 16384 + + +class RegistryError(ValueError): + status = 400 + + +class ConflictError(RegistryError): + status = 409 + + +class MissingError(RegistryError): + status = 404 + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def clean_text(value, maximum, *, required=False): + if not isinstance(value, str): + raise RegistryError("Fields must be text") + value = value.strip() + if (required and not value) or len(value) > maximum or any(ord(c) < 32 and c != "\n" for c in value): + raise RegistryError("A field is empty, too long, or contains unsupported characters") + return value + + +def validate_url(value, *, test_loopback=False): + """Literal RFC1918 IPv4 only: no DNS, proxies, redirects, queries or credentials.""" + try: + parsed = urlsplit(value) + address = ipaddress.ip_address(parsed.hostname or "") + private = any(address in ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")) + valid_address = address.version == 4 and (private or (test_loopback and address.is_loopback)) + port = parsed.port if parsed.port is not None else (443 if parsed.scheme == "https" else 80) + if ( + not valid_address + or parsed.scheme not in {"http", "https"} + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or parsed.path not in {"/health", "/status"} + or not (port in {80, 443} or 1024 <= port <= 65535) + or any(ord(c) <= 32 or ord(c) == 127 for c in value) + ): + raise ValueError() + except (ValueError, TypeError): + raise RegistryError( + "Use a private IPv4 /health or /status URL without credentials, query parameters, or fragments" + ) from None + return value + + +def validate(payload): + if not isinstance(payload, dict) or set(payload) - FIELDS: + raise RegistryError("Unsupported fields; credentials and control interfaces cannot be registered") + if payload.get("domain") != "personal" or payload.get("confirm_personal") is not True: + raise RegistryError("Only confirmed personal devices are supported in this registry") + kind = payload.get("type") + mode = payload.get("connection_mode", "metadata") + if kind not in TYPES or mode not in {"metadata", "http_status"}: + raise RegistryError("Unsupported device type or connection mode") + if kind not in {"sparkbot", "http_device"} and mode != "metadata": + raise RegistryError("Robot dogs, AGVs, and mobile manipulators support metadata registration only") + result = { + "domain": "personal", + "name": clean_text(payload.get("name", ""), 80, required=True), + "type": kind, + "connection_mode": mode, + } + for key, limit in (("model", 100), ("location", 100), ("note", 500), ("expected_project", 80)): + result[key] = clean_text(payload.get(key, ""), limit) + result["status_url"] = validate_url(payload.get("status_url", "")) if mode == "http_status" else "" + return result + + +class RobotStore: + def __init__(self, root: Path): + self.root = Path(root) + if self.root.is_symlink(): + raise RegistryError("The robot directory cannot be a symbolic link") + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(self.root, 0o700) + self.path = self.root / "devices.sqlite3" + if self.path.is_symlink(): + raise RegistryError("The robot database cannot be a symbolic link") + with self.connect() as db: + db.execute( + "CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL, observation TEXT, deleted_at TEXT, updated_at TEXT NOT NULL)" + ) + os.chmod(self.path, 0o600) + + @contextmanager + def connect(self): + db = sqlite3.connect(self.path, timeout=5) + db.row_factory = sqlite3.Row + try: + with db: + yield db + finally: + db.close() + + @staticmethod + def unpack(row): + data = json.loads(row["data"]) + if data.get("domain") != "personal": + raise RegistryError("The stored security domain does not match") + observation = json.loads(row["observation"]) if row["observation"] else None + status = "unknown" if data["connection_mode"] == "metadata" else "not_connected" + stale = False + if observation: + age = time.time() - observation["checked_epoch"] + stale = age < -5 or age > TTL + status = "unknown" if stale else observation["status"] + return { + **data, + "id": row["id"], + "revision": row["revision"], + "status": status, + "observation": observation, + "stale": stale, + "deleted_at": row["deleted_at"], + "updated_at": row["updated_at"], + } + + def list(self, *, trash=False): + with self.connect() as db: + rows = db.execute( + "SELECT * FROM devices WHERE deleted_at IS " + + ("NOT NULL" if trash else "NULL") + + " ORDER BY updated_at DESC" + ).fetchall() + return [self.unpack(row) for row in rows] + + def get(self, device_id, *, include_deleted=False): + with self.connect() as db: + row = db.execute("SELECT * FROM devices WHERE id=?", (device_id,)).fetchone() + if row is None or (row["deleted_at"] and not include_deleted): + raise MissingError("The robot does not exist or has been moved to trash") + return self.unpack(row) + + def save(self, payload, device_id=None): + data = validate(payload) + stamp = now() + with self.connect() as db: + db.execute("BEGIN IMMEDIATE") + if device_id: + old = db.execute("SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)).fetchone() + if old is None: + raise MissingError("The robot does not exist") + if type(payload.get("revision")) is not int or payload["revision"] != old["revision"]: + raise ConflictError("The robot was edited by another client; refresh before retrying") + db.execute( + "UPDATE devices SET data=?, observation=NULL, revision=revision+1, updated_at=? WHERE id=?", + (json.dumps(data, ensure_ascii=False), stamp, device_id), + ) + else: + if db.execute("SELECT COUNT(*) FROM devices").fetchone()[0] >= 100: + raise RegistryError("The registry has reached its limit of 100 records") + device_id = uuid.uuid4().hex + db.execute( + "INSERT INTO devices VALUES(?,1,?,NULL,NULL,?)", + (device_id, json.dumps(data, ensure_ascii=False), stamp), + ) + return self.get(device_id) + + def delete(self, device_id): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET deleted_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL", + (now(), device_id), + ) + if cursor.rowcount != 1: + raise MissingError("The robot does not exist or has been moved to trash") + + def restore(self, device_id): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET deleted_at=NULL, observation=NULL, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NOT NULL", + (now(), device_id), + ) + if cursor.rowcount != 1: + raise MissingError("The robot does not exist in trash") + return self.get(device_id) + + def observe(self, device_id, revision, observation): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET observation=? WHERE id=? AND revision=? AND deleted_at IS NULL", + (json.dumps(observation, ensure_ascii=False), device_id, revision), + ) + if cursor.rowcount != 1: + raise ConflictError("The robot changed during the check; the stale result was not saved") + return self.get(device_id) + + +def _nonfinite_json(value): + raise ValueError("Non-finite JSON constant") + + +async def probe_status(device, *, test_loopback=False): + url = validate_url(device["status_url"], test_loopback=test_loopback) + result = { + "status": "error", + "checked_at": now(), + "checked_epoch": time.time(), + "http_status": None, + "detail": "", + "observed": {}, + } + try: + async with ClientSession( + trust_env=False, timeout=ClientTimeout(total=3), raise_for_status=False, auto_decompress=False + ) as client: + async with client.get( + url, allow_redirects=False, headers={"Accept": "application/json", "Accept-Encoding": "identity"} + ) as response: + result["http_status"] = response.status + if response.status != 200: + result["detail"] = ( + "The endpoint returned an error; redirects were not followed and no credentials were sent" + ) + return result + raw = bytearray() + async for chunk in response.content.iter_chunked(4096): + raw.extend(chunk) + if len(raw) > MAX_RESPONSE: + result["detail"] = "The status response exceeded 16 KiB" + return result + try: + data = json.loads(raw, parse_constant=_nonfinite_json) + except (ValueError, UnicodeDecodeError, RecursionError): + result["detail"] = "The endpoint did not return valid JSON" + return result + if not isinstance(data, dict): + result["detail"] = "Unsupported status response shape" + return result + expected = device.get("expected_project") + if expected and data.get("project") != expected: + result["detail"] = "The project identity did not match the registered device" + return result + result["observed"] = { + key: value + for key, value in data.items() + if key in OBSERVED + and isinstance(value, (str, bool, int, float)) + and (not isinstance(value, float) or math.isfinite(value)) + and (not isinstance(value, str) or len(value) <= 100) + } + if data.get("ok") is True: + result.update( + status="reachable", + detail="The status endpoint responded; chassis, audio, and motion capabilities have not been verified", + ) + else: + result.update(status="unknown", detail="The endpoint is reachable but did not confirm device health") + except (asyncio.TimeoutError, ClientError, OSError): + result.update( + status="not_connected", + detail="Could not connect to the status endpoint; check device power, address, and network", + ) + return result diff --git a/raven/rpc/methods/__init__.py b/raven/rpc/methods/__init__.py index 3ccc1b2d2..5b08bd402 100644 --- a/raven/rpc/methods/__init__.py +++ b/raven/rpc/methods/__init__.py @@ -39,6 +39,7 @@ from raven.rpc.methods.plughub import register_plughub_methods from raven.rpc.methods.question import register_question_methods from raven.rpc.methods.reload import register_reload_methods +from raven.rpc.methods.robots import register_robots_methods from raven.rpc.methods.session import register_session_methods from raven.rpc.methods.setup import register_setup_methods from raven.rpc.methods.shell import register_shell_methods @@ -271,6 +272,7 @@ def register_aligned_methods_except_system( # unconditionally: the registry is a file, and the node pool is the # process's own. register_machines_methods(dispatcher) + register_robots_methods(dispatcher) __all__ = [ @@ -290,6 +292,7 @@ def register_aligned_methods_except_system( "register_console_methods", "register_knowledge_methods", "register_machines_methods", + "register_robots_methods", "register_memory_methods", "register_playbooks_methods", "register_plughub_methods", diff --git a/raven/rpc/methods/robots.py b/raven/rpc/methods/robots.py new file mode 100644 index 000000000..f84933549 --- /dev/null +++ b/raven/rpc/methods/robots.py @@ -0,0 +1,83 @@ +"""Personal robot registration, separate from the Machine registry.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from raven.home import get_config_path +from raven.ops.robots import RegistryError, RobotStore, probe_status +from raven.rpc.errors import ConfigValidationError +from raven.rpc.models import RobotsIdParams, RobotsListParams, RobotsSaveParams + +if TYPE_CHECKING: + from raven.rpc.dispatcher import Dispatcher + + +def _store() -> RobotStore: + config = get_config_path().resolve() + return RobotStore(config.parent / "robots" / config.stem / "personal") + + +def _parse(model, params): + try: + return model.model_validate(params) + except ValidationError: + raise ConfigValidationError("Invalid robot registration parameters") from None + + +async def robots_list(params: dict) -> dict: + request = _parse(RobotsListParams, params) + try: + return {"devices": _store().list(trash=request.trash), "status_ttl_seconds": 60} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_save(params: dict) -> dict: + request = _parse(RobotsSaveParams, params) + try: + payload = request.device.model_dump() + payload["revision"] = request.revision + return {"device": _store().save(payload, request.id)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_remove(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + _store().delete(request.id) + return {"ok": True} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_restore(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + return {"device": _store().restore(request.id)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_check(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + store = _store() + device = store.get(request.id) + if device["connection_mode"] != "http_status": + raise RegistryError("Metadata-only robots cannot be connected or controlled") + observation = await probe_status(device) + return {"device": store.observe(device["id"], device["revision"], observation)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +def register_robots_methods(dispatcher: Dispatcher) -> None: + dispatcher.register("robots.list", robots_list) + dispatcher.register("robots.save", robots_save) + dispatcher.register("robots.remove", robots_remove) + dispatcher.register("robots.restore", robots_restore) + dispatcher.register("robots.check", robots_check) diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 0a965ebd7..e777606c9 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -6882,7 +6882,76 @@ class ImportedUploadResult(_Strict): imported_at: str = Field(..., description="When it was imported, ISO 8601 in UTC.") +class RobotDraft(_Strict): + domain: Literal["personal"] + confirm_personal: bool = Field(..., strict=True) + name: str = Field(..., min_length=1, max_length=80) + type: Literal["sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"] + connection_mode: Literal["metadata", "http_status"] = "metadata" + model: str = Field(default="", max_length=100) + location: str = Field(default="", max_length=100) + note: str = Field(default="", max_length=500) + status_url: str = Field(default="", max_length=300) + expected_project: str = Field(default="", max_length=80) + + +class RobotObservation(_Strict): + status: Literal["reachable", "not_connected", "unknown", "error"] + checked_at: str + checked_epoch: float + http_status: int | None + detail: str + observed: dict[str, JsonValue] + + +class RobotRow(_Strict): + domain: Literal["personal"] + name: str + type: Literal["sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"] + connection_mode: Literal["metadata", "http_status"] + model: str + location: str + note: str + status_url: str + expected_project: str + id: str + revision: int + status: Literal["reachable", "not_connected", "unknown", "error"] + observation: RobotObservation | None + stale: bool + deleted_at: str | None + updated_at: str + + +class RobotsListParams(_Strict): + trash: bool = False + + +class RobotsListResult(_Strict): + devices: list[RobotRow] + status_ttl_seconds: int + + +class RobotsSaveParams(_Strict): + device: RobotDraft + id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") + revision: int | None = Field(default=None, ge=1, strict=True) + + +class RobotsIdParams(_Strict): + id: str = Field(..., pattern=r"^[a-f0-9]{32}$") + + +class RobotsDeviceResult(_Strict): + device: RobotRow + + METHOD_MODELS: dict[str, tuple[type[BaseModel], type[BaseModel]]] = { + "robots.list": (RobotsListParams, RobotsListResult), + "robots.save": (RobotsSaveParams, RobotsDeviceResult), + "robots.remove": (RobotsIdParams, OkResult), + "robots.restore": (RobotsIdParams, RobotsDeviceResult), + "robots.check": (RobotsIdParams, RobotsDeviceResult), # knowledge.* -- bases and their documents, served by the in-process engine "knowledge.status": (KnowledgeStatusParams, KnowledgeStatusResult), "knowledge.bases.list": (KnowledgeBasesListParams, KnowledgeBasesListResult), diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 383124ee6..2de4a202e 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -12118,6 +12118,181 @@ "$ref": "#/components/errors/ImportedBusy" } ] + }, + { + "name": "robots.list", + "summary": "List personal robot registrations or recoverable trash without contacting any device.", + "params": [ + { + "name": "trash", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "result": { + "name": "RobotsListResult", + "schema": { + "additionalProperties": false, + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/RobotRow" + }, + "type": "array" + }, + "status_ttl_seconds": { + "type": "integer" + } + }, + "required": [ + "devices", + "status_ttl_seconds" + ], + "type": "object" + } + } + }, + { + "name": "robots.save", + "summary": "Create or update a confirmed personal robot registration; no credentials or control endpoints.", + "params": [ + { + "name": "device", + "required": true, + "schema": { + "$ref": "#/components/schemas/RobotDraft" + } + }, + { + "name": "id", + "required": false, + "schema": { + "default": null, + "pattern": "^[a-f0-9]{32}$", + "type": [ + "string", + "null" + ] + } + }, + { + "name": "revision", + "required": false, + "schema": { + "default": null, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } + }, + { + "name": "robots.remove", + "summary": "Move a robot registration to recoverable trash without contacting the device.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "OkResult", + "schema": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + { + "name": "robots.restore", + "summary": "Restore a registration from trash and discard its previous health observation.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } + }, + { + "name": "robots.check", + "summary": "Make one bounded read-only GET to the registered private IPv4 health endpoint; never install or execute a node.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } } ], "components": { @@ -18653,6 +18828,256 @@ "description": "The agent entries on the machine that are switched off, by name: they still hold the machine for the add, so the card says so instead of counting them nowhere." } } + }, + "RobotObservation": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "checked_at": { + "type": "string" + }, + "checked_epoch": { + "type": "number" + }, + "http_status": { + "type": [ + "integer", + "null" + ] + }, + "detail": { + "type": "string" + }, + "observed": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + } + }, + "required": [ + "status", + "checked_at", + "checked_epoch", + "http_status", + "detail", + "observed" + ], + "type": "object" + }, + "RobotRow": { + "additionalProperties": false, + "properties": { + "domain": { + "const": "personal", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "sparkbot", + "http_device", + "robot_dog", + "agv", + "mobile_manipulator", + "other" + ], + "type": "string" + }, + "connection_mode": { + "enum": [ + "metadata", + "http_status" + ], + "type": "string" + }, + "model": { + "type": "string" + }, + "location": { + "type": "string" + }, + "note": { + "type": "string" + }, + "status_url": { + "type": "string" + }, + "expected_project": { + "type": "string" + }, + "id": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "observation": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "checked_at": { + "type": "string" + }, + "checked_epoch": { + "type": "number" + }, + "http_status": { + "type": [ + "integer", + "null" + ] + }, + "detail": { + "type": "string" + }, + "observed": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + } + }, + "required": [ + "status", + "checked_at", + "checked_epoch", + "http_status", + "detail", + "observed" + ], + "type": [ + "object", + "null" + ] + }, + "stale": { + "type": "boolean" + }, + "deleted_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "domain", + "name", + "type", + "connection_mode", + "model", + "location", + "note", + "status_url", + "expected_project", + "id", + "revision", + "status", + "observation", + "stale", + "deleted_at", + "updated_at" + ], + "type": "object" + }, + "RobotDraft": { + "additionalProperties": false, + "properties": { + "domain": { + "const": "personal", + "type": "string" + }, + "confirm_personal": { + "type": "boolean" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "sparkbot", + "http_device", + "robot_dog", + "agv", + "mobile_manipulator", + "other" + ], + "type": "string" + }, + "connection_mode": { + "default": "metadata", + "enum": [ + "metadata", + "http_status" + ], + "type": "string" + }, + "model": { + "default": "", + "maxLength": 100, + "type": "string" + }, + "location": { + "default": "", + "maxLength": 100, + "type": "string" + }, + "note": { + "default": "", + "maxLength": 500, + "type": "string" + }, + "status_url": { + "default": "", + "maxLength": 300, + "type": "string" + }, + "expected_project": { + "default": "", + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "domain", + "confirm_personal", + "name", + "type" + ], + "type": "object" } }, "errors": { diff --git a/tests/test_ops_robots.py b/tests/test_ops_robots.py new file mode 100644 index 000000000..3633a9742 --- /dev/null +++ b/tests/test_ops_robots.py @@ -0,0 +1,246 @@ +"""Persistence, isolation, stale results, and bounded read-only robot health checks.""" + +import asyncio +import gzip +import json +import os +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from aiohttp import web +from aiohttp.test_utils import TestServer + +from raven.ops.robots import ( + ConflictError, + MissingError, + RegistryError, + RobotStore, + probe_status, + validate, + validate_url, +) + + +def data(**changes): + return dict( + domain="personal", + confirm_personal=True, + name="Test personal device", + type="sparkbot", + connection_mode="metadata", + **changes, + ) + + +class StorageTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "registry" + self.db = RobotStore(self.root) + + def tearDown(self): + self.tmp.cleanup() + + def test_invalid_metadata_and_absent_records_preserve_existing_data(self): + original = self.db.save(data()) + for payload in [ + None, + {**data(), "name": " "}, + {**data(), "name": 4}, + {**data(), "name": "bad\x00name"}, + {**data(), "name": "x" * 81}, + {**data(), "type": "unsupported"}, + {**data(), "connection_mode": "ssh"}, + ]: + with self.subTest(payload=payload), self.assertRaises(RegistryError): + self.db.save(payload) + missing = "0" * 32 + for action in [ + lambda: self.db.get(missing), + lambda: self.db.delete(missing), + lambda: self.db.restore(missing), + lambda: self.db.save({**data(), "revision": 1}, missing), + ]: + with self.assertRaises(MissingError): + action() + self.assertEqual(self.db.list()[0]["id"], original["id"]) + + def test_registry_size_and_stored_domain_are_enforced(self): + row = self.db.save(data()) + with self.db.connect() as db: + for i in range(99): + db.execute( + "INSERT INTO devices VALUES(?,1,?,NULL,NULL,?)", + (f"{i:032x}", json.dumps(validate(data())), "fixture"), + ) + with self.assertRaises(RegistryError): + self.db.save(data()) + self.assertEqual(len(self.db.list()), 100) + with self.db.connect() as db: + db.execute( + "UPDATE devices SET data=? WHERE id=?", + (json.dumps({**validate(data()), "domain": "company"}), row["id"]), + ) + with self.assertRaises(RegistryError): + self.db.list() + + @unittest.skipIf(os.name == "nt", "POSIX symlink and permission boundary") + def test_symbolic_link_stores_are_refused(self): + alias = Path(self.tmp.name) / "alias" + alias.symlink_to(self.root, target_is_directory=True) + with self.assertRaises(RegistryError): + RobotStore(alias) + other = Path(self.tmp.name) / "other" + other.mkdir() + (other / "devices.sqlite3").symlink_to(self.db.path) + with self.assertRaises(RegistryError): + RobotStore(other) + + def test_crud_persistence_cas_and_recovery(self): + original = self.db.save(data()) + self.assertEqual(original["status"], "unknown") + reopened = RobotStore(self.root) + edited = reopened.save( + {**data(), "name": "Edited personal device", "revision": original["revision"]}, original["id"] + ) + self.assertEqual(edited["revision"], 2) + with self.assertRaises(ConflictError): + reopened.save({**data(), "revision": 1}, original["id"]) + reopened.delete(original["id"]) + self.assertEqual(reopened.list(), []) + self.assertEqual(len(RobotStore(self.root).list(trash=True)), 1) + restored = reopened.restore(original["id"]) + self.assertEqual(restored["name"], "Edited personal device") + self.assertEqual(restored["status"], "unknown") + with self.assertRaises(MissingError): + reopened.restore(original["id"]) + if os.name != "nt": + self.assertEqual(self.root.stat().st_mode & 0o777, 0o700) + self.assertEqual(reopened.path.stat().st_mode & 0o777, 0o600) + + def test_domain_and_credential_control_fields_are_rejected(self): + for payload in ( + {**data(), "domain": "company"}, + {**data(), "confirm_personal": False}, + {**data(), "api_key": "not-a-key"}, + {**data(), "command": "move"}, + {**data(), "type": "agv", "connection_mode": "http_status"}, + ): + with self.subTest(payload=payload), self.assertRaises(RegistryError): + validate(payload) + self.assertEqual(self.db.list(), []) + + def test_no_dns_public_loopback_credentials_or_control_urls(self): + for address in [ + "http://localhost:8080/health", + "http://127.0.0.1:8080/health", + "http://169.254.169.254/health", + "http://8.8.8.8/health", + "http://[::1]/health", + "http://u:p@192.168.1.2:8080/health", + "http://192.168.1.2:8080/health?command=move", + "http://192.168.1.2:8080/health#token", + "http://192.168.1.2:22/health", + "http://192.168.1.2:0/health", + "http://192.168.1.2:8080/\nhealth", + "http://192.168.1.2:8080/move", + ]: + with self.subTest(address=address), self.assertRaises(RegistryError): + validate_url(address) + self.assertEqual(validate_url("http://192.168.1.2:8080/health"), "http://192.168.1.2:8080/health") + + def test_status_stales_and_late_result_cannot_resurrect_or_overwrite(self): + row = self.db.save({**data(), "connection_mode": "http_status", "status_url": "http://192.168.1.2:8080/health"}) + observation = {"checked_epoch": time.time(), "status": "reachable"} + self.assertEqual(self.db.observe(row["id"], row["revision"], observation)["status"], "reachable") + with patch("raven.ops.robots.time.time", return_value=time.time() + 61): + self.assertEqual(self.db.get(row["id"])["status"], "unknown") + updated = self.db.save({**data(), "revision": row["revision"]}, row["id"]) + with self.assertRaises(ConflictError): + self.db.observe(row["id"], row["revision"], observation) + self.db.delete(row["id"]) + with self.assertRaises(ConflictError): + self.db.observe(row["id"], updated["revision"], observation) + self.assertEqual(self.db.list(), []) + + +class ProbeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.mode = "good" + self.redirect_hits = 0 + + async def handler(request): + if self.mode == "deep": + return web.Response(body=b"[" * 5000 + b"0" + b"]" * 5000) + if self.mode == "compressed": + return web.Response( + body=gzip.compress(b'{"ok":true,"project":"fixture"}'), headers={"Content-Encoding": "gzip"} + ) + if self.mode == "nonfinite": + return web.Response(body=b'{"ok":true,"project":"fixture","uptime_seconds":NaN}') + if self.mode == "redirect": + raise web.HTTPFound("/control-target") + if self.mode == "oversize": + return web.json_response({"ok": True, "padding": "x" * 17000}) + if self.mode == "invalid": + return web.Response(text="not a device") + if self.mode == "http_error": + return web.json_response({"ok": True}, status=503) + if self.mode == "unhealthy": + return web.json_response({"ok": False, "project": "fixture"}) + if self.mode == "shape": + return web.json_response([]) + if self.mode == "identity": + return web.json_response({"ok": True, "project": "wrong"}) + # Fragmented transfer exercises bounded reads without truncating JSON. + response = web.StreamResponse(headers={"Content-Type": "application/json"}) + await response.prepare(request) + await response.write(b'{"ok":true,"project":"fixture",') + await asyncio.sleep(0.01) + await response.write(b'"chassis_online":false,"secret":"must-not-leak"}') + await response.write_eof() + return response + + async def redirect_target(request): + self.redirect_hits += 1 + return web.json_response({"ok": True}) + + app = web.Application() + app.router.add_get("/health", handler) + app.router.add_get("/control-target", redirect_target) + self.server = TestServer(app) + await self.server.start_server() + self.device = {"status_url": str(self.server.make_url("/health")), "expected_project": "fixture"} + + async def asyncTearDown(self): + await self.server.close() + + async def test_fragmented_response_allowlist_and_identity(self): + result = await probe_status(self.device, test_loopback=True) + self.assertEqual(result["status"], "reachable") + self.assertIs(result["observed"]["chassis_online"], False) + self.assertNotIn("secret", result["observed"]) + self.mode = "identity" + self.assertEqual((await probe_status(self.device, test_loopback=True))["status"], "error") + + async def test_failure_is_never_online_and_redirect_not_followed(self): + for mode in [ + "redirect", + "oversize", + "invalid", + "http_error", + "unhealthy", + "shape", + "compressed", + "nonfinite", + "deep", + ]: + self.mode = mode + with self.subTest(mode=mode): + self.assertNotEqual((await probe_status(self.device, test_loopback=True))["status"], "reachable") + self.assertEqual(self.redirect_hits, 0) + await self.server.close() + self.assertEqual((await probe_status(self.device, test_loopback=True))["status"], "not_connected") diff --git a/tests/test_rpc_robots.py b/tests/test_rpc_robots.py new file mode 100644 index 000000000..c6cdf7227 --- /dev/null +++ b/tests/test_rpc_robots.py @@ -0,0 +1,152 @@ +"""Robot RPC contracts, profile isolation, and refusal of device control.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest +from pydantic import ValidationError + +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import ConfigValidationError +from raven.rpc.methods import robots +from raven.rpc.models import METHOD_MODELS, RobotDraft + + +def draft(**changes): + return {"domain": "personal", "confirm_personal": True, "name": "Fixture robot", "type": "sparkbot", **changes} + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setattr(robots, "get_config_path", lambda: tmp_path / "config.json") + return tmp_path + + +def checked(method, result): + METHOD_MODELS[method][1].model_validate(result) + schema = json.loads((Path(__file__).parents[1] / "rpc-schema/openrpc.json").read_text()) + wire = next(item["result"]["schema"] for item in schema["methods"] if item["name"] == method) + jsonschema.validate(result, {"components": schema["components"], **wire}) + return result + + +@pytest.mark.asyncio +async def test_rpc_crud_persistence_and_recoverable_removal(home): + row = checked("robots.save", await robots.robots_save({"device": draft()}))["device"] + assert row["status"] == "unknown" + updated = await robots.robots_save( + {"device": draft(name="Edited fixture"), "id": row["id"], "revision": row["revision"]} + ) + checked("robots.save", updated) + with pytest.raises(ConfigValidationError, match="edited by another client"): + await robots.robots_save({"device": draft(), "id": row["id"], "revision": row["revision"]}) + checked("robots.remove", await robots.robots_remove({"id": row["id"]})) + assert checked("robots.list", await robots.robots_list({}))["devices"] == [] + trash = checked("robots.list", await robots.robots_list({"trash": True}))["devices"] + assert trash[0]["name"] == "Edited fixture" + restored = checked("robots.restore", await robots.robots_restore({"id": row["id"]}))["device"] + assert restored["observation"] is None + assert restored["name"] == "Edited fixture" + assert len(robots._store().list()) == 1 + + +@pytest.mark.asyncio +async def test_metadata_check_and_invalid_inputs_never_probe(home, monkeypatch): + calls = [] + + async def forbidden_probe(*args, **kwargs): + calls.append(args) + raise AssertionError("Unexpected network access") + + monkeypatch.setattr(robots, "probe_status", forbidden_probe) + row = (await robots.robots_save({"device": draft(type="agv")}))["device"] + with pytest.raises(ConfigValidationError, match="Metadata-only"): + await robots.robots_check({"id": row["id"]}) + for change in ( + {"domain": "company"}, + {"confirm_personal": False}, + {"confirm_personal": "true"}, + {"command": "move"}, + {"api_key": "fixture"}, + {"status_url": "http://127.0.0.1:8080/health", "connection_mode": "http_status"}, + ): + with pytest.raises(ConfigValidationError): + await robots.robots_save({"device": draft(**change)}) + with pytest.raises(ConfigValidationError): + await robots.robots_check({"id": "not-an-id"}) + assert not calls + assert len(robots._store().list()) == 1 + + +@pytest.mark.asyncio +async def test_missing_records_and_foreign_stored_domains_are_refused(home): + for operation in [robots.robots_remove, robots.robots_restore]: + with pytest.raises(ConfigValidationError): + await operation({"id": "0" * 32}) + row = (await robots.robots_save({"device": draft()}))["device"] + with robots._store().connect() as db: + db.execute("UPDATE devices SET data=? WHERE id=?", (json.dumps({"domain": "company"}), row["id"])) + with pytest.raises(ConfigValidationError, match="security domain"): + await robots.robots_list({}) + + +@pytest.mark.asyncio +async def test_explicit_check_uses_registered_address_and_stale_result_is_refused(home, monkeypatch): + row = ( + await robots.robots_save( + {"device": draft(connection_mode="http_status", status_url="http://10.0.0.2:8080/health")} + ) + )["device"] + observation = { + "status": "not_connected", + "checked_at": "2026-01-01T00:00:00+00:00", + "checked_epoch": 0.0, + "http_status": None, + "detail": "Fixture disconnected", + "observed": {}, + } + calls = [] + + async def probe(device): + calls.append(device["status_url"]) + return observation + + monkeypatch.setattr(robots, "probe_status", probe) + result = checked("robots.check", await robots.robots_check({"id": row["id"]})) + assert calls == ["http://10.0.0.2:8080/health"] + assert result["device"]["status"] == "unknown" + + async def overlapping_remove(device): + robots._store().delete(device["id"]) + return observation + + monkeypatch.setattr(robots, "probe_status", overlapping_remove) + with pytest.raises(ConfigValidationError, match="stale result"): + await robots.robots_check({"id": row["id"]}) + assert not robots._store().list() + + +@pytest.mark.asyncio +async def test_config_profiles_are_separate_and_have_no_motion_method(home, monkeypatch): + await robots.robots_save({"device": draft()}) + monkeypatch.setattr(robots, "get_config_path", lambda: home / "other.json") + assert (await robots.robots_list({}))["devices"] == [] + dispatcher = Dispatcher() + robots.register_robots_methods(dispatcher) + assert set(dispatcher.methods()) == { + "robots.list", + "robots.save", + "robots.check", + "robots.remove", + "robots.restore", + } + reply = await dispatcher.dispatch({"jsonrpc": "2.0", "id": 1, "method": "robots.move", "params": {}}) + assert reply["error"]["code"] == -32601 + + +def test_draft_does_not_accept_credentials(): + with pytest.raises(ValidationError): + RobotDraft.model_validate(draft(password="fixture")) diff --git a/tests/test_rpc_schema_match.py b/tests/test_rpc_schema_match.py index 8bf6b2f88..ea9ef6233 100644 --- a/tests/test_rpc_schema_match.py +++ b/tests/test_rpc_schema_match.py @@ -89,6 +89,11 @@ def _resolve_pyd_ref(ref: str, root: dict[str, Any]) -> dict[str, Any]: _ANY_REFS = {"JsonValue"} +def _oas_nullable(node: dict[str, Any]) -> bool: + kinds = node.get("type", []) + return isinstance(kinds, list) and "null" in kinds + + def _normalize_oas_type( node: dict[str, Any], schema: dict[str, Any], _seen: frozenset[str] = frozenset() ) -> dict[str, Any]: @@ -138,7 +143,9 @@ def _normalize_oas_type( out["properties"] = { pname: _normalize_oas_type(psub, schema, _seen) for pname, psub in node["properties"].items() } - out["required"] = sorted(node.get("required", [])) + out["required"] = sorted( + name for name in node.get("required", []) if not _oas_nullable(node["properties"][name]) + ) if "additionalProperties" in node: ap = node["additionalProperties"] if isinstance(ap, dict): @@ -230,7 +237,7 @@ def _oas_params_to_canonical(method: dict[str, Any], schema: dict[str, Any]) -> out: dict[str, dict[str, Any]] = {} for p in method.get("params", []): out[p["name"]] = { - "required": bool(p.get("required", False)), + "required": bool(p.get("required", False)) and not _oas_nullable(p["schema"]), "schema": _normalize_oas_type(p["schema"], schema), } return out @@ -270,6 +277,8 @@ def _oas_object_properties( out: dict[str, dict[str, Any]] = {} for name, sub in properties.items(): out[name] = _normalize_oas_type(sub, schema) + if _oas_nullable(sub): + required_set.discard(name) return out, required_set @@ -799,3 +808,24 @@ def test_a_model_is_built_when_first_used_not_at_import(tmp_path: Path) -> None: assert "built at import: []" in done.stdout, done.stdout + done.stderr assert "first use: 3 True" in done.stdout, done.stdout + done.stderr assert "refused: extra_forbidden" in done.stdout, done.stdout + done.stderr + + +def test_required_nullable_arrays_normalize_like_pydantic_without_relaxing_the_wire(): + import jsonschema + + class Example(BaseModel): + value: str | None + + wire = { + "type": "object", + "required": ["value"], + "properties": {"value": {"type": ["string", "null"]}}, + "additionalProperties": False, + } + assert _normalize_oas_type(wire, {}) == _normalize_pyd_type( + Example.model_json_schema(), Example.model_json_schema() + ) + assert _oas_object_properties(wire, {}) == _pyd_object_properties(Example) + jsonschema.validate({"value": None}, wire) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({}, wire) diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 7c7f124e2..4911b0887 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -3726,7 +3726,67 @@ export const UI_TEXT: Record> = { 'gui.export.kept_local': 'The report is kept at {dir}. Send it later with: raven trajectory upload-bug-report {id}', 'gui.export.stop_report_title': 'Discard this bug report?', 'gui.export.stop_report_body': 'Your decisions are lost. A report already saved on this machine stays there.', - 'gui.export.stop_report_label': 'Discard' + 'gui.export.stop_report_label': 'Discard', + 'gui.extAgents.robot_title': 'Personal robots', + 'gui.extAgents.robot_add': 'Register robot', + 'gui.extAgents.robot_edit': 'Edit registration', + 'gui.extAgents.robot_name': 'Device name', + 'gui.extAgents.robot_type': 'Device type', + 'gui.extAgents.robot_model': 'Model', + 'gui.extAgents.robot_location': 'Location', + 'gui.extAgents.robot_note': 'Note', + 'gui.extAgents.robot_mode': 'Connection mode', + 'gui.extAgents.robot_metadata': 'Metadata only', + 'gui.extAgents.robot_http': 'Read-only HTTP status', + 'gui.extAgents.robot_status_url': 'Status URL', + 'gui.extAgents.robot_identity': 'Expected project identity (optional)', + 'gui.extAgents.robot_url_hint': + 'Private IPv4 /health or /status only; no credentials, query parameters, or redirects.', + 'gui.extAgents.robot_domain': 'Security domain', + 'gui.extAgents.robot_personal': 'Personal devices', + 'gui.extAgents.robot_company': 'Company devices (unsupported here)', + 'gui.extAgents.robot_company_blocked': + 'Company devices require a separate governed instance and are not supported by this personal registry.', + 'gui.extAgents.robot_boundary': + 'Personal devices only. Do not enter company addresses, accounts, or credentials. Registration does not establish a connection.', + 'gui.extAgents.robot_personal_confirm': + 'I confirm this is a personal device and contains no company data or credentials.', + 'gui.extAgents.robot_readonly_confirm': + 'I confirm this endpoint only reports status and does not trigger control or motion.', + 'gui.extAgents.robot_metadata_only': + 'This device type supports metadata only; no connection or control is attempted.', + 'gui.extAgents.robot_safety': + 'Check status sends one read-only GET on demand. Results expire after 60 seconds. A reachable endpoint does not verify chassis, audio, or motion. No executable node is installed.', + 'gui.extAgents.robot_search': 'Search devices', + 'gui.extAgents.robot_filter': 'Filter device types', + 'gui.extAgents.robot_all_types': 'All types', + 'gui.extAgents.robot_trash': 'Trash', + 'gui.extAgents.robot_back': 'Back to devices', + 'gui.extAgents.robot_restore': 'Restore', + 'gui.extAgents.robot_remove': 'Move to trash', + 'gui.extAgents.robot_check': 'Check status', + 'gui.extAgents.robot_checking': 'Checking...', + 'gui.extAgents.robot_checked': 'Last checked', + 'gui.extAgents.robot_loading': 'Loading registrations...', + 'gui.extAgents.robot_empty': 'No matching registrations.', + 'gui.extAgents.robot_save_failed': + 'Could not save. Check the fields, or refresh if another client edited this record.', + 'gui.extAgents.robot_operation_failed': 'The operation failed. The device has not been assumed online.', + 'gui.extAgents.robot_unavailable': + 'Robot registration is unavailable on this gateway. Existing registrations have not been changed.', + 'gui.extAgents.robot_unverified': 'No verified connection. Registration alone does not establish availability.', + 'gui.extAgents.robot_reachable': 'Status endpoint reachable', + 'gui.extAgents.robot_disconnected': 'Not connected', + 'gui.extAgents.robot_unknown': 'Unknown', + 'gui.extAgents.robot_error': 'Error', + 'gui.extAgents.robot_sparkbot': 'SparkBot', + 'gui.extAgents.robot_http_device': 'HTTP device', + 'gui.extAgents.robot_dog': 'Robot dog', + 'gui.extAgents.robot_agv': 'AGV', + 'gui.extAgents.robot_mobile_manipulator': 'Mobile manipulator', + 'gui.extAgents.robot_other': 'Other device', + 'gui.hub.robots': 'Robots', + 'gui.page.robots_sub': 'Register personal devices and read status; no executable nodes or robot control.' }, zh: { 'gui.act.ing.ask_user': '等待你回答', @@ -7064,6 +7124,58 @@ export const UI_TEXT: Record> = { 'gui.export.kept_local': '报告已保存在 {dir},可稍后用 raven trajectory upload-bug-report {id} 上传。', 'gui.export.stop_report_title': '放弃这份问题报告?', 'gui.export.stop_report_body': '你的决定将会丢失;已保存到本机的报告会保留。', - 'gui.export.stop_report_label': '放弃' + 'gui.export.stop_report_label': '放弃', + 'gui.extAgents.robot_title': '个人机器人', + 'gui.extAgents.robot_add': '登记机器人', + 'gui.extAgents.robot_edit': '编辑登记', + 'gui.extAgents.robot_name': '设备名称', + 'gui.extAgents.robot_type': '设备类型', + 'gui.extAgents.robot_model': '型号', + 'gui.extAgents.robot_location': '位置', + 'gui.extAgents.robot_note': '备注', + 'gui.extAgents.robot_mode': '连接方式', + 'gui.extAgents.robot_metadata': '仅登记信息', + 'gui.extAgents.robot_http': 'HTTP 只读状态', + 'gui.extAgents.robot_status_url': '状态地址', + 'gui.extAgents.robot_identity': '预期设备标识(可选)', + 'gui.extAgents.robot_url_hint': '仅支持局域网 IPv4 的 /health 或 /status;不接受凭据、查询参数或重定向。', + 'gui.extAgents.robot_domain': '安全域', + 'gui.extAgents.robot_personal': '个人设备', + 'gui.extAgents.robot_company': '公司设备(此实例不接收)', + 'gui.extAgents.robot_company_blocked': '公司设备需要独立的受管实例,此个人登记库不接收公司设备。', + 'gui.extAgents.robot_boundary': '只登记个人设备,请勿填写公司地址、账号或凭据。登记不等于已连接。', + 'gui.extAgents.robot_personal_confirm': '我确认这是个人设备,登记信息不含公司数据或凭据。', + 'gui.extAgents.robot_readonly_confirm': '我确认此地址只提供状态,不会触发控制或动作。', + 'gui.extAgents.robot_metadata_only': '此设备类型仅支持信息登记,不建立连接或执行控制。', + 'gui.extAgents.robot_safety': + '仅在点击检查时发送一次只读 GET,结果 60 秒后过期。接口可达不代表底盘、音频或动作能力已验收;不安装执行节点。', + 'gui.extAgents.robot_search': '搜索设备', + 'gui.extAgents.robot_filter': '筛选设备类型', + 'gui.extAgents.robot_all_types': '全部类型', + 'gui.extAgents.robot_trash': '回收站', + 'gui.extAgents.robot_back': '返回设备', + 'gui.extAgents.robot_restore': '恢复登记', + 'gui.extAgents.robot_remove': '移入回收站', + 'gui.extAgents.robot_check': '检查状态', + 'gui.extAgents.robot_checking': '检查中…', + 'gui.extAgents.robot_checked': '最近检查', + 'gui.extAgents.robot_loading': '正在读取登记…', + 'gui.extAgents.robot_empty': '没有匹配的登记。', + 'gui.extAgents.robot_save_failed': '保存失败。请检查字段;如其他页面已修改此记录,请刷新后重试。', + 'gui.extAgents.robot_operation_failed': '操作未完成,未将设备认定为在线。', + 'gui.extAgents.robot_unavailable': '此网关的机器人登记暂不可用,原记录未被修改。', + 'gui.extAgents.robot_unverified': '连接尚未验证,登记不代表设备可用。', + 'gui.extAgents.robot_reachable': '状态接口可达', + 'gui.extAgents.robot_disconnected': '未连接', + 'gui.extAgents.robot_unknown': '未知', + 'gui.extAgents.robot_error': '错误', + 'gui.extAgents.robot_sparkbot': 'SparkBot', + 'gui.extAgents.robot_http_device': 'HTTP 设备', + 'gui.extAgents.robot_dog': '机器狗', + 'gui.extAgents.robot_agv': 'AGV', + 'gui.extAgents.robot_mobile_manipulator': '轮臂', + 'gui.extAgents.robot_other': '其他设备', + 'gui.hub.robots': '机器人', + 'gui.page.robots_sub': '登记个人设备、读取状态;不安装执行节点,不提供机器人控制' } } as Record> diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 7c5c94112..4aa3a086c 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -3365,6 +3365,67 @@ export interface MachineRow { */ agents_off_here: string[]; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotObservation". + */ +export interface RobotObservation { + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + checked_at: string; + checked_epoch: number; + http_status: number | null; + detail: string; + observed: { + [k: string]: JsonValue; + }; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotRow". + */ +export interface RobotRow { + domain: 'personal'; + name: string; + type: 'sparkbot' | 'http_device' | 'robot_dog' | 'agv' | 'mobile_manipulator' | 'other'; + connection_mode: 'metadata' | 'http_status'; + model: string; + location: string; + note: string; + status_url: string; + expected_project: string; + id: string; + revision: number; + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + observation: { + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + checked_at: string; + checked_epoch: number; + http_status: number | null; + detail: string; + observed: { + [k: string]: JsonValue; + }; + } | null; + stale: boolean; + deleted_at: string | null; + updated_at: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotDraft". + */ +export interface RobotDraft { + domain: 'personal'; + confirm_personal: boolean; + name: string; + type: 'sparkbot' | 'http_device' | 'robot_dog' | 'agv' | 'mobile_manipulator' | 'other'; + connection_mode?: 'metadata' | 'http_status'; + model?: string; + location?: string; + note?: string; + status_url?: string; + expected_project?: string; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "SessionListParams". @@ -8532,6 +8593,79 @@ export interface ImportedUploadResult { */ imported_at: string; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsListParams". + */ +export interface RobotsListParams { + trash?: boolean; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsListResult". + */ +export interface RobotsListResult { + devices: RobotRow[]; + status_ttl_seconds: number; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsSaveParams". + */ +export interface RobotsSaveParams { + device: RobotDraft; + id?: string | null; + revision?: number | null; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsSaveResult". + */ +export interface RobotsSaveResult { + device: RobotRow; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRemoveParams". + */ +export interface RobotsRemoveParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRemoveResult". + */ +export interface RobotsRemoveResult { + ok: boolean; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRestoreParams". + */ +export interface RobotsRestoreParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRestoreResult". + */ +export interface RobotsRestoreResult { + device: RobotRow; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsCheckParams". + */ +export interface RobotsCheckParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsCheckResult". + */ +export interface RobotsCheckResult { + device: RobotRow; +} // ---- Schema-name aliases for structurally-deduplicated types ---- export type BrowserManageResult = StubResult; diff --git a/ui-web/CONTEXT.md b/ui-web/CONTEXT.md index b28ccfebb..db1b9e61e 100644 --- a/ui-web/CONTEXT.md +++ b/ui-web/CONTEXT.md @@ -1,3 +1,12 @@ +# Personal robots in the Connections hub + +The Robots tab is an `extAgents` island module reading the `robots` source seam. +Its records have no execution node and are not Machine registrations. The form +handles personal metadata and optional explicit health checks; it does not +offer robot control. Its local clock expires displayed observations without +polling the physical device. The runtime contract and storage boundary are +described in `../docs/robot-registration.md`. + # Web UI The browser front-end (`ui-web/`, React + TypeScript, one page). Renders the chat diff --git a/ui-web/scripts/__golden__/boot-live-noserver.txt b/ui-web/scripts/__golden__/boot-live-noserver.txt index 9c3924a30..820e40296 100644 --- a/ui-web/scripts/__golden__/boot-live-noserver.txt +++ b/ui-web/scripts/__golden__/boot-live-noserver.txt @@ -227,6 +227,7 @@ section#connectionsPage.page[data-open=false] button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/scripts/__golden__/boot-stub.txt b/ui-web/scripts/__golden__/boot-stub.txt index 9d5372908..44ccb8e9a 100644 --- a/ui-web/scripts/__golden__/boot-stub.txt +++ b/ui-web/scripts/__golden__/boot-stub.txt @@ -229,6 +229,7 @@ section#connectionsPage.page[data-open=false] button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/scripts/fixture-params.mjs b/ui-web/scripts/fixture-params.mjs index 74b359cef..8e7dace39 100644 --- a/ui-web/scripts/fixture-params.mjs +++ b/ui-web/scripts/fixture-params.mjs @@ -9,6 +9,12 @@ /** One call per method. Anything not named here is called with `{}`. */ export const PARAMS = { + 'robots.list': { trash: false }, + 'robots.save': { device: { domain: 'personal', confirm_personal: true, name: 'Fixture robot', type: 'sparkbot', connection_mode: 'http_status', status_url: 'http://10.0.0.2:8080/health' } }, + 'robots.remove': { id: '00000000000000000000000000000001' }, + 'robots.restore': { id: '00000000000000000000000000000001' }, + 'robots.check': { id: '00000000000000000000000000000001' }, + 'system.hello': { client_version: '0.1.0', surface: 'page' }, 'config.get': { keys: ['language', 'permissions.mode'] }, 'config.set': { key: 'language', value: 'en' }, diff --git a/ui-web/src/app/install.ts b/ui-web/src/app/install.ts index 14e1e42b5..bc565fd08 100644 --- a/ui-web/src/app/install.ts +++ b/ui-web/src/app/install.ts @@ -20,7 +20,7 @@ import { cronSource } from '../features/cron/source' import { openDeskTask } from '../features/desk/store' import { exportSource } from '../features/export/source' import { AgentsStepBody } from '../features/extAgents/AgentsBody' -import { extAgentsSource, machinesSource } from '../features/extAgents/source' +import { extAgentsSource, machinesSource, robotsSource } from '../features/extAgents/source' import * as extAgentsStore from '../features/extAgents/store' import { importedSource } from '../features/imported/source' import { importSyncSource } from '../features/importSync/source' @@ -250,6 +250,7 @@ export function installSources(): void { } sources.extAgents = extAgentsSource sources.machines = machinesSource + sources.robots = robotsSource sources.knowledge = knowledgeSource /* How a citation in the transcript is followed. Registered by the island that can do it rather than imported by the one that wants it: two domains diff --git a/ui-web/src/components/HubHead.test.tsx b/ui-web/src/components/HubHead.test.tsx index 8273f90ff..38875dd51 100644 --- a/ui-web/src/components/HubHead.test.tsx +++ b/ui-web/src/components/HubHead.test.tsx @@ -18,10 +18,10 @@ afterEach(() => { const tabs = (): HTMLButtonElement[] => [...document.querySelectorAll('.hub-head-tab')] describe('the hub header', () => { - it('names the three modules and marks the one on screen', () => { + it('names the four modules and marks the one on screen', () => { render() - expect(tabs().map((b) => b.textContent)).toEqual(['gui.hub.agents', 'gui.hub.channels', 'gui.hub.machines']) - expect(tabs().map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']) + expect(tabs().map((b) => b.textContent)).toEqual(['gui.hub.agents', 'gui.hub.channels', 'gui.hub.machines', 'gui.hub.robots']) + expect(tabs().map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false', 'false']) }) /* The line under the tabs belongs to the module on screen, not the hub. */ @@ -45,5 +45,7 @@ describe('the hub header', () => { expect(open).toHaveBeenCalledWith('channels') act(() => tabs()[2]!.click()) expect(open).toHaveBeenCalledWith('machines') + act(() => tabs()[3]!.click()) + expect(open).toHaveBeenCalledWith('robots') }) }) diff --git a/ui-web/src/components/HubHead.tsx b/ui-web/src/components/HubHead.tsx index 00f1772f8..a751fa0b0 100644 --- a/ui-web/src/components/HubHead.tsx +++ b/ui-web/src/components/HubHead.tsx @@ -1,7 +1,7 @@ /* The header the hub's modules share: the module tabs, and one line under them on what the module on screen is for. - Agents, channels and machines are one place on the rail (state/hub.ts), and + Agents, channels, machines and robot registrations are one place on the rail (state/hub.ts), and the rail row already names it, so the page carries no title of its own: the tabs are the top of the page. Underlined rather than filled, so they read as the page's parts and the filter pills a module draws under them stay the @@ -17,7 +17,8 @@ import type { JSX } from 'react' const MODULES: ReadonlyArray<{ which: HubModule; key: string; sub: string }> = [ { which: 'agents', key: 'gui.hub.agents', sub: 'gui.page.agents_sub' }, { which: 'channels', key: 'gui.hub.channels', sub: 'gui.page.conn_sub' }, - { which: 'machines', key: 'gui.hub.machines', sub: 'gui.page.machines_sub' } + { which: 'machines', key: 'gui.hub.machines', sub: 'gui.page.machines_sub' }, + { which: 'robots', key: 'gui.hub.robots', sub: 'gui.page.robots_sub' } ] export function HubHead({ current }: { current: HubModule }): JSX.Element { diff --git a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap index 21e56921b..a07055929 100644 --- a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap +++ b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap @@ -5,6 +5,7 @@ exports[`connections island > keeps its rendered shape, list 1`] = ` button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index 1e77b6545..59bb2421f 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -15,6 +15,7 @@ import { byOf, familyOf, installOf, isOwnRow } from './catalogue' import { badOf, healthOf, kindOf, ledClass, pendingLabel, refusedWrite, shownOf } from './health' import { MachineConnectSheet, MachineSheet, MachinesGrid, RegisterSheet, WhereField } from './MachineCards' import * as machines from './machines' +import { Robots } from './Robots' import { CardGrid, Spin, Tile, WaitGrid, connect, oneLine, ordered, refusedLabel, whyOf } from './Rows' import { gridEntries, hasEntry, sectionOf, stageOf } from './source' import * as store from './store' @@ -972,6 +973,7 @@ export function ExtAgentsApp(): JSX.Element { {s.picking ? : null} ) + if (s.module === 'robots') return <> if (s.module === 'machines') { /* Where the agents may run besides this computer: the registered machines, their links and nodes. The hub's own tab, beside the agents diff --git a/ui-web/src/features/extAgents/Robots.test.tsx b/ui-web/src/features/extAgents/Robots.test.tsx new file mode 100644 index 000000000..bc3e8ef92 --- /dev/null +++ b/ui-web/src/features/extAgents/Robots.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +/* Native-page robot registration against the typed offline RPC transport. */ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { setTranslator } from '../../i18n/t' +import { demoFixtures } from '../../rpc/fixtures' +import { FixtureTransport } from '../../rpc/fixtureTransport' +import { setGateway } from '../../rpc/gateway' +import { resetSources, setSources } from '../../state/sources' +import { mountPageRoot } from '../../test/pageRoot' +import { ExtAgentsApp } from './ExtAgentsPage' +import * as machines from './machines' +import { robotStatus } from './Robots' +import { extAgentsSource, machinesSource, robotsSource } from './source' +import * as store from './store' + +import type { RobotRow } from './types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let unmountPage: () => void +let transport: FixtureTransport + +beforeEach(() => { + setTranslator((key) => key) + transport = new FixtureTransport(demoFixtures) + setGateway(transport) + setSources({ robots: robotsSource, machines: machinesSource, extAgents: extAgentsSource }) + unmountPage = mountPageRoot() +}) + +afterEach(() => { + cleanup() + act(() => unmountPage()) + store._resetForTests() + machines._resetForTests() + resetSources() + setGateway(null) +}) + +async function open(): Promise { + render(, { container: document.getElementById('extAgentsBody')! }) + await act(async () => store.openRobots()) +} + +async function click(name: string): Promise { + await act(async () => fireEvent.click(screen.getByRole('button', { name }))) +} + +describe('personal robot registration', () => { + it('registers, edits, removes, and restores through the native source', async () => { + await open() + await click('gui.extAgents.robot_add') + const form = screen.getByRole('form', { name: 'gui.extAgents.robot_add' }) + fireEvent.change(within(form).getByLabelText('gui.extAgents.robot_name'), { target: { value: 'Fixture device' } }) + fireEvent.click(within(form).getByLabelText('gui.extAgents.robot_personal_confirm')) + await act(async () => fireEvent.submit(form)) + expect(screen.getByRole('article', { name: 'Fixture device' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'gui.extAgents.robot_check' })).toBeNull() + await click('gui.extAgents.robot_edit') + const edit = screen.getByRole('form', { name: 'gui.extAgents.robot_edit' }) + fireEvent.change(within(edit).getByLabelText('gui.extAgents.robot_name'), { target: { value: 'Edited fixture device' } }) + await act(async () => fireEvent.submit(edit)) + expect(screen.getByRole('article', { name: 'Edited fixture device' })).toBeTruthy() + await click('gui.extAgents.robot_remove') + expect(screen.queryByRole('article')).toBeNull() + await click('gui.extAgents.robot_trash') + await click('gui.extAgents.robot_restore') + await click('gui.extAgents.robot_back') + expect(screen.getByRole('article', { name: 'Edited fixture device' })).toBeTruthy() + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(0) + expect((await robotsSource.list(false))).toHaveLength(1) + }) + + it('blocks company registration and limits non-HTTP robots to metadata', async () => { + await open() + await click('gui.extAgents.robot_add') + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_domain'), { target: { value: 'company' } }) + expect(screen.getByRole('button', { name: 'gui.save' }).disabled).toBe(true) + expect(screen.queryByLabelText('gui.extAgents.robot_name')).toBeNull() + expect(screen.getByRole('alert').textContent).toBe('gui.extAgents.robot_company_blocked') + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_domain'), { target: { value: 'personal' } }) + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_type'), { target: { value: 'agv' } }) + expect(screen.getByRole('option', { name: 'gui.extAgents.robot_http' }).disabled).toBe(true) + expect(screen.queryByLabelText('gui.extAgents.robot_status_url')).toBeNull() + expect(transport.calls.filter((call) => call.method === 'robots.save')).toHaveLength(0) + }) + + it('checks HTTP status only on demand and clears the endpoint when changed to metadata', async () => { + await robotsSource.save({ domain: 'personal', confirm_personal: true, name: 'HTTP fixture', type: 'sparkbot', connection_mode: 'http_status', status_url: 'http://10.0.0.2:8080/health' }) + await open() + expect(screen.getByRole('article', { name: 'HTTP fixture' }).textContent).toContain('gui.extAgents.robot_disconnected') + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(0) + await click('gui.extAgents.robot_check') + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(1) + expect(screen.getByRole('article', { name: 'HTTP fixture' }).textContent).toContain('Offline fixtures never contact physical devices.') + await click('gui.extAgents.robot_edit') + expect(screen.getByRole('button', { name: 'gui.extAgents.robot_trash' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'gui.extAgents.robot_check' }).disabled).toBe(true) + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_type'), { target: { value: 'agv' } }) + expect(screen.queryByLabelText('gui.extAgents.robot_status_url')).toBeNull() + const form = screen.getByRole('form', { name: 'gui.extAgents.robot_edit' }) + await act(async () => fireEvent.submit(form)) + const record = (await robotsSource.list(false))[0]! + expect(record.connection_mode).toBe('metadata') + expect(record.status_url).toBe('') + expect(record.observation).toBeNull() + expect(screen.queryByRole('button', { name: 'gui.extAgents.robot_check' })).toBeNull() + }) + + it('degrades to an explicit unavailable state on an older gateway', async () => { + setGateway(new FixtureTransport({})) + await open() + expect(screen.getByRole('status').textContent).toBe('gui.extAgents.robot_unavailable') + expect(screen.queryByRole('article')).toBeNull() + }) + + it('expires successful observations without another network check', () => { + const row: RobotRow = { + id: 'a'.repeat(32), revision: 1, domain: 'personal', name: 'Fixture', type: 'sparkbot', connection_mode: 'http_status', + model: '', location: '', note: '', status_url: '', expected_project: '', updated_at: '', deleted_at: null, stale: false, status: 'reachable', + observation: { status: 'reachable', checked_epoch: 100, checked_at: '', http_status: 200, detail: '', observed: {} }, + } + expect(robotStatus(row, 110_000)).toBe('reachable') + expect(robotStatus(row, 161_000)).toBe('unknown') + expect(robotStatus(row, 94_000)).toBe('unknown') + }) +}) diff --git a/ui-web/src/features/extAgents/Robots.tsx b/ui-web/src/features/extAgents/Robots.tsx new file mode 100644 index 000000000..950a1e39e --- /dev/null +++ b/ui-web/src/features/extAgents/Robots.tsx @@ -0,0 +1,191 @@ +/* Personal robot records and explicit health checks, without an execution node. */ +import { useEffect, useRef, useState } from 'react' + +import { t } from '../../i18n/t' +import { ds } from '../../state/sources' + +import type { RobotDraft, RobotRow } from './types' +import type { JSX } from 'react' + +const TYPE_KEYS = { + sparkbot: 'gui.extAgents.robot_sparkbot', + http_device: 'gui.extAgents.robot_http_device', + robot_dog: 'gui.extAgents.robot_dog', + agv: 'gui.extAgents.robot_agv', + mobile_manipulator: 'gui.extAgents.robot_mobile_manipulator', + other: 'gui.extAgents.robot_other', +} satisfies Record + +const STATUS_KEYS = { + reachable: 'gui.extAgents.robot_reachable', + not_connected: 'gui.extAgents.robot_disconnected', + unknown: 'gui.extAgents.robot_unknown', + error: 'gui.extAgents.robot_error', +} + +export function robotStatus(row: RobotRow, now: number): RobotRow['status'] { + const age = row.observation ? now / 1000 - row.observation.checked_epoch : null + return age !== null && (age > 60 || age < -5) ? 'unknown' : row.status +} + +function RobotForm({ row, saved, close }: { row: RobotRow | null; saved: () => Promise; close: () => void }): JSX.Element { + const form = useRef(null) + const [kind, setKind] = useState(row?.type ?? 'sparkbot') + const [mode, setMode] = useState(row?.connection_mode ?? 'metadata') + const [company, setCompany] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const httpAllowed = kind === 'sparkbot' || kind === 'http_device' + const http = httpAllowed && mode === 'http_status' + return ( +
{ + event.preventDefault() + if (company || busy || !form.current) return + const data = new FormData(form.current) + const text = (key: string): string => String(data.get(key) ?? '') + const device: RobotDraft = { + domain: 'personal', confirm_personal: data.get('confirm_personal') === 'on', + name: text('name'), type: kind, connection_mode: http ? 'http_status' : 'metadata', + model: text('model'), location: text('location'), note: text('note'), + status_url: http ? text('status_url') : '', expected_project: http ? text('expected_project') : '', + } + setBusy(true) + setError('') + void ds('robots').save(device, row?.id, row?.revision).then(saved).catch(() => { + setError(t('gui.extAgents.robot_save_failed')) + }).finally(() => setBusy(false)) + }} + ref={form} + > +

{t(row ? 'gui.extAgents.robot_edit' : 'gui.extAgents.robot_add')}

+ + {company ?

{t('gui.extAgents.robot_company_blocked')}

: ( +
+
+ + + + +
+ + {!httpAllowed ?

{t('gui.extAgents.robot_metadata_only')}

: null} + {http ?
+ + +

{t('gui.extAgents.robot_url_hint')}

+ +
: null} +