browser-control-and-automation-cli 0.1.1.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Client helpers for the automation service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import http.client
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from browser_cli import __version__
|
|
17
|
+
from browser_cli.constants import get_app_paths
|
|
18
|
+
from browser_cli.errors import AutomationServiceError, AutomationServiceNotAvailableError
|
|
19
|
+
|
|
20
|
+
AUTOMATION_STARTUP_TIMEOUT_SECONDS = 15.0
|
|
21
|
+
AUTOMATION_STARTUP_PROBE_INTERVAL_SECONDS = 0.1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request_automation_service(
|
|
25
|
+
method: str,
|
|
26
|
+
path: str,
|
|
27
|
+
*,
|
|
28
|
+
body: dict[str, Any] | None = None,
|
|
29
|
+
start_if_needed: bool = True,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
if start_if_needed:
|
|
32
|
+
ensure_automation_service_running()
|
|
33
|
+
run_info = read_automation_service_run_info()
|
|
34
|
+
if not run_info:
|
|
35
|
+
raise AutomationServiceNotAvailableError()
|
|
36
|
+
host = str(run_info["host"])
|
|
37
|
+
port = int(run_info["port"])
|
|
38
|
+
connection = http.client.HTTPConnection(host, port, timeout=5.0)
|
|
39
|
+
try:
|
|
40
|
+
raw = json.dumps(body).encode("utf-8") if body is not None else None
|
|
41
|
+
headers = {"Content-Type": "application/json"} if raw is not None else {}
|
|
42
|
+
connection.request(method, path, body=raw, headers=headers)
|
|
43
|
+
response = connection.getresponse()
|
|
44
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise AutomationServiceNotAvailableError(str(exc)) from exc
|
|
47
|
+
finally:
|
|
48
|
+
connection.close()
|
|
49
|
+
if payload.get("ok"):
|
|
50
|
+
return payload
|
|
51
|
+
raise AutomationServiceError(payload)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_automation_service_running() -> None:
|
|
55
|
+
run_info = read_automation_service_run_info()
|
|
56
|
+
if _run_info_is_compatible(run_info) and _probe_automation_service(run_info):
|
|
57
|
+
return
|
|
58
|
+
_cleanup_stale_automation_service()
|
|
59
|
+
_spawn_automation_service()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stop_automation_service() -> dict[str, Any]:
|
|
63
|
+
run_info = read_automation_service_run_info()
|
|
64
|
+
if not run_info or not _probe_automation_service(run_info):
|
|
65
|
+
_cleanup_stale_automation_service()
|
|
66
|
+
return {"stopped": False, "already_stopped": True}
|
|
67
|
+
payload = request_automation_service("POST", "/api/service/stop", start_if_needed=False)
|
|
68
|
+
_wait_for_automation_service_stop()
|
|
69
|
+
_cleanup_stale_automation_service()
|
|
70
|
+
return dict(payload.get("data") or {})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def automation_service_ui_url() -> str:
|
|
74
|
+
ensure_automation_service_running()
|
|
75
|
+
run_info = read_automation_service_run_info()
|
|
76
|
+
if not run_info:
|
|
77
|
+
raise AutomationServiceNotAvailableError()
|
|
78
|
+
return f"http://{run_info['host']}:{run_info['port']}/"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_automation_service_run_info() -> dict[str, Any] | None:
|
|
82
|
+
path = get_app_paths().automation_service_run_info_path
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_automation_service_run_info(info: dict[str, Any]) -> None:
|
|
90
|
+
path = get_app_paths().automation_service_run_info_path
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
tmp = path.with_suffix(".tmp")
|
|
93
|
+
tmp.write_text(json.dumps(info), encoding="utf-8")
|
|
94
|
+
tmp.replace(path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def remove_automation_service_run_info() -> None:
|
|
98
|
+
with contextlib.suppress(OSError):
|
|
99
|
+
get_app_paths().automation_service_run_info_path.unlink()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _spawn_automation_service() -> None:
|
|
103
|
+
app_paths = get_app_paths()
|
|
104
|
+
app_paths.run_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
env = os.environ.copy()
|
|
106
|
+
existing_pythonpath = os.environ.get("PYTHONPATH", "")
|
|
107
|
+
source_root = Path(__file__).resolve().parents[3]
|
|
108
|
+
pythonpath_parts = [str(source_root)]
|
|
109
|
+
if existing_pythonpath:
|
|
110
|
+
pythonpath_parts.append(existing_pythonpath)
|
|
111
|
+
env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
|
|
112
|
+
log_handle = app_paths.automation_service_log_path.open("ab")
|
|
113
|
+
process = None
|
|
114
|
+
try:
|
|
115
|
+
process = subprocess.Popen(
|
|
116
|
+
[sys.executable, "-m", "browser_cli.automation.service", "--home", str(app_paths.home)],
|
|
117
|
+
stdout=log_handle,
|
|
118
|
+
stderr=subprocess.STDOUT,
|
|
119
|
+
env=env,
|
|
120
|
+
start_new_session=True,
|
|
121
|
+
)
|
|
122
|
+
finally:
|
|
123
|
+
log_handle.close()
|
|
124
|
+
deadline = time.time() + AUTOMATION_STARTUP_TIMEOUT_SECONDS
|
|
125
|
+
while time.time() < deadline:
|
|
126
|
+
run_info = read_automation_service_run_info()
|
|
127
|
+
if _run_info_is_compatible(run_info) and _probe_automation_service(run_info):
|
|
128
|
+
return
|
|
129
|
+
time.sleep(AUTOMATION_STARTUP_PROBE_INTERVAL_SECONDS)
|
|
130
|
+
if process is not None:
|
|
131
|
+
_terminate_process_tree(process.pid)
|
|
132
|
+
raise AutomationServiceNotAvailableError(
|
|
133
|
+
f"Timed out waiting for automation service startup (log={app_paths.automation_service_log_path})."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _probe_automation_service(run_info: dict[str, Any] | None) -> bool:
|
|
138
|
+
if not isinstance(run_info, dict):
|
|
139
|
+
return False
|
|
140
|
+
host = str(run_info.get("host") or "")
|
|
141
|
+
port = int(run_info.get("port") or 0)
|
|
142
|
+
if not host or port <= 0:
|
|
143
|
+
return False
|
|
144
|
+
connection: http.client.HTTPConnection | None = None
|
|
145
|
+
try:
|
|
146
|
+
connection = http.client.HTTPConnection(host, port, timeout=1.0)
|
|
147
|
+
connection.request("GET", "/api/service/status")
|
|
148
|
+
response = connection.getresponse()
|
|
149
|
+
response.read()
|
|
150
|
+
return response.status == 200
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
finally:
|
|
154
|
+
if connection is not None:
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
connection.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _wait_for_automation_service_stop(timeout_seconds: float = 3.0) -> bool:
|
|
160
|
+
deadline = time.time() + timeout_seconds
|
|
161
|
+
while time.time() < deadline:
|
|
162
|
+
run_info = read_automation_service_run_info()
|
|
163
|
+
if not run_info or not _probe_automation_service(run_info):
|
|
164
|
+
return True
|
|
165
|
+
time.sleep(0.1)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _cleanup_stale_automation_service() -> None:
|
|
170
|
+
run_info = read_automation_service_run_info()
|
|
171
|
+
if isinstance(run_info, dict):
|
|
172
|
+
pid = _coerce_pid(run_info.get("pid"))
|
|
173
|
+
if pid is not None and _pid_exists(pid) and not _probe_automation_service(run_info):
|
|
174
|
+
_terminate_process_tree(pid)
|
|
175
|
+
remove_automation_service_run_info()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_info_is_compatible(run_info: dict[str, Any] | None) -> bool:
|
|
179
|
+
if not isinstance(run_info, dict):
|
|
180
|
+
return False
|
|
181
|
+
return (
|
|
182
|
+
str(run_info.get("package_version") or "") == __version__
|
|
183
|
+
and str(run_info.get("runtime_version") or "") == "2026-04-12-automation-service-v1"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _terminate_process_tree(pid: int) -> None:
|
|
188
|
+
_signal_process_tree(pid, signal.SIGTERM)
|
|
189
|
+
deadline = time.time() + 3.0
|
|
190
|
+
while time.time() < deadline:
|
|
191
|
+
if not _pid_exists(pid):
|
|
192
|
+
return
|
|
193
|
+
time.sleep(0.1)
|
|
194
|
+
_signal_process_tree(pid, signal.SIGKILL)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _signal_process_tree(pid: int, sig: signal.Signals) -> None:
|
|
198
|
+
try:
|
|
199
|
+
os.killpg(pid, sig)
|
|
200
|
+
except OSError:
|
|
201
|
+
with contextlib.suppress(OSError):
|
|
202
|
+
os.kill(pid, sig)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coerce_pid(value: Any) -> int | None:
|
|
206
|
+
try:
|
|
207
|
+
pid = int(value)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
return None
|
|
210
|
+
return pid if pid > 0 else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _pid_exists(pid: int) -> bool:
|
|
214
|
+
try:
|
|
215
|
+
os.kill(pid, 0)
|
|
216
|
+
except ProcessLookupError:
|
|
217
|
+
return False
|
|
218
|
+
except PermissionError:
|
|
219
|
+
return True
|
|
220
|
+
else:
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = [
|
|
225
|
+
"ensure_automation_service_running",
|
|
226
|
+
"read_automation_service_run_info",
|
|
227
|
+
"remove_automation_service_run_info",
|
|
228
|
+
"request_automation_service",
|
|
229
|
+
"stop_automation_service",
|
|
230
|
+
"automation_service_ui_url",
|
|
231
|
+
"_write_automation_service_run_info",
|
|
232
|
+
]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Automation service runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from browser_cli import __version__, error_codes
|
|
14
|
+
from browser_cli.automation.hooks import run_hook_commands
|
|
15
|
+
from browser_cli.automation.models import AutomationRunEvent
|
|
16
|
+
from browser_cli.automation.persistence import AutomationStore
|
|
17
|
+
from browser_cli.constants import get_app_paths
|
|
18
|
+
from browser_cli.errors import AutomationRunTimeoutError, OperationFailedError
|
|
19
|
+
from browser_cli.task_runtime import run_task_entrypoint
|
|
20
|
+
from browser_cli.task_runtime.client import BrowserCliTaskClient
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
AUTOMATION_SERVICE_RUNTIME_VERSION = "2026-04-12-automation-service-v1"
|
|
25
|
+
FAILURE_HOOK_TIMEOUT_SECONDS = 5.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_task_entrypoint_child(
|
|
29
|
+
queue,
|
|
30
|
+
*,
|
|
31
|
+
task_path: str,
|
|
32
|
+
entrypoint: str,
|
|
33
|
+
inputs: dict[str, Any],
|
|
34
|
+
artifacts_dir: str,
|
|
35
|
+
automation_path: str | None,
|
|
36
|
+
automation_name: str | None,
|
|
37
|
+
log_path: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
try:
|
|
40
|
+
with open(log_path, "a", encoding="utf-8") as log_handle:
|
|
41
|
+
result = run_task_entrypoint(
|
|
42
|
+
task_path=Path(task_path),
|
|
43
|
+
entrypoint=entrypoint,
|
|
44
|
+
inputs=inputs,
|
|
45
|
+
artifacts_dir=Path(artifacts_dir),
|
|
46
|
+
automation_path=Path(automation_path) if automation_path else None,
|
|
47
|
+
automation_name=automation_name,
|
|
48
|
+
client=BrowserCliTaskClient(),
|
|
49
|
+
stdout_handle=log_handle,
|
|
50
|
+
stderr_handle=log_handle,
|
|
51
|
+
)
|
|
52
|
+
queue.put({"ok": True, "result": result})
|
|
53
|
+
except BaseException as exc: # noqa: BLE001
|
|
54
|
+
queue.put(
|
|
55
|
+
{
|
|
56
|
+
"ok": False,
|
|
57
|
+
"error_message": str(exc),
|
|
58
|
+
"error_code": (
|
|
59
|
+
exc.error_code
|
|
60
|
+
if isinstance(exc, OperationFailedError)
|
|
61
|
+
else error_codes.AUTOMATION_RUN_FAILED
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AutomationServiceRuntime:
|
|
68
|
+
def __init__(self, store: AutomationStore | None = None) -> None:
|
|
69
|
+
self.app_paths = get_app_paths()
|
|
70
|
+
self.store = store or AutomationStore()
|
|
71
|
+
self.started_at = time.time()
|
|
72
|
+
self.stop_event = threading.Event()
|
|
73
|
+
self._scheduler_thread = threading.Thread(
|
|
74
|
+
target=self._scheduler_loop,
|
|
75
|
+
name="automation-service-scheduler",
|
|
76
|
+
daemon=True,
|
|
77
|
+
)
|
|
78
|
+
self._executor_thread = threading.Thread(
|
|
79
|
+
target=self._executor_loop,
|
|
80
|
+
name="automation-service-executor",
|
|
81
|
+
daemon=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
self._scheduler_thread.start()
|
|
86
|
+
self._executor_thread.start()
|
|
87
|
+
|
|
88
|
+
def stop(self) -> None:
|
|
89
|
+
self.stop_event.set()
|
|
90
|
+
|
|
91
|
+
def join(self, timeout: float | None = None) -> None:
|
|
92
|
+
self._scheduler_thread.join(timeout=timeout)
|
|
93
|
+
self._executor_thread.join(timeout=timeout)
|
|
94
|
+
|
|
95
|
+
def status_payload(self) -> dict[str, Any]:
|
|
96
|
+
metrics = self.store.service_metrics()
|
|
97
|
+
return {
|
|
98
|
+
"service": {
|
|
99
|
+
"healthy": not self.stop_event.is_set(),
|
|
100
|
+
"started_at": self.started_at,
|
|
101
|
+
"runtime_version": AUTOMATION_SERVICE_RUNTIME_VERSION,
|
|
102
|
+
"package_version": __version__,
|
|
103
|
+
"db_path": str(self.store.db_path),
|
|
104
|
+
"runs_dir": str(self.app_paths.automation_runs_dir),
|
|
105
|
+
},
|
|
106
|
+
"metrics": metrics,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def _scheduler_loop(self) -> None:
|
|
110
|
+
while not self.stop_event.is_set():
|
|
111
|
+
try:
|
|
112
|
+
self.store.enqueue_due_runs()
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception("Automation scheduler loop failed")
|
|
115
|
+
self.stop_event.wait(1.0)
|
|
116
|
+
|
|
117
|
+
def _executor_loop(self) -> None:
|
|
118
|
+
while not self.stop_event.is_set():
|
|
119
|
+
try:
|
|
120
|
+
run = self.store.claim_next_run()
|
|
121
|
+
if run is None:
|
|
122
|
+
self.stop_event.wait(0.5)
|
|
123
|
+
continue
|
|
124
|
+
self._execute_run(run.run_id)
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.exception("Automation executor loop failed")
|
|
127
|
+
self.stop_event.wait(0.5)
|
|
128
|
+
|
|
129
|
+
def _execute_run(self, run_id: str) -> None:
|
|
130
|
+
run = self.store.get_run(run_id)
|
|
131
|
+
automation = self.store.get_automation(run.automation_id)
|
|
132
|
+
run_dir = (automation.output_dir / "runs" / run.run_id).resolve()
|
|
133
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
log_path = run_dir / "run.log"
|
|
135
|
+
result_path = run_dir / "result.json"
|
|
136
|
+
deadline = self._deadline_for_run(automation.timeout_seconds)
|
|
137
|
+
stage = "daemon_ready"
|
|
138
|
+
self.store.add_run_event(
|
|
139
|
+
run_id,
|
|
140
|
+
AutomationRunEvent(
|
|
141
|
+
run_id=run_id,
|
|
142
|
+
event_type="task_started",
|
|
143
|
+
message="Task execution started.",
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
client = BrowserCliTaskClient()
|
|
148
|
+
self._run_with_timeout(
|
|
149
|
+
lambda: client.command("runtime-status"),
|
|
150
|
+
timeout_seconds=self._remaining_timeout(deadline, stage="daemon_ready"),
|
|
151
|
+
timeout_message=(
|
|
152
|
+
f"Automation run timed out during daemon readiness after "
|
|
153
|
+
f"{automation.timeout_seconds} seconds."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
self.store.add_run_event(
|
|
157
|
+
run_id,
|
|
158
|
+
AutomationRunEvent(
|
|
159
|
+
run_id=run_id,
|
|
160
|
+
event_type="browser_daemon_ready",
|
|
161
|
+
message="Browser daemon is reachable.",
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
self.store.add_run_event(
|
|
166
|
+
run_id,
|
|
167
|
+
AutomationRunEvent(
|
|
168
|
+
run_id=run_id,
|
|
169
|
+
event_type="browser_daemon_unavailable",
|
|
170
|
+
message=str(exc),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
with log_path.open("a", encoding="utf-8") as log_handle:
|
|
175
|
+
try:
|
|
176
|
+
hook_env = {
|
|
177
|
+
"BROWSER_CLI_AUTOMATION_ID": automation.id,
|
|
178
|
+
"BROWSER_CLI_AUTOMATION_NAME": automation.name,
|
|
179
|
+
"BROWSER_CLI_TASK_PATH": str(automation.task_path),
|
|
180
|
+
"BROWSER_CLI_AUTOMATION_STATUS": "running",
|
|
181
|
+
}
|
|
182
|
+
stage = "before_hooks"
|
|
183
|
+
before_hooks = run_hook_commands(
|
|
184
|
+
automation.before_run_hooks,
|
|
185
|
+
cwd=automation.task_path.parent,
|
|
186
|
+
extra_env=hook_env,
|
|
187
|
+
timeout_seconds=self._remaining_timeout(deadline, stage=stage),
|
|
188
|
+
)
|
|
189
|
+
if before_hooks:
|
|
190
|
+
log_handle.write(
|
|
191
|
+
json.dumps({"before_hooks": before_hooks}, ensure_ascii=False) + "\n"
|
|
192
|
+
)
|
|
193
|
+
stage = "task_execution"
|
|
194
|
+
result = self._run_task_entrypoint_with_timeout(
|
|
195
|
+
task_path=automation.task_path,
|
|
196
|
+
entrypoint=automation.entrypoint,
|
|
197
|
+
inputs=run.effective_inputs,
|
|
198
|
+
artifacts_dir=run_dir,
|
|
199
|
+
automation_path=automation.output_dir,
|
|
200
|
+
automation_name=automation.name,
|
|
201
|
+
log_path=log_path,
|
|
202
|
+
timeout_seconds=self._remaining_timeout(deadline, stage=stage),
|
|
203
|
+
)
|
|
204
|
+
stage = "persist_result"
|
|
205
|
+
self._ensure_time_remaining(deadline, stage=stage)
|
|
206
|
+
result_path.write_text(
|
|
207
|
+
json.dumps(result, ensure_ascii=False, indent=2),
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
)
|
|
210
|
+
if automation.result_json_path is not None:
|
|
211
|
+
self._ensure_time_remaining(deadline, stage=stage)
|
|
212
|
+
automation.result_json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
automation.result_json_path.write_text(
|
|
214
|
+
json.dumps(result, ensure_ascii=False, indent=2),
|
|
215
|
+
encoding="utf-8",
|
|
216
|
+
)
|
|
217
|
+
stage = "after_success_hooks"
|
|
218
|
+
success_hooks = run_hook_commands(
|
|
219
|
+
automation.after_success_hooks,
|
|
220
|
+
cwd=automation.task_path.parent,
|
|
221
|
+
extra_env={**hook_env, "BROWSER_CLI_AUTOMATION_STATUS": "success"},
|
|
222
|
+
timeout_seconds=self._remaining_timeout(deadline, stage=stage),
|
|
223
|
+
)
|
|
224
|
+
if success_hooks:
|
|
225
|
+
log_handle.write(
|
|
226
|
+
json.dumps({"after_success_hooks": success_hooks}, ensure_ascii=False)
|
|
227
|
+
+ "\n"
|
|
228
|
+
)
|
|
229
|
+
self.store.complete_run(
|
|
230
|
+
run_id,
|
|
231
|
+
status="success",
|
|
232
|
+
result_json_path=result_path,
|
|
233
|
+
artifacts_dir=run_dir,
|
|
234
|
+
log_path=log_path,
|
|
235
|
+
)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
timed_out = isinstance(exc, AutomationRunTimeoutError)
|
|
238
|
+
if timed_out:
|
|
239
|
+
self.store.add_run_event(
|
|
240
|
+
run_id,
|
|
241
|
+
AutomationRunEvent(
|
|
242
|
+
run_id=run_id,
|
|
243
|
+
event_type="run_timed_out",
|
|
244
|
+
message=str(exc),
|
|
245
|
+
payload={"stage": stage},
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
failure_hooks = run_hook_commands(
|
|
250
|
+
automation.after_failure_hooks,
|
|
251
|
+
cwd=automation.task_path.parent,
|
|
252
|
+
extra_env={
|
|
253
|
+
"BROWSER_CLI_AUTOMATION_ID": automation.id,
|
|
254
|
+
"BROWSER_CLI_AUTOMATION_NAME": automation.name,
|
|
255
|
+
"BROWSER_CLI_TASK_PATH": str(automation.task_path),
|
|
256
|
+
"BROWSER_CLI_AUTOMATION_STATUS": "failure",
|
|
257
|
+
},
|
|
258
|
+
timeout_seconds=FAILURE_HOOK_TIMEOUT_SECONDS,
|
|
259
|
+
)
|
|
260
|
+
if failure_hooks:
|
|
261
|
+
log_handle.write(
|
|
262
|
+
json.dumps({"after_failure_hooks": failure_hooks}, ensure_ascii=False)
|
|
263
|
+
+ "\n"
|
|
264
|
+
)
|
|
265
|
+
except Exception:
|
|
266
|
+
logger.exception("Automation failure hooks failed for %s", automation.id)
|
|
267
|
+
logger.exception("Automation run failed: %s", run_id)
|
|
268
|
+
log_handle.write(f"\nERROR: {exc}\n")
|
|
269
|
+
error_code = (
|
|
270
|
+
exc.error_code
|
|
271
|
+
if isinstance(exc, OperationFailedError)
|
|
272
|
+
else error_codes.AUTOMATION_RUN_FAILED
|
|
273
|
+
)
|
|
274
|
+
self.store.complete_run(
|
|
275
|
+
run_id,
|
|
276
|
+
status="failed",
|
|
277
|
+
error_code=error_code,
|
|
278
|
+
error_message=str(exc),
|
|
279
|
+
result_json_path=result_path if result_path.exists() else None,
|
|
280
|
+
artifacts_dir=run_dir,
|
|
281
|
+
log_path=log_path,
|
|
282
|
+
)
|
|
283
|
+
if not timed_out and automation.retry_attempts > run.attempt_number:
|
|
284
|
+
retry_run = self.store.retry_run(run_id)
|
|
285
|
+
self.store.add_run_event(
|
|
286
|
+
retry_run.run_id,
|
|
287
|
+
AutomationRunEvent(
|
|
288
|
+
run_id=retry_run.run_id,
|
|
289
|
+
event_type="retry_scheduled",
|
|
290
|
+
message=f"Retry scheduled after failed run {run_id}.",
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _deadline_for_run(timeout_seconds: float | None) -> float | None:
|
|
296
|
+
if timeout_seconds is None or timeout_seconds <= 0:
|
|
297
|
+
return None
|
|
298
|
+
return time.monotonic() + timeout_seconds
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _remaining_timeout(deadline: float | None, *, stage: str) -> float | None:
|
|
302
|
+
if deadline is None:
|
|
303
|
+
return None
|
|
304
|
+
remaining = deadline - time.monotonic()
|
|
305
|
+
if remaining <= 0:
|
|
306
|
+
raise AutomationRunTimeoutError(
|
|
307
|
+
f"Automation run timed out during {stage} before work could continue."
|
|
308
|
+
)
|
|
309
|
+
return remaining
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _ensure_time_remaining(deadline: float | None, *, stage: str) -> None:
|
|
313
|
+
_ = AutomationServiceRuntime._remaining_timeout(deadline, stage=stage)
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _run_with_timeout(
|
|
317
|
+
fn,
|
|
318
|
+
*,
|
|
319
|
+
timeout_seconds: float | None,
|
|
320
|
+
timeout_message: str,
|
|
321
|
+
):
|
|
322
|
+
if timeout_seconds is None:
|
|
323
|
+
return fn()
|
|
324
|
+
result_box: dict[str, Any] = {}
|
|
325
|
+
error_box: dict[str, BaseException] = {}
|
|
326
|
+
|
|
327
|
+
def _target() -> None:
|
|
328
|
+
try:
|
|
329
|
+
result_box["value"] = fn()
|
|
330
|
+
except BaseException as exc: # noqa: BLE001
|
|
331
|
+
error_box["error"] = exc
|
|
332
|
+
|
|
333
|
+
thread = threading.Thread(target=_target, daemon=True)
|
|
334
|
+
thread.start()
|
|
335
|
+
thread.join(timeout=timeout_seconds)
|
|
336
|
+
if thread.is_alive():
|
|
337
|
+
raise AutomationRunTimeoutError(timeout_message)
|
|
338
|
+
if "error" in error_box:
|
|
339
|
+
raise error_box["error"]
|
|
340
|
+
return result_box.get("value")
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _run_task_entrypoint_with_timeout(
|
|
344
|
+
*,
|
|
345
|
+
task_path: Path,
|
|
346
|
+
entrypoint: str,
|
|
347
|
+
inputs: dict[str, Any],
|
|
348
|
+
artifacts_dir: Path,
|
|
349
|
+
automation_path: Path | None,
|
|
350
|
+
automation_name: str | None,
|
|
351
|
+
log_path: Path,
|
|
352
|
+
timeout_seconds: float | None,
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
if timeout_seconds is None:
|
|
355
|
+
timeout_seconds = None
|
|
356
|
+
context = multiprocessing.get_context("spawn")
|
|
357
|
+
queue = context.Queue()
|
|
358
|
+
process = context.Process(
|
|
359
|
+
target=_run_task_entrypoint_child,
|
|
360
|
+
kwargs={
|
|
361
|
+
"queue": queue,
|
|
362
|
+
"task_path": str(task_path),
|
|
363
|
+
"entrypoint": entrypoint,
|
|
364
|
+
"inputs": inputs,
|
|
365
|
+
"artifacts_dir": str(artifacts_dir),
|
|
366
|
+
"automation_path": str(automation_path) if automation_path else None,
|
|
367
|
+
"automation_name": automation_name,
|
|
368
|
+
"log_path": str(log_path),
|
|
369
|
+
},
|
|
370
|
+
daemon=True,
|
|
371
|
+
)
|
|
372
|
+
process.start()
|
|
373
|
+
process.join(timeout=timeout_seconds)
|
|
374
|
+
if process.is_alive():
|
|
375
|
+
process.terminate()
|
|
376
|
+
process.join(timeout=1.0)
|
|
377
|
+
raise AutomationRunTimeoutError(
|
|
378
|
+
f"Automation run timed out during task execution after {timeout_seconds} seconds."
|
|
379
|
+
)
|
|
380
|
+
if queue.empty():
|
|
381
|
+
raise OperationFailedError("Task worker exited without returning a result.")
|
|
382
|
+
payload = queue.get()
|
|
383
|
+
if bool(payload.get("ok")):
|
|
384
|
+
return dict(payload.get("result") or {})
|
|
385
|
+
raise OperationFailedError(
|
|
386
|
+
str(payload.get("error_message") or "Task worker failed."),
|
|
387
|
+
error_code=str(payload.get("error_code") or error_codes.AUTOMATION_RUN_FAILED),
|
|
388
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Minimal TOML rendering helpers for automation manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dumps_toml_sections(sections: Iterable[tuple[str, dict[str, Any]]]) -> str:
|
|
11
|
+
lines: list[str] = []
|
|
12
|
+
first_section = True
|
|
13
|
+
for section_name, values in sections:
|
|
14
|
+
if not first_section:
|
|
15
|
+
lines.append("")
|
|
16
|
+
first_section = False
|
|
17
|
+
lines.append(f"[{section_name}]")
|
|
18
|
+
for key, value in values.items():
|
|
19
|
+
if value is None:
|
|
20
|
+
continue
|
|
21
|
+
lines.append(f"{key} = {_toml_value(value)}")
|
|
22
|
+
lines.append("")
|
|
23
|
+
return "\n".join(lines)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _toml_value(value: Any) -> str:
|
|
27
|
+
if isinstance(value, bool):
|
|
28
|
+
return "true" if value else "false"
|
|
29
|
+
if isinstance(value, int | float):
|
|
30
|
+
return str(value)
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
return json.dumps(value, ensure_ascii=False)
|
|
33
|
+
if isinstance(value, list | tuple):
|
|
34
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
35
|
+
raise TypeError(f"Unsupported TOML value type: {type(value)!r}")
|