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,279 @@
1
+ """Shared semantic projections for automation manifests and live definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from browser_cli.automation.models import (
9
+ AutomationManifest,
10
+ PersistedAutomationDefinition,
11
+ )
12
+ from browser_cli.automation.toml import dumps_toml_sections
13
+
14
+
15
+ def manifest_to_persisted_definition(
16
+ manifest: AutomationManifest,
17
+ *,
18
+ enabled: bool = False,
19
+ ) -> PersistedAutomationDefinition:
20
+ return PersistedAutomationDefinition(
21
+ id=manifest.automation.id,
22
+ name=manifest.automation.name,
23
+ task_path=manifest.task.path,
24
+ task_meta_path=manifest.task.meta_path,
25
+ output_dir=manifest.outputs.artifact_dir,
26
+ description=manifest.automation.description,
27
+ version=str(manifest.automation.version),
28
+ entrypoint=manifest.task.entrypoint,
29
+ enabled=enabled,
30
+ schedule_kind=str(manifest.schedule.get("mode") or "manual"),
31
+ schedule_payload=dict(manifest.schedule),
32
+ timezone=str(manifest.schedule.get("timezone") or "UTC"),
33
+ result_json_path=manifest.outputs.result_json_path,
34
+ stdout_mode=manifest.outputs.stdout,
35
+ input_overrides=dict(manifest.inputs),
36
+ before_run_hooks=manifest.hooks.before_run,
37
+ after_success_hooks=manifest.hooks.after_success,
38
+ after_failure_hooks=manifest.hooks.after_failure,
39
+ retry_attempts=manifest.runtime.retry_attempts,
40
+ retry_backoff_seconds=manifest.runtime.retry_backoff_seconds,
41
+ timeout_seconds=manifest.runtime.timeout_seconds,
42
+ log_level=manifest.runtime.log_level,
43
+ )
44
+
45
+
46
+ def payload_to_persisted_definition(payload: dict[str, Any]) -> PersistedAutomationDefinition:
47
+ automation_id = str(payload.get("id") or "").strip()
48
+ if not automation_id:
49
+ raise ValueError("Automation id is required.")
50
+ output_dir_raw = str(payload.get("output_dir") or "").strip()
51
+ result_json_raw = str(payload.get("result_json_path") or "").strip()
52
+ return PersistedAutomationDefinition(
53
+ id=automation_id,
54
+ name=str(payload.get("name") or automation_id),
55
+ description=str(payload.get("description") or ""),
56
+ version=str(payload.get("version") or "0.1.0"),
57
+ task_path=Path(str(payload.get("task_path") or "")),
58
+ task_meta_path=Path(str(payload.get("task_meta_path") or "")),
59
+ entrypoint=str(payload.get("entrypoint") or "run"),
60
+ enabled=bool(payload.get("enabled")),
61
+ definition_status=str(payload.get("definition_status") or "valid"),
62
+ definition_error=str(payload.get("definition_error"))
63
+ if payload.get("definition_error")
64
+ else None,
65
+ schedule_kind=str(payload.get("schedule_kind") or "manual"),
66
+ schedule_payload=dict(payload.get("schedule_payload") or {}),
67
+ timezone=str(payload.get("timezone") or "UTC"),
68
+ output_dir=Path(output_dir_raw) if output_dir_raw else Path(),
69
+ result_json_path=Path(result_json_raw) if result_json_raw else None,
70
+ stdout_mode=str(payload.get("stdout_mode") or "json"),
71
+ input_overrides=dict(payload.get("input_overrides") or {}),
72
+ before_run_hooks=tuple(payload.get("before_run_hooks") or []),
73
+ after_success_hooks=tuple(payload.get("after_success_hooks") or []),
74
+ after_failure_hooks=tuple(payload.get("after_failure_hooks") or []),
75
+ retry_attempts=int(payload.get("retry_attempts") or 0),
76
+ retry_backoff_seconds=int(payload.get("retry_backoff_seconds") or 0),
77
+ timeout_seconds=float(payload["timeout_seconds"])
78
+ if payload.get("timeout_seconds") is not None
79
+ else None,
80
+ log_level=str(payload.get("log_level") or "info"),
81
+ )
82
+
83
+
84
+ def persisted_definition_to_manifest_toml(
85
+ automation: PersistedAutomationDefinition,
86
+ ) -> str:
87
+ return dumps_toml_sections(
88
+ [
89
+ (
90
+ "automation",
91
+ {
92
+ "id": automation.id,
93
+ "name": automation.name,
94
+ "description": automation.description,
95
+ "version": automation.version,
96
+ },
97
+ ),
98
+ (
99
+ "task",
100
+ {
101
+ "path": str(automation.task_path),
102
+ "meta_path": str(automation.task_meta_path),
103
+ "entrypoint": automation.entrypoint,
104
+ },
105
+ ),
106
+ ("inputs", dict(automation.input_overrides)),
107
+ ("schedule", _schedule_values(automation)),
108
+ (
109
+ "outputs",
110
+ {
111
+ "artifact_dir": str(automation.output_dir),
112
+ "result_json_path": str(automation.result_json_path)
113
+ if automation.result_json_path
114
+ else None,
115
+ "stdout": automation.stdout_mode,
116
+ },
117
+ ),
118
+ (
119
+ "hooks",
120
+ {
121
+ "before_run": list(automation.before_run_hooks),
122
+ "after_success": list(automation.after_success_hooks),
123
+ "after_failure": list(automation.after_failure_hooks),
124
+ },
125
+ ),
126
+ (
127
+ "runtime",
128
+ {
129
+ "retry_attempts": automation.retry_attempts,
130
+ "retry_backoff_seconds": automation.retry_backoff_seconds,
131
+ "timeout_seconds": automation.timeout_seconds,
132
+ "log_level": automation.log_level,
133
+ },
134
+ ),
135
+ ]
136
+ )
137
+
138
+
139
+ def manifest_to_snapshot_manifest_toml(
140
+ manifest: AutomationManifest,
141
+ *,
142
+ version: int,
143
+ task_path: Path,
144
+ task_meta_path: Path,
145
+ output_dir: Path,
146
+ ) -> str:
147
+ result_json_path = _remap_result_json_path(
148
+ manifest.outputs.artifact_dir,
149
+ manifest.outputs.result_json_path,
150
+ output_dir,
151
+ )
152
+ return dumps_toml_sections(
153
+ [
154
+ (
155
+ "automation",
156
+ {
157
+ "id": manifest.automation.id,
158
+ "name": manifest.automation.name,
159
+ "description": manifest.automation.description,
160
+ "version": str(version),
161
+ },
162
+ ),
163
+ (
164
+ "task",
165
+ {
166
+ "path": str(task_path),
167
+ "meta_path": str(task_meta_path),
168
+ "entrypoint": manifest.task.entrypoint,
169
+ },
170
+ ),
171
+ ("inputs", dict(manifest.inputs)),
172
+ ("schedule", dict(manifest.schedule)),
173
+ (
174
+ "outputs",
175
+ {
176
+ "artifact_dir": str(output_dir),
177
+ "result_json_path": str(result_json_path) if result_json_path else None,
178
+ "stdout": manifest.outputs.stdout,
179
+ },
180
+ ),
181
+ (
182
+ "hooks",
183
+ {
184
+ "before_run": list(manifest.hooks.before_run),
185
+ "after_success": list(manifest.hooks.after_success),
186
+ "after_failure": list(manifest.hooks.after_failure),
187
+ },
188
+ ),
189
+ (
190
+ "runtime",
191
+ {
192
+ "retry_attempts": manifest.runtime.retry_attempts,
193
+ "retry_backoff_seconds": manifest.runtime.retry_backoff_seconds,
194
+ "timeout_seconds": manifest.runtime.timeout_seconds,
195
+ "log_level": manifest.runtime.log_level,
196
+ },
197
+ ),
198
+ ]
199
+ )
200
+
201
+
202
+ def manifest_to_config_payload(manifest: AutomationManifest) -> dict[str, object]:
203
+ return {
204
+ "id": manifest.automation.id,
205
+ "name": manifest.automation.name,
206
+ "description": manifest.automation.description,
207
+ "version": str(manifest.automation.version),
208
+ "task_path": str(manifest.task.path),
209
+ "task_meta_path": str(manifest.task.meta_path),
210
+ "entrypoint": manifest.task.entrypoint,
211
+ "schedule_kind": str(manifest.schedule.get("mode") or "manual"),
212
+ "schedule_payload": dict(manifest.schedule),
213
+ "timezone": str(manifest.schedule.get("timezone") or "UTC"),
214
+ "output_dir": str(manifest.outputs.artifact_dir),
215
+ "result_json_path": str(manifest.outputs.result_json_path)
216
+ if manifest.outputs.result_json_path
217
+ else None,
218
+ "stdout_mode": manifest.outputs.stdout,
219
+ "input_overrides": dict(manifest.inputs),
220
+ "before_run_hooks": list(manifest.hooks.before_run),
221
+ "after_success_hooks": list(manifest.hooks.after_success),
222
+ "after_failure_hooks": list(manifest.hooks.after_failure),
223
+ "retry_attempts": manifest.runtime.retry_attempts,
224
+ "retry_backoff_seconds": manifest.runtime.retry_backoff_seconds,
225
+ "timeout_seconds": manifest.runtime.timeout_seconds,
226
+ "log_level": manifest.runtime.log_level,
227
+ }
228
+
229
+
230
+ def persisted_definition_to_config_payload(
231
+ automation: PersistedAutomationDefinition,
232
+ ) -> dict[str, object]:
233
+ return {
234
+ "id": automation.id,
235
+ "name": automation.name,
236
+ "description": automation.description,
237
+ "version": automation.version,
238
+ "task_path": str(automation.task_path),
239
+ "task_meta_path": str(automation.task_meta_path),
240
+ "entrypoint": automation.entrypoint,
241
+ "schedule_kind": automation.schedule_kind,
242
+ "schedule_payload": dict(automation.schedule_payload),
243
+ "timezone": automation.timezone,
244
+ "output_dir": str(automation.output_dir),
245
+ "result_json_path": str(automation.result_json_path)
246
+ if automation.result_json_path
247
+ else None,
248
+ "stdout_mode": automation.stdout_mode,
249
+ "input_overrides": dict(automation.input_overrides),
250
+ "before_run_hooks": list(automation.before_run_hooks),
251
+ "after_success_hooks": list(automation.after_success_hooks),
252
+ "after_failure_hooks": list(automation.after_failure_hooks),
253
+ "retry_attempts": automation.retry_attempts,
254
+ "retry_backoff_seconds": automation.retry_backoff_seconds,
255
+ "timeout_seconds": automation.timeout_seconds,
256
+ "log_level": automation.log_level,
257
+ }
258
+
259
+
260
+ def _schedule_values(automation: PersistedAutomationDefinition) -> dict[str, Any]:
261
+ values = {"mode": automation.schedule_kind, "timezone": automation.timezone}
262
+ for key, value in automation.schedule_payload.items():
263
+ if key not in {"mode", "timezone"}:
264
+ values[key] = value
265
+ return values
266
+
267
+
268
+ def _remap_result_json_path(
269
+ source_artifact_dir: Path,
270
+ source_result_json_path: Path | None,
271
+ target_artifact_dir: Path,
272
+ ) -> Path | None:
273
+ if source_result_json_path is None:
274
+ return None
275
+ try:
276
+ relative = source_result_json_path.relative_to(source_artifact_dir)
277
+ except ValueError:
278
+ return target_artifact_dir / source_result_json_path.name
279
+ return target_artifact_dir / relative
@@ -0,0 +1,153 @@
1
+ """Publish local task directories as versioned automation snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from browser_cli.automation.loader import load_automation_manifest
12
+ from browser_cli.automation.projections import manifest_to_snapshot_manifest_toml
13
+ from browser_cli.automation.toml import dumps_toml_sections
14
+ from browser_cli.constants import AppPaths
15
+ from browser_cli.task_runtime import validate_task_dir
16
+
17
+
18
+ @dataclass(slots=True, frozen=True)
19
+ class PublishedAutomation:
20
+ automation_id: str
21
+ automation_name: str
22
+ version: int
23
+ snapshot_dir: Path
24
+ manifest_path: Path
25
+ output_dir: Path
26
+ manifest_source: str
27
+
28
+
29
+ def publish_task_dir(task_dir: Path, *, app_paths: AppPaths) -> PublishedAutomation:
30
+ metadata = validate_task_dir(task_dir)
31
+ automation_id = str(metadata["task"]["id"])
32
+ automation_name = str(metadata["task"]["name"])
33
+ automation_root = app_paths.automations_dir / automation_id
34
+ versions_dir = automation_root / "versions"
35
+ versions_dir.mkdir(parents=True, exist_ok=True)
36
+ while True:
37
+ version = _next_version(versions_dir)
38
+ snapshot_dir = versions_dir / str(version)
39
+ try:
40
+ snapshot_dir.mkdir(parents=True, exist_ok=False)
41
+ except FileExistsError:
42
+ continue
43
+ break
44
+
45
+ task_path = snapshot_dir / "task.py"
46
+ task_meta_path = snapshot_dir / "task.meta.json"
47
+ shutil.copy2(task_dir / "task.py", task_path)
48
+ shutil.copy2(task_dir / "task.meta.json", task_meta_path)
49
+
50
+ source_manifest_path = task_dir / "automation.toml"
51
+ if source_manifest_path.exists():
52
+ manifest_source = "task_dir"
53
+ source_manifest = load_automation_manifest(source_manifest_path)
54
+ rendered_manifest = manifest_to_snapshot_manifest_toml(
55
+ source_manifest,
56
+ version=version,
57
+ task_path=task_path,
58
+ task_meta_path=task_meta_path,
59
+ output_dir=automation_root,
60
+ )
61
+ else:
62
+ manifest_source = "generated_defaults"
63
+ rendered_manifest = render_automation_manifest(
64
+ automation_id=automation_id,
65
+ name=automation_name,
66
+ version=version,
67
+ task_path=task_path,
68
+ task_meta_path=task_meta_path,
69
+ output_dir=automation_root,
70
+ )
71
+
72
+ manifest_path = snapshot_dir / "automation.toml"
73
+ manifest_path.write_text(rendered_manifest, encoding="utf-8")
74
+ (snapshot_dir / "publish.json").write_text(
75
+ json.dumps(
76
+ {
77
+ "automation_id": automation_id,
78
+ "name": automation_name,
79
+ "version": version,
80
+ "manifest_source": manifest_source,
81
+ "source_task_path": str(task_dir),
82
+ "snapshot_dir": str(snapshot_dir),
83
+ },
84
+ ensure_ascii=False,
85
+ indent=2,
86
+ ),
87
+ encoding="utf-8",
88
+ )
89
+ return PublishedAutomation(
90
+ automation_id=automation_id,
91
+ automation_name=automation_name,
92
+ version=version,
93
+ snapshot_dir=snapshot_dir,
94
+ manifest_path=manifest_path,
95
+ output_dir=automation_root,
96
+ manifest_source=manifest_source,
97
+ )
98
+
99
+
100
+ def render_automation_manifest(
101
+ *,
102
+ automation_id: str,
103
+ name: str,
104
+ version: int,
105
+ task_path: Path,
106
+ task_meta_path: Path,
107
+ output_dir: Path,
108
+ ) -> str:
109
+ sections: list[tuple[str, dict[str, Any]]] = [
110
+ (
111
+ "automation",
112
+ {
113
+ "id": str(automation_id),
114
+ "name": str(name),
115
+ "version": str(version),
116
+ },
117
+ ),
118
+ (
119
+ "task",
120
+ {
121
+ "path": str(task_path),
122
+ "meta_path": str(task_meta_path),
123
+ "entrypoint": "run",
124
+ },
125
+ ),
126
+ ("inputs", {}),
127
+ ("schedule", {"mode": "manual", "timezone": "UTC"}),
128
+ ("outputs", {"artifact_dir": str(output_dir), "stdout": "json"}),
129
+ (
130
+ "hooks",
131
+ {
132
+ "before_run": [],
133
+ "after_success": [],
134
+ "after_failure": [],
135
+ },
136
+ ),
137
+ (
138
+ "runtime",
139
+ {
140
+ "retry_attempts": 0,
141
+ "retry_backoff_seconds": 0,
142
+ "log_level": "info",
143
+ },
144
+ ),
145
+ ]
146
+ return dumps_toml_sections(sections)
147
+
148
+
149
+ def _next_version(versions_dir: Path) -> int:
150
+ versions = [
151
+ int(path.name) for path in versions_dir.iterdir() if path.is_dir() and path.name.isdigit()
152
+ ]
153
+ return max(versions, default=0) + 1
@@ -0,0 +1,5 @@
1
+ """automation schedule helpers."""
2
+
3
+ from browser_cli.automation.scheduler.schedule import compute_next_run_at, normalize_schedule
4
+
5
+ __all__ = ["compute_next_run_at", "normalize_schedule"]
@@ -0,0 +1,113 @@
1
+ """Schedule normalization and next-run calculation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from browser_cli.errors import InvalidInputError
10
+
11
+ WEEKDAY_NAMES = {
12
+ "monday": 0,
13
+ "tuesday": 1,
14
+ "wednesday": 2,
15
+ "thursday": 3,
16
+ "friday": 4,
17
+ "saturday": 5,
18
+ "sunday": 6,
19
+ }
20
+
21
+
22
+ def normalize_schedule(
23
+ kind: str,
24
+ payload: dict[str, Any] | None,
25
+ *,
26
+ timezone_name: str,
27
+ ) -> tuple[str, dict[str, Any], str]:
28
+ normalized_kind = (kind or "manual").strip().lower() or "manual"
29
+ normalized_payload = dict(payload or {})
30
+ try:
31
+ ZoneInfo(timezone_name)
32
+ except Exception as exc:
33
+ raise InvalidInputError(f"Invalid automation timezone: {timezone_name}") from exc
34
+
35
+ if normalized_kind == "manual":
36
+ return normalized_kind, {}, timezone_name
37
+ if normalized_kind == "interval":
38
+ seconds = int(normalized_payload.get("interval_seconds") or 0)
39
+ if seconds <= 0:
40
+ raise InvalidInputError("Interval automations require positive interval_seconds.")
41
+ return normalized_kind, {"interval_seconds": seconds}, timezone_name
42
+ if normalized_kind == "daily":
43
+ hour = int(
44
+ normalized_payload.get("hour") if normalized_payload.get("hour") is not None else -1
45
+ )
46
+ minute = int(
47
+ normalized_payload.get("minute") if normalized_payload.get("minute") is not None else -1
48
+ )
49
+ _validate_hour_minute(hour, minute)
50
+ return normalized_kind, {"hour": hour, "minute": minute}, timezone_name
51
+ if normalized_kind == "weekly":
52
+ weekday = str(normalized_payload.get("weekday") or "").strip().lower()
53
+ hour = int(
54
+ normalized_payload.get("hour") if normalized_payload.get("hour") is not None else -1
55
+ )
56
+ minute = int(
57
+ normalized_payload.get("minute") if normalized_payload.get("minute") is not None else -1
58
+ )
59
+ if weekday not in WEEKDAY_NAMES:
60
+ expected = ", ".join(sorted(WEEKDAY_NAMES))
61
+ raise InvalidInputError(f"Weekly automations require weekday in {{{expected}}}.")
62
+ _validate_hour_minute(hour, minute)
63
+ return normalized_kind, {"weekday": weekday, "hour": hour, "minute": minute}, timezone_name
64
+ raise InvalidInputError(f"Unsupported automation schedule mode: {normalized_kind}")
65
+
66
+
67
+ def compute_next_run_at(
68
+ kind: str,
69
+ payload: dict[str, Any] | None,
70
+ *,
71
+ timezone_name: str,
72
+ now: datetime | None = None,
73
+ ) -> str | None:
74
+ normalized_kind, normalized_payload, timezone_name = normalize_schedule(
75
+ kind, payload, timezone_name=timezone_name
76
+ )
77
+ if normalized_kind == "manual":
78
+ return None
79
+ moment = now or datetime.now(timezone.utc)
80
+ if moment.tzinfo is None:
81
+ moment = moment.replace(tzinfo=timezone.utc)
82
+ if normalized_kind == "interval":
83
+ seconds = int(normalized_payload["interval_seconds"])
84
+ return _to_utc_iso(moment + timedelta(seconds=seconds))
85
+ zone = ZoneInfo(timezone_name)
86
+ local_now = moment.astimezone(zone)
87
+ if normalized_kind == "daily":
88
+ hour = int(normalized_payload["hour"])
89
+ minute = int(normalized_payload["minute"])
90
+ candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
91
+ if candidate <= local_now:
92
+ candidate = candidate + timedelta(days=1)
93
+ return _to_utc_iso(candidate.astimezone(timezone.utc))
94
+ weekday = WEEKDAY_NAMES[str(normalized_payload["weekday"])]
95
+ hour = int(normalized_payload["hour"])
96
+ minute = int(normalized_payload["minute"])
97
+ candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
98
+ days_ahead = (weekday - local_now.weekday()) % 7
99
+ candidate = candidate + timedelta(days=days_ahead)
100
+ if candidate <= local_now:
101
+ candidate = candidate + timedelta(days=7)
102
+ return _to_utc_iso(candidate.astimezone(timezone.utc))
103
+
104
+
105
+ def _validate_hour_minute(hour: int, minute: int) -> None:
106
+ if hour < 0 or hour > 23:
107
+ raise InvalidInputError("automation schedule hour must be between 0 and 23.")
108
+ if minute < 0 or minute > 59:
109
+ raise InvalidInputError("automation schedule minute must be between 0 and 59.")
110
+
111
+
112
+ def _to_utc_iso(value: datetime) -> str:
113
+ return value.astimezone(timezone.utc).isoformat()
@@ -0,0 +1,8 @@
1
+ """automation service helpers."""
2
+
3
+ from browser_cli.automation.service.client import (
4
+ ensure_automation_service_running,
5
+ request_automation_service,
6
+ )
7
+
8
+ __all__ = ["ensure_automation_service_running", "request_automation_service"]
@@ -0,0 +1,64 @@
1
+ """automation service process entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from browser_cli import __version__
11
+ from browser_cli.automation.api import AutomationHttpServer, AutomationRequestHandler
12
+ from browser_cli.automation.service.client import (
13
+ _write_automation_service_run_info,
14
+ remove_automation_service_run_info,
15
+ )
16
+ from browser_cli.automation.service.runtime import (
17
+ AUTOMATION_SERVICE_RUNTIME_VERSION,
18
+ AutomationServiceRuntime,
19
+ )
20
+ from browser_cli.constants import APP_HOME_ENV, get_app_paths
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(prog="browser-cli automation-service")
25
+ parser.add_argument("--home", help="Override Browser CLI home.")
26
+ args = parser.parse_args(argv)
27
+ if args.home:
28
+ os.environ[APP_HOME_ENV] = str(Path(args.home).expanduser())
29
+ logging.basicConfig(level=logging.INFO)
30
+ runtime: AutomationServiceRuntime | None = None
31
+ server: AutomationHttpServer | None = None
32
+ try:
33
+ runtime = AutomationServiceRuntime()
34
+ runtime.start()
35
+ app_paths = get_app_paths()
36
+ server = AutomationHttpServer(
37
+ (app_paths.automation_service_host, app_paths.automation_service_port or 0),
38
+ AutomationRequestHandler,
39
+ runtime,
40
+ )
41
+ host, port = server.server_address[:2]
42
+ _write_automation_service_run_info(
43
+ {
44
+ "pid": os.getpid(),
45
+ "host": host,
46
+ "port": port,
47
+ "started_at": runtime.started_at,
48
+ "package_version": __version__,
49
+ "runtime_version": AUTOMATION_SERVICE_RUNTIME_VERSION,
50
+ }
51
+ )
52
+ server.serve_forever(poll_interval=0.5)
53
+ finally:
54
+ if runtime is not None:
55
+ runtime.stop()
56
+ runtime.join(timeout=2.0)
57
+ if server is not None:
58
+ server.server_close()
59
+ remove_automation_service_run_info()
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__": # pragma: no cover
64
+ raise SystemExit(main())