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.
Files changed (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. 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}")
@@ -0,0 +1,5 @@
1
+ """automation Web UI assets."""
2
+
3
+ from browser_cli.automation.web.assets import render_index_html
4
+
5
+ __all__ = ["render_index_html"]