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,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,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,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())
|