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,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,5 @@
1
+ """automation service API helpers."""
2
+
3
+ from browser_cli.automation.api.server import AutomationHttpServer, AutomationRequestHandler
4
+
5
+ __all__ = ["AutomationHttpServer", "AutomationRequestHandler"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ """automation persistence helpers."""
2
+
3
+ from browser_cli.automation.persistence.store import AutomationStore
4
+
5
+ __all__ = ["AutomationStore"]