This commit is contained in:
@@ -56,6 +56,12 @@ class AssignmentExecutor:
|
||||
should_stop: Callable[[], bool] | None,
|
||||
) -> AssignmentExecutionResult:
|
||||
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
||||
if self.factories.metadata_store is not None:
|
||||
self.factories.metadata_store.create_task(
|
||||
task,
|
||||
source_task_id=assignment.task_id,
|
||||
source_attempt=assignment.attempt,
|
||||
)
|
||||
runner = self.factories.task_runner_factory()
|
||||
runner.on_step_progress = self._progress.update
|
||||
if should_stop is None:
|
||||
|
||||
@@ -13,6 +13,11 @@ class HostAgentConfigurationError(ValueError):
|
||||
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
|
||||
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
|
||||
"HOST_AGENT_RUNTIME_SUPERVISED",
|
||||
"HOST_AGENT_RUNTIME_HOST",
|
||||
"HOST_AGENT_RUNTIME_PORT",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -39,9 +44,6 @@ class HostAgentConfig:
|
||||
appium_supervised: bool = False
|
||||
appium_host: str = "127.0.0.1"
|
||||
appium_port: int = 4723
|
||||
runtime_supervised: bool = False
|
||||
runtime_host: str = "127.0.0.1"
|
||||
runtime_port: int = 8000
|
||||
dependency_restart_max_attempts: int = 5
|
||||
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
|
||||
task_artifact_dir: Path = Path("host_agent_data/history")
|
||||
@@ -54,6 +56,7 @@ def load_host_agent_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> HostAgentConfig:
|
||||
values = os.environ if env is None else env
|
||||
_reject_removed_runtime_supervision_settings(values)
|
||||
control_plane_url = (
|
||||
values.get(
|
||||
"HOST_AGENT_CONTROL_PLANE_URL",
|
||||
@@ -134,9 +137,6 @@ def load_host_agent_config(
|
||||
appium_supervised=_truthy(values, "HOST_AGENT_APPIUM_SUPERVISED", False),
|
||||
appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
|
||||
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
|
||||
runtime_supervised=_truthy(values, "HOST_AGENT_RUNTIME_SUPERVISED", False),
|
||||
runtime_host=values.get("HOST_AGENT_RUNTIME_HOST", "127.0.0.1").strip(),
|
||||
runtime_port=_positive_int(values, "HOST_AGENT_RUNTIME_PORT", 8000),
|
||||
dependency_restart_max_attempts=_positive_int(
|
||||
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
|
||||
),
|
||||
@@ -177,6 +177,20 @@ def load_host_agent_config(
|
||||
return config
|
||||
|
||||
|
||||
def _reject_removed_runtime_supervision_settings(values: Mapping[str, str]) -> None:
|
||||
configured = [
|
||||
setting
|
||||
for setting in _REMOVED_RUNTIME_SUPERVISION_SETTINGS
|
||||
if setting in values
|
||||
]
|
||||
if configured:
|
||||
raise HostAgentConfigurationError(
|
||||
f"{', '.join(configured)} has been removed with the standalone "
|
||||
"Runtime service. Use the Host Agent console for task evidence "
|
||||
"and HOST_AGENT_APPIUM_SUPERVISED for optional Appium supervision."
|
||||
)
|
||||
|
||||
|
||||
def _parse_ai_planner_transport(value: str | None) -> str:
|
||||
if value is None:
|
||||
return "cloud"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Optional supervisor for the two local external processes the Host Agent
|
||||
depends on for the macOS single-machine real-device workflow: the Appium
|
||||
server (which gates real ``Driver.connect()``) and the local Runtime API
|
||||
(used for local inspection).
|
||||
"""Optional supervisor for Appium in the macOS single-machine real-device
|
||||
workflow. Appium gates real driver connections; task inspection is provided by
|
||||
the Host Agent's own console.
|
||||
|
||||
Lives in ``host_agent`` because it spawns and monitors host-level processes
|
||||
alongside the heartbeat/claim loop. Off by default; see ``HostAgentConfig``.
|
||||
@@ -56,18 +55,6 @@ def probe_appium(host: str, port: int) -> ProbeResult:
|
||||
return _probe_http(host, port, path="/status")
|
||||
|
||||
|
||||
def probe_runtime(host: str, port: int) -> ProbeResult:
|
||||
"""Probe the local Runtime API at ``host:port``. Healthy iff ``GET /devices``
|
||||
returns 200 with a JSON body.
|
||||
|
||||
``api.rest.create_app`` does not expose a dedicated ``/health`` endpoint;
|
||||
``/devices`` is the stable read-only GET that proves the FastAPI app is
|
||||
mounted and the device manager is reachable. Per design.md Decision 2 this
|
||||
is the "equivalent existing endpoint" used for the readiness check.
|
||||
"""
|
||||
return _probe_http(host, port, path="/devices")
|
||||
|
||||
|
||||
def _probe_http(host: str, port: int, *, path: str) -> ProbeResult:
|
||||
# Step 1: plain TCP connect — distinguish "nothing listening" (→ spawn)
|
||||
# from "something is there but wrong" (→ port conflict, skip).
|
||||
@@ -98,18 +85,6 @@ def appium_argv_factory(host: str, port: int) -> list[str]:
|
||||
return ["appium", "--address", host, "--port", str(port)]
|
||||
|
||||
|
||||
def runtime_argv_factory(host: str, port: int) -> list[str]:
|
||||
return [
|
||||
"uvicorn",
|
||||
"api.rest:create_app",
|
||||
"--factory",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupervisedDependency:
|
||||
"""Config + mutable runtime state for one supervised external process."""
|
||||
@@ -190,16 +165,6 @@ class DependencySupervisor:
|
||||
probe=probe_appium,
|
||||
)
|
||||
)
|
||||
if ha_config.runtime_supervised:
|
||||
deps.append(
|
||||
SupervisedDependency(
|
||||
name="runtime",
|
||||
host=ha_config.runtime_host,
|
||||
port=ha_config.runtime_port,
|
||||
argv_factory=runtime_argv_factory,
|
||||
probe=probe_runtime,
|
||||
)
|
||||
)
|
||||
return cls(
|
||||
deps,
|
||||
max_attempts=ha_config.dependency_restart_max_attempts,
|
||||
|
||||
@@ -25,6 +25,7 @@ class ExecutionFactories:
|
||||
task_runner_factory: Callable[[], TaskRunner]
|
||||
workflow_runner_factory: Callable[[], WorkflowRunner]
|
||||
workflow_store: WorkflowStore
|
||||
metadata_store: TaskMetadataStore | None = None
|
||||
|
||||
|
||||
def create_execution_factories(
|
||||
@@ -61,6 +62,7 @@ def create_execution_factories(
|
||||
task_runner_factory=create_task_runner,
|
||||
workflow_runner_factory=create_workflow_runner,
|
||||
workflow_store=shared_workflow_store,
|
||||
metadata_store=metadata_store,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,9 +70,13 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
|
||||
return device.status
|
||||
|
||||
|
||||
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
|
||||
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
|
||||
screenshot_path = record.get("screenshot_path")
|
||||
def _screenshot_data_uri(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
path_key: str = "screenshot_path",
|
||||
) -> str | None:
|
||||
"""Return a ``data:`` URI for one step screenshot, or ``None``."""
|
||||
screenshot_path = record.get(path_key)
|
||||
if not screenshot_path:
|
||||
return None
|
||||
path = Path(str(screenshot_path))
|
||||
@@ -82,6 +86,49 @@ def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw_results = record.get("ocr_results")
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
return [result for result in raw_results if isinstance(result, dict)]
|
||||
|
||||
|
||||
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
tool_call = record.get("tool_call")
|
||||
if not isinstance(tool_call, dict):
|
||||
return []
|
||||
if tool_call.get("action") not in {"get_ui_tree", "ui_tree"}:
|
||||
return []
|
||||
step_result = record.get("result")
|
||||
if not isinstance(step_result, dict):
|
||||
return []
|
||||
raw_nodes = step_result.get("result")
|
||||
if not isinstance(raw_nodes, list):
|
||||
return []
|
||||
return [node for node in raw_nodes if isinstance(node, dict)]
|
||||
|
||||
|
||||
def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_call = record.get("tool_call")
|
||||
result = record.get("result")
|
||||
return {
|
||||
"index": record.get("index", ""),
|
||||
"timestamp": record.get("timestamp", ""),
|
||||
"prompt": record.get("prompt") or "",
|
||||
"tool_call": tool_call if isinstance(tool_call, dict) else {},
|
||||
"result": result if isinstance(result, dict) else {},
|
||||
"before_screenshot_src": _screenshot_data_uri(
|
||||
record, path_key="before_screenshot_path"
|
||||
),
|
||||
"after_screenshot_src": _screenshot_data_uri(
|
||||
record, path_key="after_screenshot_path"
|
||||
)
|
||||
or _screenshot_data_uri(record),
|
||||
"ocr_results": _ocr_results(record),
|
||||
"ui_tree_nodes": _ui_tree_nodes(record),
|
||||
}
|
||||
|
||||
|
||||
def _safe_submission_error(detail: str) -> str:
|
||||
"""Return a safe, single-line error message for the operator.
|
||||
|
||||
@@ -525,7 +572,7 @@ def create_console_app(
|
||||
if task_id:
|
||||
notice = (
|
||||
f"Task submitted. Cloud task ID: {task_id}. "
|
||||
"Track it from the Cloud console for execution progress."
|
||||
"It will appear below when this Host begins executing it."
|
||||
)
|
||||
elif request.query_params.get("outcome") == "unknown":
|
||||
unknown = True
|
||||
@@ -664,36 +711,22 @@ def create_console_app(
|
||||
if timeline is not None:
|
||||
timeline_records = await asyncio.to_thread(timeline.read, task_id)
|
||||
task_rows = [
|
||||
(key, task[key])
|
||||
for key in (
|
||||
"id",
|
||||
"goal",
|
||||
"device_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
(label, task[key])
|
||||
for key, label in (
|
||||
("source_task_id", "Cloud task ID"),
|
||||
("source_attempt", "Cloud attempt"),
|
||||
("id", "Execution ID"),
|
||||
("goal", "Goal"),
|
||||
("device_id", "Device"),
|
||||
("status", "Status"),
|
||||
("created_at", "Created"),
|
||||
("updated_at", "Updated"),
|
||||
("completed_at", "Completed"),
|
||||
("failure_reason", "Failure reason"),
|
||||
)
|
||||
if task.get(key) is not None
|
||||
]
|
||||
timeline_steps = [
|
||||
{
|
||||
"index": record.get("index", ""),
|
||||
"timestamp": record.get("timestamp", ""),
|
||||
"prompt": record.get("prompt") or "",
|
||||
"tool_call_text": (
|
||||
json.dumps(record.get("tool_call"), ensure_ascii=False)
|
||||
if record.get("tool_call")
|
||||
else ""
|
||||
),
|
||||
"result_text": (
|
||||
json.dumps(record.get("result"), ensure_ascii=False)
|
||||
if record.get("result")
|
||||
else ""
|
||||
),
|
||||
"screenshot_src": _screenshot_data_uri(record),
|
||||
}
|
||||
for record in timeline_records
|
||||
]
|
||||
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
|
||||
return _render(
|
||||
"task_detail.html",
|
||||
title=f"Task {task_id}",
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
.task-back { margin-top: 0; }
|
||||
.timeline-step { border: 1px solid #c8d0d6; background: #fff; padding: 1rem; margin-bottom: 1rem; }
|
||||
.step-heading { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin-bottom: 0.75rem; }
|
||||
.step-heading p { margin: 0; color: #4d5a63; }
|
||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.evidence-pane { margin: 0; min-width: 0; }
|
||||
.evidence-pane h3 { font-size: 1rem; margin: 0 0 0.35rem; }
|
||||
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; }
|
||||
.screenshot-frame img { display: block; width: 100%; height: auto; }
|
||||
.step-details { margin-top: 0.75rem; }
|
||||
.step-details summary { cursor: pointer; font-weight: 600; }
|
||||
.step-details pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.65rem 0 0; padding: 0.65rem; border: 1px solid #d5dce0; background: #f8fafb; }
|
||||
.operation-grid { display: grid; grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); gap: 0.35rem 0.75rem; margin: 0.65rem 0 0; }
|
||||
.operation-grid dt { font-weight: 600; }
|
||||
.operation-grid dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.observation-list, .ui-tree-nodes { margin: 0.65rem 0 0; padding-left: 1.25rem; }
|
||||
.observation-list li, .ui-tree-nodes li { margin-bottom: 0.45rem; overflow-wrap: anywhere; }
|
||||
.observation-list span, .ui-tree-nodes span { color: #4d5a63; margin-left: 0.4rem; }
|
||||
.ui-tree-nodes code { overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) {
|
||||
.evidence-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.timeline-step { padding: 0.75rem; }
|
||||
}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Task {{ task.get("id") or "" }}</h1>
|
||||
<p class="task-back"><a href="/tasks">← Back to executions</a></p>
|
||||
<h1>Execution</h1>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
|
||||
@@ -10,13 +37,81 @@
|
||||
<p>No timeline records.</p>
|
||||
{% else %}
|
||||
{% for step in timeline_steps %}
|
||||
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
|
||||
<p><strong>Step {{ step.index }}</strong> — {{ step.timestamp }}</p>
|
||||
<p>Prompt: {{ step.prompt }}</p>
|
||||
<p>Tool call: <code>{{ step.tool_call_text }}</code></p>
|
||||
<p>Result: <code>{{ step.result_text }}</code></p>
|
||||
{% if step.screenshot_src %}<img src="{{ step.screenshot_src }}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">{% endif %}
|
||||
</div>
|
||||
<section class="timeline-step">
|
||||
<div class="step-heading">
|
||||
<strong>Step {{ step.index }}</strong>
|
||||
<p>{{ step.timestamp }}</p>
|
||||
</div>
|
||||
<div class="evidence-grid">
|
||||
<figure class="evidence-pane">
|
||||
<h3>Before action</h3>
|
||||
<div class="screenshot-frame">
|
||||
{% if step.before_screenshot_src %}
|
||||
<img src="{{ step.before_screenshot_src }}" alt="Screenshot before action">
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</figure>
|
||||
<figure class="evidence-pane">
|
||||
<h3>After action</h3>
|
||||
<div class="screenshot-frame">
|
||||
{% if step.after_screenshot_src %}
|
||||
<img src="{{ step.after_screenshot_src }}" alt="Screenshot after action">
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
<details class="step-details" open>
|
||||
<summary>Operation</summary>
|
||||
<dl class="operation-grid">
|
||||
<dt>Action</dt><dd>{{ step.tool_call.get("action") or "-" }}</dd>
|
||||
<dt>Description</dt><dd>{{ step.tool_call.get("description") or "-" }}</dd>
|
||||
</dl>
|
||||
<pre>{{ step.tool_call | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
<details class="step-details">
|
||||
<summary>Result</summary>
|
||||
<pre>{{ step.result | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
{% if step.prompt %}
|
||||
<details class="step-details">
|
||||
<summary>Planner prompt</summary>
|
||||
<pre>{{ step.prompt }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if step.ocr_results %}
|
||||
<details class="step-details" open>
|
||||
<summary>OCR results ({{ step.ocr_results|length }})</summary>
|
||||
<ul class="observation-list">
|
||||
{% for ocr in step.ocr_results %}
|
||||
<li>
|
||||
<strong>{{ ocr.get("text") or "-" }}</strong>
|
||||
<span>{{ ocr.get("bounds") | tojson }}</span>
|
||||
{% if ocr.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(ocr.get("confidence")) }}</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if step.ui_tree_nodes %}
|
||||
<details class="step-details">
|
||||
<summary>UI tree ({{ step.ui_tree_nodes|length }} normalized nodes)</summary>
|
||||
<ul class="ui-tree-nodes">
|
||||
{% for node in step.ui_tree_nodes %}
|
||||
<li>
|
||||
<strong>{{ node.get("type") or "unknown" }}</strong>
|
||||
<span>{{ node.get("text") or node.get("id") or "-" }}</span>
|
||||
<code>{{ node.get("bounds") | tojson }}</code>
|
||||
{% if node.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(node.get("confidence")) }}</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,17 +29,19 @@
|
||||
</section>
|
||||
|
||||
<section id="local-tasks">
|
||||
<h2>Local Runtime tasks</h2>
|
||||
<h2>Executed tasks on this Host</h2>
|
||||
{% if metadata_store_missing %}
|
||||
<p class="error">Task metadata store is not configured.</p>
|
||||
{% elif not tasks %}
|
||||
<p>No tasks recorded.</p>
|
||||
<p>No executions recorded yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Execution ID</th><th>Cloud task</th><th>Attempt</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<tbody>{% for task in tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
|
||||
<td>{{ task.get("source_task_id") or "" }}</td>
|
||||
<td>{{ task.get("source_attempt") if task.get("source_attempt") is not none else "" }}</td>
|
||||
<td>{{ task.get("status") or "" }}</td>
|
||||
<td>{{ task.get("device_id") or "" }}</td>
|
||||
<td>{{ task.get("created_at") or "" }}</td>
|
||||
@@ -49,4 +51,4 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,6 +25,7 @@ def _assignment(**overrides) -> AssignmentModel:
|
||||
|
||||
def test_goal_assignment_executes_through_task_runner() -> None:
|
||||
received: list[Task] = []
|
||||
created: list[tuple[str, str | None, int | None]] = []
|
||||
|
||||
class FakeTaskRunner:
|
||||
def run(self, task: Task) -> Task:
|
||||
@@ -32,10 +33,21 @@ def test_goal_assignment_executes_through_task_runner() -> None:
|
||||
task.status = "completed"
|
||||
return task
|
||||
|
||||
class FakeMetadataStore:
|
||||
def create_task(
|
||||
self,
|
||||
task: Task,
|
||||
*,
|
||||
source_task_id: str | None = None,
|
||||
source_attempt: int | None = None,
|
||||
) -> None:
|
||||
created.append((task.id, source_task_id, source_attempt))
|
||||
|
||||
factories = ExecutionFactories(
|
||||
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
|
||||
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
|
||||
workflow_store=object(), # type: ignore[arg-type]
|
||||
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = AssignmentExecutor(factories).execute(_assignment())
|
||||
@@ -44,6 +56,7 @@ def test_goal_assignment_executes_through_task_runner() -> None:
|
||||
assert received[0].goal == "open settings"
|
||||
assert received[0].device_id == "device-a"
|
||||
assert result.metadata["runtime_task_id"] == received[0].id
|
||||
assert created == [(received[0].id, "cloud-task", 1)]
|
||||
|
||||
|
||||
def test_goal_assignment_preserves_runtime_failure_reason() -> None:
|
||||
|
||||
@@ -191,9 +191,6 @@ def test_dependency_supervisor_defaults_to_disabled() -> None:
|
||||
assert config.appium_supervised is False
|
||||
assert config.appium_host == "127.0.0.1"
|
||||
assert config.appium_port == 4723
|
||||
assert config.runtime_supervised is False
|
||||
assert config.runtime_host == "127.0.0.1"
|
||||
assert config.runtime_port == 8000
|
||||
assert config.dependency_restart_max_attempts == 5
|
||||
|
||||
|
||||
@@ -204,9 +201,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
|
||||
"HOST_AGENT_APPIUM_SUPERVISED": "1",
|
||||
"HOST_AGENT_APPIUM_HOST": "0.0.0.0",
|
||||
"HOST_AGENT_APPIUM_PORT": "4724",
|
||||
"HOST_AGENT_RUNTIME_SUPERVISED": "true",
|
||||
"HOST_AGENT_RUNTIME_HOST": "localhost",
|
||||
"HOST_AGENT_RUNTIME_PORT": "8001",
|
||||
"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "8",
|
||||
}
|
||||
)
|
||||
@@ -215,9 +209,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
|
||||
assert config.appium_supervised is True
|
||||
assert config.appium_host == "0.0.0.0"
|
||||
assert config.appium_port == 4724
|
||||
assert config.runtime_supervised is True
|
||||
assert config.runtime_host == "localhost"
|
||||
assert config.runtime_port == 8001
|
||||
assert config.dependency_restart_max_attempts == 8
|
||||
|
||||
|
||||
@@ -225,7 +216,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
|
||||
"overrides",
|
||||
[
|
||||
{"HOST_AGENT_APPIUM_PORT": "0"},
|
||||
{"HOST_AGENT_RUNTIME_PORT": "not-a-number"},
|
||||
{"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "-1"},
|
||||
],
|
||||
)
|
||||
@@ -234,3 +224,19 @@ def test_dependency_supervisor_numeric_fields_reject_invalid_values(
|
||||
) -> None:
|
||||
with pytest.raises(HostAgentConfigurationError):
|
||||
load_host_agent_config(overrides)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setting,value",
|
||||
[
|
||||
("HOST_AGENT_RUNTIME_SUPERVISED", "true"),
|
||||
("HOST_AGENT_RUNTIME_HOST", "127.0.0.1"),
|
||||
("HOST_AGENT_RUNTIME_PORT", "8000"),
|
||||
],
|
||||
)
|
||||
def test_removed_runtime_supervision_settings_are_rejected(
|
||||
setting: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
with pytest.raises(HostAgentConfigurationError, match="standalone Runtime service"):
|
||||
load_host_agent_config({setting: value})
|
||||
|
||||
@@ -16,8 +16,6 @@ from host_agent.dependency_supervisor import (
|
||||
_SupervisorKnobs,
|
||||
appium_argv_factory,
|
||||
probe_appium,
|
||||
probe_runtime,
|
||||
runtime_argv_factory,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
|
||||
@@ -175,7 +173,6 @@ def test_probe_returns_no_listener_when_port_is_closed(monkeypatch) -> None:
|
||||
_raise_connection_refused,
|
||||
)
|
||||
assert probe_appium("127.0.0.1", 4723) is ProbeResult.NO_LISTENER
|
||||
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.NO_LISTENER
|
||||
|
||||
|
||||
def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
|
||||
@@ -190,18 +187,6 @@ def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
|
||||
assert probe_appium("127.0.0.1", 4723) is ProbeResult.HEALTHY
|
||||
|
||||
|
||||
def test_probe_returns_healthy_on_runtime_devices_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"host_agent.dependency_supervisor.socket.create_connection",
|
||||
_ok_connection,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"host_agent.dependency_supervisor.httpx.get",
|
||||
lambda url, timeout=2.0: httpx.Response(200, json=[]),
|
||||
)
|
||||
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.HEALTHY
|
||||
|
||||
|
||||
def test_probe_returns_unhealthy_when_listener_returns_non_200(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"host_agent.dependency_supervisor.socket.create_connection",
|
||||
@@ -223,7 +208,7 @@ def test_probe_returns_unhealthy_when_listener_returns_non_json(monkeypatch) ->
|
||||
"host_agent.dependency_supervisor.httpx.get",
|
||||
lambda url, timeout=2.0: httpx.Response(200, text="not json"),
|
||||
)
|
||||
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.UNHEALTHY_LISTENER
|
||||
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
|
||||
|
||||
|
||||
def test_probe_returns_unhealthy_on_http_transport_error(monkeypatch) -> None:
|
||||
@@ -335,38 +320,6 @@ def test_spawn_uses_appium_argv_factory() -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_spawn_uses_runtime_argv_factory() -> None:
|
||||
async def scenario() -> None:
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def recording_popen(argv, **kwargs):
|
||||
captured.append(list(argv))
|
||||
return _FakePopen(argv)
|
||||
|
||||
dep, _ = _dep(
|
||||
name="runtime",
|
||||
port=8000,
|
||||
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
|
||||
argv_factory=runtime_argv_factory,
|
||||
)
|
||||
sup, _ = _build_supervisor([dep], popen_factory=recording_popen)
|
||||
await sup.start()
|
||||
|
||||
assert captured == [
|
||||
[
|
||||
"uvicorn",
|
||||
"api.rest:create_app",
|
||||
"--factory",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"8000",
|
||||
]
|
||||
]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_readiness_timeout_leaves_process_running_without_restart_loop() -> None:
|
||||
async def scenario() -> None:
|
||||
# NO_LISTENER for initial probe; probe never becomes HEALTHY → startup
|
||||
@@ -529,7 +482,7 @@ def test_from_host_agent_config_builds_empty_supervisor_when_no_dep_selected() -
|
||||
assert sup.dependencies == []
|
||||
|
||||
|
||||
def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> None:
|
||||
def test_from_host_agent_config_includes_appium_when_selected() -> None:
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
@@ -538,17 +491,12 @@ def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> N
|
||||
appium_supervised=True,
|
||||
appium_host="10.0.0.5",
|
||||
appium_port=4724,
|
||||
runtime_supervised=True,
|
||||
runtime_host="10.0.0.5",
|
||||
runtime_port=8001,
|
||||
dependency_restart_max_attempts=7,
|
||||
)
|
||||
sup = DependencySupervisor.from_host_agent_config(config)
|
||||
names = [dep.name for dep in sup.dependencies]
|
||||
assert names == ["appium", "runtime"]
|
||||
assert names == ["appium"]
|
||||
appium = sup.dependencies[0]
|
||||
assert appium.host == "10.0.0.5"
|
||||
assert appium.port == 4724
|
||||
runtime = sup.dependencies[1]
|
||||
assert runtime.port == 8001
|
||||
assert sup._max_attempts == 7
|
||||
|
||||
Reference in New Issue
Block a user