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,14 @@
|
|
|
1
|
+
"""Agent visibility-domain helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from browser_cli.constants import DEFAULT_PUBLIC_AGENT_ID
|
|
8
|
+
|
|
9
|
+
AGENT_ID_ENV = "X_AGENT_ID"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_agent_id() -> str:
|
|
13
|
+
raw_value = os.environ.get(AGENT_ID_ENV, "").strip()
|
|
14
|
+
return raw_value or DEFAULT_PUBLIC_AGENT_ID
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Automation helpers."""
|
|
2
|
+
|
|
3
|
+
from browser_cli.automation.loader import AutomationManifest, load_automation_manifest
|
|
4
|
+
from browser_cli.automation.publisher import PublishedAutomation, publish_task_dir
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AutomationManifest",
|
|
8
|
+
"PublishedAutomation",
|
|
9
|
+
"load_automation_manifest",
|
|
10
|
+
"publish_task_dir",
|
|
11
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Local HTTP API for automation service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from browser_cli.automation.loader import load_automation_manifest
|
|
13
|
+
from browser_cli.automation.models import PersistedAutomationDefinition
|
|
14
|
+
from browser_cli.automation.projections import (
|
|
15
|
+
manifest_to_persisted_definition,
|
|
16
|
+
payload_to_persisted_definition,
|
|
17
|
+
persisted_definition_to_config_payload,
|
|
18
|
+
persisted_definition_to_manifest_toml,
|
|
19
|
+
)
|
|
20
|
+
from browser_cli.automation.web import render_index_html
|
|
21
|
+
from browser_cli.errors import BrowserCliError, InvalidInputError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AutomationHttpServer(ThreadingHTTPServer):
|
|
25
|
+
def __init__(self, server_address, request_handler_class, runtime) -> None:
|
|
26
|
+
super().__init__(server_address, request_handler_class)
|
|
27
|
+
self.runtime = runtime
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AutomationRequestHandler(BaseHTTPRequestHandler):
|
|
31
|
+
server: AutomationHttpServer
|
|
32
|
+
|
|
33
|
+
def do_GET(self) -> None: # noqa: N802
|
|
34
|
+
self._handle("GET")
|
|
35
|
+
|
|
36
|
+
def do_POST(self) -> None: # noqa: N802
|
|
37
|
+
self._handle("POST")
|
|
38
|
+
|
|
39
|
+
def do_PUT(self) -> None: # noqa: N802
|
|
40
|
+
self._handle("PUT")
|
|
41
|
+
|
|
42
|
+
def log_message(self, _format: str, *_args) -> None:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
def _handle(self, method: str) -> None:
|
|
46
|
+
parsed = urlparse(self.path)
|
|
47
|
+
path = parsed.path
|
|
48
|
+
try:
|
|
49
|
+
if path == "/":
|
|
50
|
+
self._send_html(render_index_html())
|
|
51
|
+
return
|
|
52
|
+
if path == "/api/service/status":
|
|
53
|
+
self._send_json(
|
|
54
|
+
{
|
|
55
|
+
"ok": True,
|
|
56
|
+
"data": self.server.runtime.status_payload(),
|
|
57
|
+
"meta": {},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
if path == "/api/service/stop" and method == "POST":
|
|
62
|
+
self.server.runtime.stop()
|
|
63
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
64
|
+
self._send_json({"ok": True, "data": {"stopped": True}, "meta": {}})
|
|
65
|
+
return
|
|
66
|
+
if path == "/api/automations" and method == "GET":
|
|
67
|
+
automations = [
|
|
68
|
+
self._serialize_automation(item, include_latest_run=True)
|
|
69
|
+
for item in self.server.runtime.store.list_automations()
|
|
70
|
+
]
|
|
71
|
+
self._send_json({"ok": True, "data": automations, "meta": {}})
|
|
72
|
+
return
|
|
73
|
+
if path == "/api/automations" and method == "POST":
|
|
74
|
+
body = self._read_json_body()
|
|
75
|
+
created = self.server.runtime.store.upsert_automation(
|
|
76
|
+
payload_to_persisted_definition(body)
|
|
77
|
+
)
|
|
78
|
+
self._send_json(
|
|
79
|
+
{
|
|
80
|
+
"ok": True,
|
|
81
|
+
"data": self._serialize_automation(created),
|
|
82
|
+
"meta": {},
|
|
83
|
+
},
|
|
84
|
+
status=HTTPStatus.CREATED,
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
if path == "/api/automations/import" and method == "POST":
|
|
88
|
+
body = self._read_json_body()
|
|
89
|
+
manifest_path = str(body.get("path") or "").strip()
|
|
90
|
+
if not manifest_path:
|
|
91
|
+
raise InvalidInputError("Automation import requires non-empty path.")
|
|
92
|
+
manifest = load_automation_manifest(manifest_path)
|
|
93
|
+
enabled = bool(body.get("enabled"))
|
|
94
|
+
automation = manifest_to_persisted_definition(manifest, enabled=enabled)
|
|
95
|
+
created = self.server.runtime.store.upsert_automation(automation)
|
|
96
|
+
self._send_json(
|
|
97
|
+
{
|
|
98
|
+
"ok": True,
|
|
99
|
+
"data": self._serialize_automation(created),
|
|
100
|
+
"meta": {},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
if path.startswith("/api/automations/") and path.endswith("/runs") and method == "GET":
|
|
105
|
+
automation_id = path.split("/")[3]
|
|
106
|
+
runs = [
|
|
107
|
+
self._serialize_run(item)
|
|
108
|
+
for item in self.server.runtime.store.list_runs(automation_id)
|
|
109
|
+
]
|
|
110
|
+
self._send_json({"ok": True, "data": runs, "meta": {}})
|
|
111
|
+
return
|
|
112
|
+
if (
|
|
113
|
+
path.startswith("/api/automations/")
|
|
114
|
+
and path.endswith("/enable")
|
|
115
|
+
and method == "POST"
|
|
116
|
+
):
|
|
117
|
+
automation_id = path.split("/")[3]
|
|
118
|
+
updated = self.server.runtime.store.set_enabled(automation_id, True)
|
|
119
|
+
self._send_json(
|
|
120
|
+
{
|
|
121
|
+
"ok": True,
|
|
122
|
+
"data": self._serialize_automation(updated),
|
|
123
|
+
"meta": {},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
if (
|
|
128
|
+
path.startswith("/api/automations/")
|
|
129
|
+
and path.endswith("/disable")
|
|
130
|
+
and method == "POST"
|
|
131
|
+
):
|
|
132
|
+
automation_id = path.split("/")[3]
|
|
133
|
+
updated = self.server.runtime.store.set_enabled(automation_id, False)
|
|
134
|
+
self._send_json(
|
|
135
|
+
{
|
|
136
|
+
"ok": True,
|
|
137
|
+
"data": self._serialize_automation(updated),
|
|
138
|
+
"meta": {},
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
if path.startswith("/api/automations/") and path.endswith("/run") and method == "POST":
|
|
143
|
+
automation_id = path.split("/")[3]
|
|
144
|
+
run = self.server.runtime.store.create_run(automation_id, trigger_type="manual")
|
|
145
|
+
self._send_json({"ok": True, "data": self._serialize_run(run), "meta": {}})
|
|
146
|
+
return
|
|
147
|
+
if (
|
|
148
|
+
path.startswith("/api/automations/")
|
|
149
|
+
and path.endswith("/export")
|
|
150
|
+
and method == "GET"
|
|
151
|
+
):
|
|
152
|
+
automation_id = path.split("/")[3]
|
|
153
|
+
automation = self.server.runtime.store.get_automation(automation_id)
|
|
154
|
+
self._send_json(
|
|
155
|
+
{
|
|
156
|
+
"ok": True,
|
|
157
|
+
"data": {"toml": persisted_definition_to_manifest_toml(automation)},
|
|
158
|
+
"meta": {},
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
if path.startswith("/api/automations/") and method == "GET":
|
|
163
|
+
automation_id = path.split("/")[3]
|
|
164
|
+
automation = self.server.runtime.store.get_automation(automation_id)
|
|
165
|
+
self._send_json(
|
|
166
|
+
{
|
|
167
|
+
"ok": True,
|
|
168
|
+
"data": self._serialize_automation(automation, include_latest_run=True),
|
|
169
|
+
"meta": {},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
if path.startswith("/api/automations/") and method == "PUT":
|
|
174
|
+
automation_id = path.split("/")[3]
|
|
175
|
+
payload = self._read_json_body()
|
|
176
|
+
payload["id"] = automation_id
|
|
177
|
+
updated = self.server.runtime.store.upsert_automation(
|
|
178
|
+
payload_to_persisted_definition(payload)
|
|
179
|
+
)
|
|
180
|
+
self._send_json(
|
|
181
|
+
{
|
|
182
|
+
"ok": True,
|
|
183
|
+
"data": self._serialize_automation(updated),
|
|
184
|
+
"meta": {},
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
if path.startswith("/api/runs/") and path.endswith("/retry") and method == "POST":
|
|
189
|
+
run_id = path.split("/")[3]
|
|
190
|
+
run = self.server.runtime.store.retry_run(run_id)
|
|
191
|
+
self._send_json({"ok": True, "data": self._serialize_run(run), "meta": {}})
|
|
192
|
+
return
|
|
193
|
+
if path.startswith("/api/runs/") and method == "GET":
|
|
194
|
+
run_id = path.split("/")[3]
|
|
195
|
+
run = self.server.runtime.store.get_run(run_id)
|
|
196
|
+
events = self.server.runtime.store.list_run_events(run_id)
|
|
197
|
+
self._send_json(
|
|
198
|
+
{
|
|
199
|
+
"ok": True,
|
|
200
|
+
"data": {
|
|
201
|
+
**self._serialize_run(run),
|
|
202
|
+
"events": [self._serialize_event(item) for item in events],
|
|
203
|
+
"log_text": _read_text(run.log_path),
|
|
204
|
+
},
|
|
205
|
+
"meta": {},
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
self._send_json(
|
|
210
|
+
{
|
|
211
|
+
"ok": False,
|
|
212
|
+
"error_message": f"Not found: {path}",
|
|
213
|
+
"error_code": "NOT_FOUND",
|
|
214
|
+
},
|
|
215
|
+
status=HTTPStatus.NOT_FOUND,
|
|
216
|
+
)
|
|
217
|
+
except KeyError as exc:
|
|
218
|
+
self._send_json(
|
|
219
|
+
{
|
|
220
|
+
"ok": False,
|
|
221
|
+
"error_message": f"Not found: {exc}",
|
|
222
|
+
"error_code": "NOT_FOUND",
|
|
223
|
+
},
|
|
224
|
+
status=HTTPStatus.NOT_FOUND,
|
|
225
|
+
)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
status = (
|
|
228
|
+
HTTPStatus.BAD_REQUEST
|
|
229
|
+
if isinstance(exc, BrowserCliError | ValueError | json.JSONDecodeError)
|
|
230
|
+
else HTTPStatus.INTERNAL_SERVER_ERROR
|
|
231
|
+
)
|
|
232
|
+
self._send_json(
|
|
233
|
+
{
|
|
234
|
+
"ok": False,
|
|
235
|
+
"error_message": str(exc),
|
|
236
|
+
"error_code": getattr(
|
|
237
|
+
exc,
|
|
238
|
+
"error_code",
|
|
239
|
+
"INVALID_INPUT" if status == HTTPStatus.BAD_REQUEST else "INTERNAL_ERROR",
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
status=status,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _send_html(self, html: str, *, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
246
|
+
body = html.encode("utf-8")
|
|
247
|
+
self.send_response(status.value)
|
|
248
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
249
|
+
self.send_header("Content-Length", str(len(body)))
|
|
250
|
+
self.end_headers()
|
|
251
|
+
self.wfile.write(body)
|
|
252
|
+
|
|
253
|
+
def _send_json(self, payload: dict, *, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
254
|
+
body = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
255
|
+
self.send_response(status.value)
|
|
256
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
257
|
+
self.send_header("Content-Length", str(len(body)))
|
|
258
|
+
self.end_headers()
|
|
259
|
+
self.wfile.write(body)
|
|
260
|
+
|
|
261
|
+
def _read_json_body(self) -> dict:
|
|
262
|
+
length = int(self.headers.get("Content-Length") or "0")
|
|
263
|
+
if length <= 0:
|
|
264
|
+
return {}
|
|
265
|
+
raw = self.rfile.read(length)
|
|
266
|
+
return json.loads(raw.decode("utf-8"))
|
|
267
|
+
|
|
268
|
+
def _serialize_automation(
|
|
269
|
+
self,
|
|
270
|
+
automation: PersistedAutomationDefinition,
|
|
271
|
+
*,
|
|
272
|
+
include_latest_run: bool = False,
|
|
273
|
+
) -> dict:
|
|
274
|
+
payload = persisted_definition_to_config_payload(automation)
|
|
275
|
+
payload.update(
|
|
276
|
+
{
|
|
277
|
+
"enabled": automation.enabled,
|
|
278
|
+
"definition_status": automation.definition_status,
|
|
279
|
+
"definition_error": automation.definition_error,
|
|
280
|
+
"created_at": automation.created_at,
|
|
281
|
+
"updated_at": automation.updated_at,
|
|
282
|
+
"last_run_at": automation.last_run_at,
|
|
283
|
+
"next_run_at": automation.next_run_at,
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
if include_latest_run:
|
|
287
|
+
runs = self.server.runtime.store.list_runs(automation.id, limit=1)
|
|
288
|
+
payload["latest_run"] = self._serialize_run(runs[0]) if runs else None
|
|
289
|
+
return payload
|
|
290
|
+
|
|
291
|
+
def _serialize_run(self, run) -> dict:
|
|
292
|
+
return {
|
|
293
|
+
"run_id": run.run_id,
|
|
294
|
+
"automation_id": run.automation_id,
|
|
295
|
+
"trigger_type": run.trigger_type,
|
|
296
|
+
"status": run.status,
|
|
297
|
+
"effective_inputs": run.effective_inputs,
|
|
298
|
+
"attempt_number": run.attempt_number,
|
|
299
|
+
"queued_at": run.queued_at,
|
|
300
|
+
"started_at": run.started_at,
|
|
301
|
+
"finished_at": run.finished_at,
|
|
302
|
+
"error_code": run.error_code,
|
|
303
|
+
"error_message": run.error_message,
|
|
304
|
+
"result_json_path": str(run.result_json_path) if run.result_json_path else None,
|
|
305
|
+
"artifacts_dir": str(run.artifacts_dir) if run.artifacts_dir else None,
|
|
306
|
+
"log_path": str(run.log_path) if run.log_path else None,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
def _serialize_event(self, event) -> dict:
|
|
310
|
+
return {
|
|
311
|
+
"event_type": event.event_type,
|
|
312
|
+
"message": event.message,
|
|
313
|
+
"created_at": event.created_at,
|
|
314
|
+
"payload": event.payload,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _read_text(path: Path | None) -> str:
|
|
319
|
+
if path is None or not path.exists():
|
|
320
|
+
return ""
|
|
321
|
+
return path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Automation hook execution helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_hook_commands(
|
|
12
|
+
commands: tuple[str, ...],
|
|
13
|
+
*,
|
|
14
|
+
cwd: Path,
|
|
15
|
+
extra_env: dict[str, Any] | None = None,
|
|
16
|
+
timeout_seconds: float | None = None,
|
|
17
|
+
) -> list[dict[str, Any]]:
|
|
18
|
+
results: list[dict[str, Any]] = []
|
|
19
|
+
env = os.environ.copy()
|
|
20
|
+
if extra_env:
|
|
21
|
+
env.update({key: str(value) for key, value in extra_env.items() if value is not None})
|
|
22
|
+
for command in commands:
|
|
23
|
+
completed = subprocess.run(
|
|
24
|
+
command,
|
|
25
|
+
shell=True,
|
|
26
|
+
cwd=str(cwd),
|
|
27
|
+
env=env,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=timeout_seconds,
|
|
31
|
+
)
|
|
32
|
+
results.append(
|
|
33
|
+
{
|
|
34
|
+
"command": command,
|
|
35
|
+
"returncode": completed.returncode,
|
|
36
|
+
"stdout": completed.stdout,
|
|
37
|
+
"stderr": completed.stderr,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
if completed.returncode != 0:
|
|
41
|
+
raise RuntimeError(f"Hook failed: {command}")
|
|
42
|
+
return results
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Automation manifest loading helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else: # pragma: no cover
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
from browser_cli.automation.models import (
|
|
16
|
+
AutomationHooks,
|
|
17
|
+
AutomationIdentity,
|
|
18
|
+
AutomationManifest,
|
|
19
|
+
AutomationOutputs,
|
|
20
|
+
AutomationRuntime,
|
|
21
|
+
AutomationTaskConfig,
|
|
22
|
+
)
|
|
23
|
+
from browser_cli.errors import InvalidInputError
|
|
24
|
+
from browser_cli.task_runtime import validate_task_metadata
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_automation_manifest(path: str | Path) -> AutomationManifest:
|
|
28
|
+
manifest_path = Path(path).expanduser().resolve()
|
|
29
|
+
if not manifest_path.exists():
|
|
30
|
+
raise InvalidInputError(f"Automation manifest does not exist: {manifest_path}")
|
|
31
|
+
try:
|
|
32
|
+
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, tomllib.TOMLDecodeError, ValueError) as exc:
|
|
34
|
+
raise InvalidInputError(
|
|
35
|
+
f"Automation manifest is invalid TOML: {manifest_path}: {exc}"
|
|
36
|
+
) from exc
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
raise InvalidInputError(f"Automation manifest must be a TOML object: {manifest_path}")
|
|
39
|
+
|
|
40
|
+
automation_section = _require_section(data, "automation", manifest_path)
|
|
41
|
+
task_section = _require_section(data, "task", manifest_path)
|
|
42
|
+
base_dir = manifest_path.parent
|
|
43
|
+
task_path = _resolve_relative(base_dir, _require_string(task_section, "path", manifest_path))
|
|
44
|
+
meta_path = _resolve_relative(
|
|
45
|
+
base_dir, _require_string(task_section, "meta_path", manifest_path)
|
|
46
|
+
)
|
|
47
|
+
if not task_path.exists():
|
|
48
|
+
raise InvalidInputError(f"Task file does not exist: {task_path}")
|
|
49
|
+
if not meta_path.exists():
|
|
50
|
+
raise InvalidInputError(f"Task metadata file does not exist: {meta_path}")
|
|
51
|
+
_load_task_metadata(meta_path)
|
|
52
|
+
return AutomationManifest(
|
|
53
|
+
manifest_path=manifest_path,
|
|
54
|
+
automation=AutomationIdentity(
|
|
55
|
+
id=_require_string(automation_section, "id", manifest_path),
|
|
56
|
+
name=_require_string(automation_section, "name", manifest_path),
|
|
57
|
+
description=str(automation_section.get("description") or ""),
|
|
58
|
+
version=str(automation_section.get("version") or "1"),
|
|
59
|
+
),
|
|
60
|
+
task=AutomationTaskConfig(
|
|
61
|
+
path=task_path,
|
|
62
|
+
meta_path=meta_path,
|
|
63
|
+
entrypoint=str(task_section.get("entrypoint") or "run"),
|
|
64
|
+
),
|
|
65
|
+
inputs=dict(data.get("inputs") or {}),
|
|
66
|
+
schedule=dict(data.get("schedule") or {}),
|
|
67
|
+
outputs=AutomationOutputs(
|
|
68
|
+
artifact_dir=_resolve_relative(
|
|
69
|
+
base_dir,
|
|
70
|
+
str((data.get("outputs") or {}).get("artifact_dir") or "artifacts"),
|
|
71
|
+
),
|
|
72
|
+
result_json_path=(
|
|
73
|
+
_resolve_relative(
|
|
74
|
+
base_dir, str((data.get("outputs") or {}).get("result_json_path"))
|
|
75
|
+
)
|
|
76
|
+
if (data.get("outputs") or {}).get("result_json_path")
|
|
77
|
+
else None
|
|
78
|
+
),
|
|
79
|
+
stdout=str((data.get("outputs") or {}).get("stdout") or "json"),
|
|
80
|
+
),
|
|
81
|
+
hooks=AutomationHooks(
|
|
82
|
+
before_run=tuple(_as_string_list((data.get("hooks") or {}).get("before_run"))),
|
|
83
|
+
after_success=tuple(_as_string_list((data.get("hooks") or {}).get("after_success"))),
|
|
84
|
+
after_failure=tuple(_as_string_list((data.get("hooks") or {}).get("after_failure"))),
|
|
85
|
+
),
|
|
86
|
+
runtime=AutomationRuntime(
|
|
87
|
+
timeout_seconds=float((data.get("runtime") or {})["timeout_seconds"])
|
|
88
|
+
if (data.get("runtime") or {}).get("timeout_seconds") is not None
|
|
89
|
+
else None,
|
|
90
|
+
retry_attempts=int((data.get("runtime") or {}).get("retry_attempts") or 0),
|
|
91
|
+
retry_backoff_seconds=int(
|
|
92
|
+
(data.get("runtime") or {}).get("retry_backoff_seconds") or 0
|
|
93
|
+
),
|
|
94
|
+
log_level=str((data.get("runtime") or {}).get("log_level") or "info"),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _require_section(data: dict[str, Any], key: str, source: Path) -> dict[str, Any]:
|
|
100
|
+
value = data.get(key)
|
|
101
|
+
if not isinstance(value, dict):
|
|
102
|
+
raise InvalidInputError(f"Automation manifest requires a [{key}] section: {source}")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _require_string(section: dict[str, Any], key: str, source: Path) -> str:
|
|
107
|
+
value = section.get(key)
|
|
108
|
+
if not isinstance(value, str) or not value.strip():
|
|
109
|
+
raise InvalidInputError(f"Automation manifest requires non-empty {key!r}: {source}")
|
|
110
|
+
return value.strip()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_relative(base_dir: Path, value: str) -> Path:
|
|
114
|
+
path = Path(value).expanduser()
|
|
115
|
+
if not path.is_absolute():
|
|
116
|
+
path = (base_dir / path).resolve()
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _as_string_list(value: Any) -> list[str]:
|
|
121
|
+
if value is None:
|
|
122
|
+
return []
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return [str(item) for item in value if str(item).strip()]
|
|
125
|
+
return [str(value)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_task_metadata(meta_path: Path) -> dict[str, Any]:
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
131
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
132
|
+
raise InvalidInputError(f"Task metadata is invalid JSON: {meta_path}: {exc}") from exc
|
|
133
|
+
validate_task_metadata(payload, source=str(meta_path))
|
|
134
|
+
return payload
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Automation manifest and service dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True, frozen=True)
|
|
11
|
+
class AutomationIdentity:
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
description: str = ""
|
|
15
|
+
version: str = "0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True, frozen=True)
|
|
19
|
+
class AutomationTaskConfig:
|
|
20
|
+
path: Path
|
|
21
|
+
meta_path: Path
|
|
22
|
+
entrypoint: str = "run"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True, frozen=True)
|
|
26
|
+
class AutomationOutputs:
|
|
27
|
+
artifact_dir: Path
|
|
28
|
+
result_json_path: Path | None = None
|
|
29
|
+
stdout: str = "json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class AutomationHooks:
|
|
34
|
+
before_run: tuple[str, ...] = ()
|
|
35
|
+
after_success: tuple[str, ...] = ()
|
|
36
|
+
after_failure: tuple[str, ...] = ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True, frozen=True)
|
|
40
|
+
class AutomationRuntime:
|
|
41
|
+
timeout_seconds: float | None = None
|
|
42
|
+
retry_attempts: int = 0
|
|
43
|
+
retry_backoff_seconds: int = 0
|
|
44
|
+
log_level: str = "info"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True, frozen=True)
|
|
48
|
+
class AutomationManifest:
|
|
49
|
+
manifest_path: Path
|
|
50
|
+
automation: AutomationIdentity
|
|
51
|
+
task: AutomationTaskConfig
|
|
52
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
schedule: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
outputs: AutomationOutputs = field(
|
|
55
|
+
default_factory=lambda: AutomationOutputs(artifact_dir=Path("artifacts"))
|
|
56
|
+
)
|
|
57
|
+
hooks: AutomationHooks = field(default_factory=AutomationHooks)
|
|
58
|
+
runtime: AutomationRuntime = field(default_factory=AutomationRuntime)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True, frozen=True)
|
|
62
|
+
class PersistedAutomationDefinition:
|
|
63
|
+
id: str
|
|
64
|
+
name: str
|
|
65
|
+
task_path: Path
|
|
66
|
+
task_meta_path: Path
|
|
67
|
+
output_dir: Path
|
|
68
|
+
description: str = ""
|
|
69
|
+
version: str = "0.1.0"
|
|
70
|
+
entrypoint: str = "run"
|
|
71
|
+
enabled: bool = False
|
|
72
|
+
definition_status: str = "valid"
|
|
73
|
+
definition_error: str | None = None
|
|
74
|
+
schedule_kind: str = "manual"
|
|
75
|
+
schedule_payload: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
timezone: str = "UTC"
|
|
77
|
+
result_json_path: Path | None = None
|
|
78
|
+
stdout_mode: str = "json"
|
|
79
|
+
input_overrides: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
before_run_hooks: tuple[str, ...] = ()
|
|
81
|
+
after_success_hooks: tuple[str, ...] = ()
|
|
82
|
+
after_failure_hooks: tuple[str, ...] = ()
|
|
83
|
+
retry_attempts: int = 0
|
|
84
|
+
retry_backoff_seconds: int = 0
|
|
85
|
+
timeout_seconds: float | None = None
|
|
86
|
+
log_level: str = "info"
|
|
87
|
+
created_at: str | None = None
|
|
88
|
+
updated_at: str | None = None
|
|
89
|
+
last_run_at: str | None = None
|
|
90
|
+
next_run_at: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True, frozen=True)
|
|
94
|
+
class AutomationRunRecord:
|
|
95
|
+
run_id: str
|
|
96
|
+
automation_id: str
|
|
97
|
+
trigger_type: str
|
|
98
|
+
status: str
|
|
99
|
+
effective_inputs: dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
attempt_number: int = 0
|
|
101
|
+
queued_at: str | None = None
|
|
102
|
+
started_at: str | None = None
|
|
103
|
+
finished_at: str | None = None
|
|
104
|
+
error_code: str | None = None
|
|
105
|
+
error_message: str | None = None
|
|
106
|
+
result_json_path: Path | None = None
|
|
107
|
+
artifacts_dir: Path | None = None
|
|
108
|
+
log_path: Path | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True, frozen=True)
|
|
112
|
+
class AutomationRunEvent:
|
|
113
|
+
run_id: str
|
|
114
|
+
event_type: str
|
|
115
|
+
message: str = ""
|
|
116
|
+
created_at: str | None = None
|
|
117
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def manifest_to_persisted_definition(
|
|
121
|
+
manifest: AutomationManifest,
|
|
122
|
+
*,
|
|
123
|
+
enabled: bool = False,
|
|
124
|
+
) -> PersistedAutomationDefinition:
|
|
125
|
+
from browser_cli.automation.projections import manifest_to_persisted_definition as _impl
|
|
126
|
+
|
|
127
|
+
return _impl(manifest, enabled=enabled)
|