feat: release 2.2.0 with PC-only tests and execution reliability fixes
Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
1 parent
fac4ccb760
commit
be6e8c2abd
79 files changed
+2263
-277
No files matched your search
Binary file not shown.
@@ -10,8 +10,8 @@ android {
|
||||
applicationId = "com.artemis.helper"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 8
|
||||
versionName = "1.2.2"
|
||||
versionCode = 9
|
||||
versionName = "1.2.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
+7
-1
@@ -796,10 +796,16 @@ public final class HierarchyDumper {
|
||||
for (RawRootEntry entry : roots) {
|
||||
try {
|
||||
AccessibilityNodeInfo focused = entry.root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT);
|
||||
if (focused != null) {
|
||||
// The IME window can have a focused, non-editable container
|
||||
// ahead of the app's focused field in window layer order.
|
||||
if (focused != null && focused.isEditable()
|
||||
&& focused.isEnabled() && focused.isVisibleToUser()) {
|
||||
found = focused;
|
||||
return found;
|
||||
}
|
||||
if (focused != null && focused != entry.root) {
|
||||
safeRecycle(focused);
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
for (RawRootEntry entry : roots) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"package": "com.artemis.helper",
|
||||
"version_code": 8,
|
||||
"version_name": "1.2.2",
|
||||
"sha256": "1d2b87f4bde9a4022a923519656b9229cedc6258830149842ac33be768866bb4",
|
||||
"built_at": "2026-10-25T09:57:42+00:00"
|
||||
"version_code": 9,
|
||||
"version_name": "1.2.3",
|
||||
"sha256": "f25f4b6b173049def6601953d8aa43779f3e5c5775d9a74f46dff7e93b731123",
|
||||
"built_at": "2026-10-26T13:49:10+00:00"
|
||||
}
|
||||
@@ -23,6 +23,10 @@ normalization, and result models. Device discovery, task scheduling, concurrency
|
||||
control, model selection, execution, logs, videos, and artifact generation all
|
||||
belong to the server.
|
||||
|
||||
Artemis 2.2.0 executes one task at a time across computer and Android targets.
|
||||
Legacy concurrency options remain accepted for compatibility, but cannot enable
|
||||
parallel execution. The client does not implement a separate scheduler.
|
||||
|
||||
The distribution is named `artemis-client`, while the Python import is
|
||||
`artemis_client`. This prevents it from shadowing the full runtime's top-level
|
||||
`artemis` package. The full runtime can depend on this package later and
|
||||
@@ -39,6 +43,12 @@ session database, the client reconstructs its state from `queue` and
|
||||
Experimental parameters live in `options`. Older servers ignore unknown fields,
|
||||
while newer servers can consume them without requiring an immediate SDK release.
|
||||
|
||||
On the current host, `options` maps to the shared `runtime_overrides` TaskOptions
|
||||
object rather than an unknown top-level `options` field. The first-class
|
||||
`execution_target` argument is serialized there too. `computer` omits saved
|
||||
Android serials; the host owns plugin selection, desktop observation, resource
|
||||
locks and execution. The client never discovers Android for a PC-only submission.
|
||||
|
||||
### Guided exploration in 2.0
|
||||
|
||||
`ArtemisClient.exploration()` uses the existing `/api/explorations` API shared
|
||||
|
||||
@@ -7,6 +7,9 @@ ADB, device-driver, agent, LLM, or image-processing code.
|
||||
Runtime dependencies: **zero**. HTTP, JSON, and asynchronous adapters use only
|
||||
the Python standard library.
|
||||
|
||||
Artemis 2.2.0 queues computer and Android tasks for serial execution. Legacy
|
||||
concurrency options cannot enable parallel execution on that server version.
|
||||
|
||||
## Installation
|
||||
|
||||
Install from a local checkout:
|
||||
@@ -44,6 +47,26 @@ and bearer-token authentication and exposes only task-related APIs.
|
||||
|
||||
## Usage
|
||||
|
||||
PC-only tasks do not need Android or an emulator online. Enable and authorize a
|
||||
computer observation plugin on the host first, then use the same target as the
|
||||
web launcher's “仅 PC” checkbox:
|
||||
|
||||
```python
|
||||
handle = await client.submit(
|
||||
"Open the desktop browser and inspect the requested page",
|
||||
profile="flash", # "pro" is also supported
|
||||
execution_target="computer",
|
||||
)
|
||||
```
|
||||
|
||||
`run()` accepts the same argument. For task-like objects, use
|
||||
`Task(goal="Check desktop", execution_target="computer")` from the full runtime
|
||||
and pass it to `client.run_task()`. A saved Android device on the client is ignored
|
||||
for PC-only tasks. Per-task `options` are sent as the server's `runtime_overrides`,
|
||||
for example `options={"execution_target":"computer","plugins":[{"id":"cua-driver","profile":"default"}]}`.
|
||||
Plugin IDs must exist on the host; omitting `plugins` selects enabled authorized
|
||||
defaults for the target. Mixed tasks still require Android.
|
||||
|
||||
For interactive case design, use `client.exploration` with the same actions as the
|
||||
web workspace and `mobile_exploration`. Use a stable request ID when retrying a write:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"""Public API for the lightweight Artemis remote client."""
|
||||
|
||||
from artemis_client.client import ArtemisClient
|
||||
from artemis_client.client import ArtemisClient, ExecutionTarget
|
||||
from artemis_client.errors import (
|
||||
ApiError,
|
||||
ArtemisClientError,
|
||||
@@ -33,6 +33,7 @@ __all__ = [
|
||||
"Capabilities",
|
||||
"ConflictError",
|
||||
"Device",
|
||||
"ExecutionTarget",
|
||||
"JsonTransport",
|
||||
"NetworkError",
|
||||
"NotFoundError",
|
||||
|
||||
@@ -39,6 +39,8 @@ VERIFICATION_LEVELS: tuple[str, ...] = ("off", "final", "checkpoints", "strict")
|
||||
#: Pro-profile Explorer perception versions accepted by ``/api/run`` (``explorer_mode``).
|
||||
ExplorerMode = Literal["flash", "pro", "ultra"]
|
||||
EXPLORER_MODES: tuple[str, ...] = ("flash", "pro", "ultra")
|
||||
ExecutionTarget = Literal["android", "computer", "mixed"]
|
||||
EXECUTION_TARGETS: tuple[str, ...] = ("android", "computer", "mixed")
|
||||
|
||||
|
||||
def _normalize_choice(value: str | None, name: str, choices: tuple[str, ...]) -> str | None:
|
||||
@@ -82,7 +84,7 @@ class ArtemisClient:
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
default_profile: Literal["flash", "pro"] = "flash",
|
||||
concurrency_mode: str = "per_device",
|
||||
concurrency_mode: str = "global",
|
||||
max_concurrency: int | None = None,
|
||||
standalone: bool = False,
|
||||
) -> None:
|
||||
@@ -187,6 +189,7 @@ class ArtemisClient:
|
||||
task_id: str | None = None,
|
||||
verification_level: VerificationLevel | None = None,
|
||||
explorer_mode: ExplorerMode | None = None,
|
||||
execution_target: ExecutionTarget | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
) -> TaskHandle:
|
||||
"""Submit one task and return immediately after scheduler admission.
|
||||
@@ -197,7 +200,9 @@ class ArtemisClient:
|
||||
``strict``: how much the Checker audits a Pro run) and
|
||||
``explorer_mode`` (``flash`` | ``pro`` | ``ultra``: the Pro Operator's
|
||||
perception depth) are Pro-only tuning knobs; the Flash profile ignores
|
||||
them. Experimental, forward-compatible fields belong in ``options``.
|
||||
them. ``execution_target="computer"`` runs only on the host PC without
|
||||
Android discovery. Per-task ``options`` use the host's TaskOptions schema
|
||||
and are sent as ``runtime_overrides`` (including plugin selections).
|
||||
"""
|
||||
normalized_goal = goal.strip()
|
||||
if not normalized_goal:
|
||||
@@ -206,6 +211,18 @@ class ArtemisClient:
|
||||
verification_level, "verification_level", VERIFICATION_LEVELS
|
||||
)
|
||||
resolved_mode = _normalize_choice(explorer_mode, "explorer_mode", EXPLORER_MODES)
|
||||
runtime_overrides = dict(options) if options is not None else {}
|
||||
resolved_target = _normalize_choice(
|
||||
execution_target
|
||||
if execution_target is not None
|
||||
else runtime_overrides.get("execution_target"),
|
||||
"execution_target",
|
||||
EXECUTION_TARGETS,
|
||||
)
|
||||
if resolved_target is not None:
|
||||
runtime_overrides["execution_target"] = resolved_target
|
||||
if resolved_target == "computer":
|
||||
runtime_overrides.pop("device_serial", None)
|
||||
if task_id is None:
|
||||
resolved_task_id = str(uuid.uuid4())
|
||||
else:
|
||||
@@ -214,7 +231,9 @@ class ArtemisClient:
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise ValueError("task_id must be a valid UUID string") from exc
|
||||
resolved_profile = profile or self.default_profile
|
||||
resolved_device = device_serial or self.device_serial
|
||||
resolved_device = (
|
||||
None if resolved_target == "computer" else device_serial or self.device_serial
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"goal": normalized_goal,
|
||||
"profile": resolved_profile,
|
||||
@@ -230,7 +249,9 @@ class ArtemisClient:
|
||||
"conversation_id": conversation_id,
|
||||
"verification_level": resolved_level,
|
||||
"explorer_mode": resolved_mode,
|
||||
"options": dict(options) if options is not None else None,
|
||||
"runtime_overrides": runtime_overrides
|
||||
if options is not None or runtime_overrides
|
||||
else None,
|
||||
}
|
||||
payload.update({key: value for key, value in optional_values.items() if value is not None})
|
||||
|
||||
@@ -307,6 +328,7 @@ class ArtemisClient:
|
||||
task_id: str | None = None,
|
||||
verification_level: VerificationLevel | None = None,
|
||||
explorer_mode: ExplorerMode | None = None,
|
||||
execution_target: ExecutionTarget | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
timeout: float = 1800.0,
|
||||
poll_interval: float | None = None,
|
||||
@@ -324,6 +346,7 @@ class ArtemisClient:
|
||||
task_id=task_id,
|
||||
verification_level=verification_level,
|
||||
explorer_mode=explorer_mode,
|
||||
execution_target=execution_target,
|
||||
options=options,
|
||||
)
|
||||
return await self.wait_for_task(
|
||||
@@ -342,6 +365,7 @@ class ArtemisClient:
|
||||
"device_serial": getattr(task, "device_serial", None)
|
||||
or getattr(task, "device_id", None),
|
||||
"locked_app_package": getattr(task, "locked_package", None),
|
||||
"execution_target": getattr(task, "execution_target", None),
|
||||
}
|
||||
values.update(overrides)
|
||||
return await self.run(goal, **values)
|
||||
|
||||
@@ -18,6 +18,7 @@ from artemis_client import (
|
||||
NotFoundError,
|
||||
ProtocolError,
|
||||
TaskRejectedError,
|
||||
TaskResult,
|
||||
TaskTimeoutError,
|
||||
)
|
||||
|
||||
@@ -105,7 +106,50 @@ class ArtemisClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
assert body is not None
|
||||
self.assertEqual(body["session_id"], task_id)
|
||||
self.assertEqual(body["ingress"], "python_sdk")
|
||||
self.assertEqual(body["options"], {"record_video": True})
|
||||
self.assertEqual(body["runtime_overrides"], {"record_video": True})
|
||||
|
||||
async def test_pc_submission_ignores_saved_phone_and_uses_shared_options(self) -> None:
|
||||
self.client.device_serial = "offline-phone"
|
||||
options = {"device_serial": "old-phone", "plugins": [{"id": "cua", "profile": "default"}]}
|
||||
self.transport.add(
|
||||
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
|
||||
)
|
||||
await self.client.submit("Check desktop", execution_target="computer", options=options)
|
||||
body = self.transport.calls[-1][2]
|
||||
assert body is not None
|
||||
self.assertNotIn("device_serial", body)
|
||||
self.assertEqual(
|
||||
body["runtime_overrides"],
|
||||
{"execution_target": "computer", "plugins": options["plugins"]},
|
||||
)
|
||||
self.assertEqual(options["device_serial"], "old-phone")
|
||||
|
||||
async def test_pc_target_in_options_and_task_object_are_forwarded(self) -> None:
|
||||
self.transport.add(
|
||||
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
|
||||
)
|
||||
await self.client.submit("Check desktop", options={"execution_target": "computer"})
|
||||
self.assertEqual(
|
||||
self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"}
|
||||
)
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace
|
||||
|
||||
self.client.wait_for_task = AsyncMock(
|
||||
return_value=TaskResult(task_id="pc", status="completed")
|
||||
)
|
||||
self.transport.add(
|
||||
"POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]}
|
||||
)
|
||||
await self.client.run_task(SimpleNamespace(goal="Check PC", execution_target="computer"))
|
||||
self.assertEqual(
|
||||
self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"}
|
||||
)
|
||||
|
||||
async def test_invalid_execution_target_is_rejected_without_submission(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "execution_target"):
|
||||
await self.client.submit("Check desktop", options={"execution_target": "phone"})
|
||||
self.assertEqual(self.transport.calls, [])
|
||||
|
||||
async def test_submit_forwards_pro_tuning_knobs_normalised(self) -> None:
|
||||
task_id = "00000000-0000-4000-8000-000000000321"
|
||||
|
||||
Reference in new issue
Block a user