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,682 @@
|
|
|
1
|
+
"""SQLite-backed automation persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from browser_cli.automation.models import (
|
|
16
|
+
AutomationRunEvent,
|
|
17
|
+
AutomationRunRecord,
|
|
18
|
+
PersistedAutomationDefinition,
|
|
19
|
+
)
|
|
20
|
+
from browser_cli.automation.scheduler import compute_next_run_at, normalize_schedule
|
|
21
|
+
from browser_cli.constants import get_app_paths
|
|
22
|
+
from browser_cli.task_runtime.models import validate_task_metadata
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutomationStore:
|
|
28
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
29
|
+
self._db_path = db_path or get_app_paths().automation_db_path
|
|
30
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._initialize()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def db_path(self) -> Path:
|
|
35
|
+
return self._db_path
|
|
36
|
+
|
|
37
|
+
def _initialize(self) -> None:
|
|
38
|
+
with self._connect() as conn:
|
|
39
|
+
conn.executescript(
|
|
40
|
+
"""
|
|
41
|
+
PRAGMA journal_mode=WAL;
|
|
42
|
+
CREATE TABLE IF NOT EXISTS automations (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
name TEXT NOT NULL,
|
|
45
|
+
description TEXT NOT NULL DEFAULT '',
|
|
46
|
+
version TEXT NOT NULL DEFAULT '0.1.0',
|
|
47
|
+
task_path TEXT NOT NULL,
|
|
48
|
+
task_meta_path TEXT NOT NULL,
|
|
49
|
+
entrypoint TEXT NOT NULL DEFAULT 'run',
|
|
50
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
definition_status TEXT NOT NULL DEFAULT 'valid',
|
|
52
|
+
definition_error TEXT,
|
|
53
|
+
schedule_kind TEXT NOT NULL DEFAULT 'manual',
|
|
54
|
+
schedule_payload_json TEXT NOT NULL DEFAULT '{}',
|
|
55
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
56
|
+
output_dir TEXT NOT NULL,
|
|
57
|
+
result_json_path TEXT,
|
|
58
|
+
stdout_mode TEXT NOT NULL DEFAULT 'json',
|
|
59
|
+
input_overrides_json TEXT NOT NULL DEFAULT '{}',
|
|
60
|
+
before_run_hooks_json TEXT NOT NULL DEFAULT '[]',
|
|
61
|
+
after_success_hooks_json TEXT NOT NULL DEFAULT '[]',
|
|
62
|
+
after_failure_hooks_json TEXT NOT NULL DEFAULT '[]',
|
|
63
|
+
retry_attempts INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
retry_backoff_seconds INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
timeout_seconds REAL,
|
|
66
|
+
log_level TEXT NOT NULL DEFAULT 'info',
|
|
67
|
+
created_at TEXT NOT NULL,
|
|
68
|
+
updated_at TEXT NOT NULL,
|
|
69
|
+
last_run_at TEXT,
|
|
70
|
+
next_run_at TEXT
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS automation_runs (
|
|
73
|
+
run_id TEXT PRIMARY KEY,
|
|
74
|
+
automation_id TEXT NOT NULL,
|
|
75
|
+
trigger_type TEXT NOT NULL,
|
|
76
|
+
status TEXT NOT NULL,
|
|
77
|
+
effective_inputs_json TEXT NOT NULL DEFAULT '{}',
|
|
78
|
+
attempt_number INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
queued_at TEXT NOT NULL,
|
|
80
|
+
started_at TEXT,
|
|
81
|
+
finished_at TEXT,
|
|
82
|
+
error_code TEXT,
|
|
83
|
+
error_message TEXT,
|
|
84
|
+
result_json_path TEXT,
|
|
85
|
+
artifacts_dir TEXT,
|
|
86
|
+
log_path TEXT
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_automation_runs_automation_id
|
|
89
|
+
ON automation_runs(automation_id, queued_at DESC);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_automation_runs_status
|
|
91
|
+
ON automation_runs(status, queued_at ASC);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS automation_run_events (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
run_id TEXT NOT NULL,
|
|
95
|
+
event_type TEXT NOT NULL,
|
|
96
|
+
message TEXT NOT NULL DEFAULT '',
|
|
97
|
+
created_at TEXT NOT NULL,
|
|
98
|
+
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_automation_run_events_run_id
|
|
101
|
+
ON automation_run_events(run_id, created_at ASC);
|
|
102
|
+
"""
|
|
103
|
+
)
|
|
104
|
+
self._ensure_column(
|
|
105
|
+
conn,
|
|
106
|
+
"automations",
|
|
107
|
+
"log_level",
|
|
108
|
+
"TEXT NOT NULL DEFAULT 'info'",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _ensure_column(
|
|
113
|
+
conn: sqlite3.Connection,
|
|
114
|
+
table: str,
|
|
115
|
+
column: str,
|
|
116
|
+
ddl: str,
|
|
117
|
+
) -> None:
|
|
118
|
+
columns = {
|
|
119
|
+
str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
120
|
+
}
|
|
121
|
+
if column not in columns:
|
|
122
|
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
|
123
|
+
|
|
124
|
+
@contextmanager
|
|
125
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
126
|
+
conn = sqlite3.connect(self._db_path, timeout=30.0, isolation_level=None)
|
|
127
|
+
conn.row_factory = sqlite3.Row
|
|
128
|
+
try:
|
|
129
|
+
yield conn
|
|
130
|
+
finally:
|
|
131
|
+
conn.close()
|
|
132
|
+
|
|
133
|
+
def upsert_automation(
|
|
134
|
+
self, automation: PersistedAutomationDefinition
|
|
135
|
+
) -> PersistedAutomationDefinition:
|
|
136
|
+
normalized = self._normalize_automation(automation)
|
|
137
|
+
now = _utcnow()
|
|
138
|
+
created_at = normalized.created_at or self._existing_created_at(normalized.id) or now
|
|
139
|
+
updated = replace(normalized, created_at=created_at, updated_at=now)
|
|
140
|
+
with self._connect() as conn:
|
|
141
|
+
conn.execute(
|
|
142
|
+
"""
|
|
143
|
+
INSERT INTO automations (
|
|
144
|
+
id, name, description, version, task_path, task_meta_path, entrypoint,
|
|
145
|
+
enabled, definition_status, definition_error, schedule_kind,
|
|
146
|
+
schedule_payload_json, timezone, output_dir, result_json_path, stdout_mode,
|
|
147
|
+
input_overrides_json, before_run_hooks_json, after_success_hooks_json,
|
|
148
|
+
after_failure_hooks_json, retry_attempts, retry_backoff_seconds,
|
|
149
|
+
timeout_seconds, log_level, created_at, updated_at, last_run_at, next_run_at
|
|
150
|
+
) VALUES (
|
|
151
|
+
:id, :name, :description, :version, :task_path, :task_meta_path, :entrypoint,
|
|
152
|
+
:enabled, :definition_status, :definition_error, :schedule_kind,
|
|
153
|
+
:schedule_payload_json, :timezone, :output_dir, :result_json_path, :stdout_mode,
|
|
154
|
+
:input_overrides_json, :before_run_hooks_json, :after_success_hooks_json,
|
|
155
|
+
:after_failure_hooks_json, :retry_attempts, :retry_backoff_seconds,
|
|
156
|
+
:timeout_seconds, :log_level, :created_at, :updated_at, :last_run_at, :next_run_at
|
|
157
|
+
)
|
|
158
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
159
|
+
name = excluded.name,
|
|
160
|
+
description = excluded.description,
|
|
161
|
+
version = excluded.version,
|
|
162
|
+
task_path = excluded.task_path,
|
|
163
|
+
task_meta_path = excluded.task_meta_path,
|
|
164
|
+
entrypoint = excluded.entrypoint,
|
|
165
|
+
enabled = excluded.enabled,
|
|
166
|
+
definition_status = excluded.definition_status,
|
|
167
|
+
definition_error = excluded.definition_error,
|
|
168
|
+
schedule_kind = excluded.schedule_kind,
|
|
169
|
+
schedule_payload_json = excluded.schedule_payload_json,
|
|
170
|
+
timezone = excluded.timezone,
|
|
171
|
+
output_dir = excluded.output_dir,
|
|
172
|
+
result_json_path = excluded.result_json_path,
|
|
173
|
+
stdout_mode = excluded.stdout_mode,
|
|
174
|
+
input_overrides_json = excluded.input_overrides_json,
|
|
175
|
+
before_run_hooks_json = excluded.before_run_hooks_json,
|
|
176
|
+
after_success_hooks_json = excluded.after_success_hooks_json,
|
|
177
|
+
after_failure_hooks_json = excluded.after_failure_hooks_json,
|
|
178
|
+
retry_attempts = excluded.retry_attempts,
|
|
179
|
+
retry_backoff_seconds = excluded.retry_backoff_seconds,
|
|
180
|
+
timeout_seconds = excluded.timeout_seconds,
|
|
181
|
+
log_level = excluded.log_level,
|
|
182
|
+
updated_at = excluded.updated_at,
|
|
183
|
+
next_run_at = excluded.next_run_at
|
|
184
|
+
""",
|
|
185
|
+
_automation_to_row(updated),
|
|
186
|
+
)
|
|
187
|
+
return self.get_automation(updated.id)
|
|
188
|
+
|
|
189
|
+
def _existing_created_at(self, automation_id: str) -> str | None:
|
|
190
|
+
with self._connect() as conn:
|
|
191
|
+
row = conn.execute(
|
|
192
|
+
"SELECT created_at FROM automations WHERE id = ?",
|
|
193
|
+
(automation_id,),
|
|
194
|
+
).fetchone()
|
|
195
|
+
return str(row["created_at"]) if row and row["created_at"] else None
|
|
196
|
+
|
|
197
|
+
def get_automation(self, automation_id: str) -> PersistedAutomationDefinition:
|
|
198
|
+
with self._connect() as conn:
|
|
199
|
+
row = conn.execute(
|
|
200
|
+
"SELECT * FROM automations WHERE id = ?", (automation_id,)
|
|
201
|
+
).fetchone()
|
|
202
|
+
if row is None:
|
|
203
|
+
raise KeyError(automation_id)
|
|
204
|
+
return _row_to_automation(row)
|
|
205
|
+
|
|
206
|
+
def list_automations(self) -> list[PersistedAutomationDefinition]:
|
|
207
|
+
with self._connect() as conn:
|
|
208
|
+
rows = conn.execute("SELECT * FROM automations ORDER BY name, id").fetchall()
|
|
209
|
+
return [_row_to_automation(row) for row in rows]
|
|
210
|
+
|
|
211
|
+
def set_enabled(self, automation_id: str, enabled: bool) -> PersistedAutomationDefinition:
|
|
212
|
+
automation = self.get_automation(automation_id)
|
|
213
|
+
updated = replace(automation, enabled=enabled)
|
|
214
|
+
return self.upsert_automation(updated)
|
|
215
|
+
|
|
216
|
+
def create_run(
|
|
217
|
+
self,
|
|
218
|
+
automation_id: str,
|
|
219
|
+
*,
|
|
220
|
+
trigger_type: str,
|
|
221
|
+
effective_inputs: dict[str, Any] | None = None,
|
|
222
|
+
attempt_number: int = 0,
|
|
223
|
+
queued_at: str | None = None,
|
|
224
|
+
) -> AutomationRunRecord:
|
|
225
|
+
automation = self.get_automation(automation_id)
|
|
226
|
+
if automation.definition_status != "valid":
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Automation {automation_id} is invalid: {automation.definition_error or 'invalid'}"
|
|
229
|
+
)
|
|
230
|
+
now = _utcnow()
|
|
231
|
+
run = AutomationRunRecord(
|
|
232
|
+
run_id=uuid.uuid4().hex,
|
|
233
|
+
automation_id=automation.id,
|
|
234
|
+
trigger_type=trigger_type,
|
|
235
|
+
status="queued",
|
|
236
|
+
effective_inputs=dict(
|
|
237
|
+
effective_inputs if effective_inputs is not None else automation.input_overrides
|
|
238
|
+
),
|
|
239
|
+
attempt_number=attempt_number,
|
|
240
|
+
queued_at=queued_at or now,
|
|
241
|
+
)
|
|
242
|
+
with self._connect() as conn:
|
|
243
|
+
conn.execute(
|
|
244
|
+
"""
|
|
245
|
+
INSERT INTO automation_runs (
|
|
246
|
+
run_id, automation_id, trigger_type, status, effective_inputs_json,
|
|
247
|
+
attempt_number, queued_at, started_at, finished_at, error_code,
|
|
248
|
+
error_message, result_json_path, artifacts_dir, log_path
|
|
249
|
+
) VALUES (
|
|
250
|
+
:run_id, :automation_id, :trigger_type, :status, :effective_inputs_json,
|
|
251
|
+
:attempt_number, :queued_at, :started_at, :finished_at, :error_code,
|
|
252
|
+
:error_message, :result_json_path, :artifacts_dir, :log_path
|
|
253
|
+
)
|
|
254
|
+
""",
|
|
255
|
+
_run_to_row(run),
|
|
256
|
+
)
|
|
257
|
+
self.add_run_event(
|
|
258
|
+
run.run_id,
|
|
259
|
+
AutomationRunEvent(
|
|
260
|
+
run_id=run.run_id,
|
|
261
|
+
event_type="queued",
|
|
262
|
+
message=f"Run queued by {trigger_type}.",
|
|
263
|
+
created_at=now,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
return self.get_run(run.run_id)
|
|
267
|
+
|
|
268
|
+
def enqueue_due_runs(self) -> list[str]:
|
|
269
|
+
now = _utcnow()
|
|
270
|
+
queued: list[str] = []
|
|
271
|
+
for automation in self.list_automations():
|
|
272
|
+
if (
|
|
273
|
+
not automation.enabled
|
|
274
|
+
or automation.definition_status != "valid"
|
|
275
|
+
or not automation.next_run_at
|
|
276
|
+
):
|
|
277
|
+
continue
|
|
278
|
+
if automation.schedule_kind == "manual":
|
|
279
|
+
continue
|
|
280
|
+
if automation.next_run_at > now:
|
|
281
|
+
continue
|
|
282
|
+
if self._has_pending_run(automation.id):
|
|
283
|
+
continue
|
|
284
|
+
run = self.create_run(automation.id, trigger_type="scheduled")
|
|
285
|
+
queued.append(run.run_id)
|
|
286
|
+
self._update_next_run(automation.id)
|
|
287
|
+
return queued
|
|
288
|
+
|
|
289
|
+
def _has_pending_run(self, automation_id: str) -> bool:
|
|
290
|
+
with self._connect() as conn:
|
|
291
|
+
row = conn.execute(
|
|
292
|
+
"""
|
|
293
|
+
SELECT COUNT(*) AS count
|
|
294
|
+
FROM automation_runs
|
|
295
|
+
WHERE automation_id = ? AND status IN ('queued', 'running')
|
|
296
|
+
""",
|
|
297
|
+
(automation_id,),
|
|
298
|
+
).fetchone()
|
|
299
|
+
return int(row["count"]) > 0
|
|
300
|
+
|
|
301
|
+
def _update_next_run(self, automation_id: str) -> None:
|
|
302
|
+
automation = self.get_automation(automation_id)
|
|
303
|
+
normalized = self._normalize_automation(automation)
|
|
304
|
+
with self._connect() as conn:
|
|
305
|
+
conn.execute(
|
|
306
|
+
"UPDATE automations SET next_run_at = ?, updated_at = ? WHERE id = ?",
|
|
307
|
+
(normalized.next_run_at, _utcnow(), automation_id),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def claim_next_run(self) -> AutomationRunRecord | None:
|
|
311
|
+
with self._connect() as conn:
|
|
312
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
313
|
+
row = conn.execute(
|
|
314
|
+
"""
|
|
315
|
+
SELECT *
|
|
316
|
+
FROM automation_runs
|
|
317
|
+
WHERE status = 'queued' AND queued_at <= ?
|
|
318
|
+
ORDER BY queued_at ASC
|
|
319
|
+
LIMIT 1
|
|
320
|
+
""",
|
|
321
|
+
(_utcnow(),),
|
|
322
|
+
).fetchone()
|
|
323
|
+
if row is None:
|
|
324
|
+
conn.execute("COMMIT")
|
|
325
|
+
return None
|
|
326
|
+
run_id = str(row["run_id"])
|
|
327
|
+
started_at = _utcnow()
|
|
328
|
+
conn.execute(
|
|
329
|
+
"""
|
|
330
|
+
UPDATE automation_runs
|
|
331
|
+
SET status = 'running', started_at = ?
|
|
332
|
+
WHERE run_id = ?
|
|
333
|
+
""",
|
|
334
|
+
(started_at, run_id),
|
|
335
|
+
)
|
|
336
|
+
conn.execute("COMMIT")
|
|
337
|
+
self.add_run_event(
|
|
338
|
+
run_id,
|
|
339
|
+
AutomationRunEvent(
|
|
340
|
+
run_id=run_id,
|
|
341
|
+
event_type="claimed",
|
|
342
|
+
message="Executor claimed run.",
|
|
343
|
+
created_at=started_at,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
return self.get_run(run_id)
|
|
347
|
+
|
|
348
|
+
def complete_run(
|
|
349
|
+
self,
|
|
350
|
+
run_id: str,
|
|
351
|
+
*,
|
|
352
|
+
status: str,
|
|
353
|
+
error_code: str | None = None,
|
|
354
|
+
error_message: str | None = None,
|
|
355
|
+
result_json_path: Path | None = None,
|
|
356
|
+
artifacts_dir: Path | None = None,
|
|
357
|
+
log_path: Path | None = None,
|
|
358
|
+
) -> AutomationRunRecord:
|
|
359
|
+
finished_at = _utcnow()
|
|
360
|
+
with self._connect() as conn:
|
|
361
|
+
conn.execute(
|
|
362
|
+
"""
|
|
363
|
+
UPDATE automation_runs
|
|
364
|
+
SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
|
|
365
|
+
result_json_path = ?, artifacts_dir = ?, log_path = ?
|
|
366
|
+
WHERE run_id = ?
|
|
367
|
+
""",
|
|
368
|
+
(
|
|
369
|
+
status,
|
|
370
|
+
finished_at,
|
|
371
|
+
error_code,
|
|
372
|
+
error_message,
|
|
373
|
+
str(result_json_path) if result_json_path else None,
|
|
374
|
+
str(artifacts_dir) if artifacts_dir else None,
|
|
375
|
+
str(log_path) if log_path else None,
|
|
376
|
+
run_id,
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
row = conn.execute(
|
|
380
|
+
"SELECT automation_id FROM automation_runs WHERE run_id = ?", (run_id,)
|
|
381
|
+
).fetchone()
|
|
382
|
+
if row is not None and status == "success":
|
|
383
|
+
conn.execute(
|
|
384
|
+
"""
|
|
385
|
+
UPDATE automations
|
|
386
|
+
SET last_run_at = ?, updated_at = ?
|
|
387
|
+
WHERE id = ?
|
|
388
|
+
""",
|
|
389
|
+
(finished_at, finished_at, str(row["automation_id"])),
|
|
390
|
+
)
|
|
391
|
+
event_type = "task_succeeded" if status == "success" else "task_failed"
|
|
392
|
+
message = (
|
|
393
|
+
"Task completed successfully."
|
|
394
|
+
if status == "success"
|
|
395
|
+
else (error_message or "Task failed.")
|
|
396
|
+
)
|
|
397
|
+
self.add_run_event(
|
|
398
|
+
run_id,
|
|
399
|
+
AutomationRunEvent(
|
|
400
|
+
run_id=run_id,
|
|
401
|
+
event_type=event_type,
|
|
402
|
+
message=message,
|
|
403
|
+
created_at=finished_at,
|
|
404
|
+
payload={"error_code": error_code} if error_code else {},
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
return self.get_run(run_id)
|
|
408
|
+
|
|
409
|
+
def get_run(self, run_id: str) -> AutomationRunRecord:
|
|
410
|
+
with self._connect() as conn:
|
|
411
|
+
row = conn.execute(
|
|
412
|
+
"SELECT * FROM automation_runs WHERE run_id = ?", (run_id,)
|
|
413
|
+
).fetchone()
|
|
414
|
+
if row is None:
|
|
415
|
+
raise KeyError(run_id)
|
|
416
|
+
return _row_to_run(row)
|
|
417
|
+
|
|
418
|
+
def list_runs(self, automation_id: str, *, limit: int = 50) -> list[AutomationRunRecord]:
|
|
419
|
+
with self._connect() as conn:
|
|
420
|
+
rows = conn.execute(
|
|
421
|
+
"""
|
|
422
|
+
SELECT *
|
|
423
|
+
FROM automation_runs
|
|
424
|
+
WHERE automation_id = ?
|
|
425
|
+
ORDER BY queued_at DESC
|
|
426
|
+
LIMIT ?
|
|
427
|
+
""",
|
|
428
|
+
(automation_id, limit),
|
|
429
|
+
).fetchall()
|
|
430
|
+
return [_row_to_run(row) for row in rows]
|
|
431
|
+
|
|
432
|
+
def list_run_events(self, run_id: str) -> list[AutomationRunEvent]:
|
|
433
|
+
with self._connect() as conn:
|
|
434
|
+
rows = conn.execute(
|
|
435
|
+
"""
|
|
436
|
+
SELECT *
|
|
437
|
+
FROM automation_run_events
|
|
438
|
+
WHERE run_id = ?
|
|
439
|
+
ORDER BY created_at ASC, id ASC
|
|
440
|
+
""",
|
|
441
|
+
(run_id,),
|
|
442
|
+
).fetchall()
|
|
443
|
+
return [_row_to_event(row) for row in rows]
|
|
444
|
+
|
|
445
|
+
def add_run_event(self, run_id: str, event: AutomationRunEvent) -> None:
|
|
446
|
+
with self._connect() as conn:
|
|
447
|
+
conn.execute(
|
|
448
|
+
"""
|
|
449
|
+
INSERT INTO automation_run_events (run_id, event_type, message, created_at, payload_json)
|
|
450
|
+
VALUES (?, ?, ?, ?, ?)
|
|
451
|
+
""",
|
|
452
|
+
(
|
|
453
|
+
run_id,
|
|
454
|
+
event.event_type,
|
|
455
|
+
event.message,
|
|
456
|
+
event.created_at or _utcnow(),
|
|
457
|
+
json.dumps(event.payload, ensure_ascii=False, sort_keys=True),
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def retry_run(self, run_id: str) -> AutomationRunRecord:
|
|
462
|
+
source = self.get_run(run_id)
|
|
463
|
+
automation = self.get_automation(source.automation_id)
|
|
464
|
+
queued_at = _utcnow()
|
|
465
|
+
if automation.retry_backoff_seconds > 0:
|
|
466
|
+
queued_at = (
|
|
467
|
+
datetime.now(timezone.utc) + timedelta(seconds=automation.retry_backoff_seconds)
|
|
468
|
+
).isoformat()
|
|
469
|
+
return self.create_run(
|
|
470
|
+
source.automation_id,
|
|
471
|
+
trigger_type="retry",
|
|
472
|
+
effective_inputs=source.effective_inputs,
|
|
473
|
+
attempt_number=source.attempt_number + 1,
|
|
474
|
+
queued_at=queued_at,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def service_metrics(self) -> dict[str, Any]:
|
|
478
|
+
with self._connect() as conn:
|
|
479
|
+
queued = conn.execute(
|
|
480
|
+
"SELECT COUNT(*) AS count FROM automation_runs WHERE status = 'queued'"
|
|
481
|
+
).fetchone()
|
|
482
|
+
running = conn.execute(
|
|
483
|
+
"SELECT COUNT(*) AS count FROM automation_runs WHERE status = 'running'"
|
|
484
|
+
).fetchone()
|
|
485
|
+
automations = conn.execute("SELECT COUNT(*) AS count FROM automations").fetchone()
|
|
486
|
+
return {
|
|
487
|
+
"automation_count": int(automations["count"]),
|
|
488
|
+
"queued_runs": int(queued["count"]),
|
|
489
|
+
"running_runs": int(running["count"]),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
def _normalize_automation(
|
|
493
|
+
self,
|
|
494
|
+
automation: PersistedAutomationDefinition,
|
|
495
|
+
) -> PersistedAutomationDefinition:
|
|
496
|
+
task_path = automation.task_path.expanduser().resolve()
|
|
497
|
+
task_meta_path = automation.task_meta_path.expanduser().resolve()
|
|
498
|
+
raw_output_dir = str(automation.output_dir).strip()
|
|
499
|
+
output_dir = (
|
|
500
|
+
(get_app_paths().automation_runs_dir / automation.id).resolve()
|
|
501
|
+
if raw_output_dir in {"", "."}
|
|
502
|
+
else automation.output_dir.expanduser().resolve()
|
|
503
|
+
)
|
|
504
|
+
result_json_path = None
|
|
505
|
+
if automation.result_json_path is not None:
|
|
506
|
+
result_json_path = automation.result_json_path.expanduser()
|
|
507
|
+
if not result_json_path.is_absolute():
|
|
508
|
+
result_json_path = (output_dir / result_json_path).resolve()
|
|
509
|
+
normalized_kind, normalized_payload, normalized_timezone = normalize_schedule(
|
|
510
|
+
automation.schedule_kind,
|
|
511
|
+
automation.schedule_payload,
|
|
512
|
+
timezone_name=automation.timezone,
|
|
513
|
+
)
|
|
514
|
+
definition_status = "valid"
|
|
515
|
+
definition_error: str | None = None
|
|
516
|
+
next_run_at: str | None = None
|
|
517
|
+
try:
|
|
518
|
+
if not task_path.exists():
|
|
519
|
+
raise FileNotFoundError(f"Task file does not exist: {task_path}")
|
|
520
|
+
payload = _load_task_metadata(task_meta_path)
|
|
521
|
+
validate_task_metadata(payload, source=str(task_meta_path))
|
|
522
|
+
if automation.enabled:
|
|
523
|
+
next_run_at = compute_next_run_at(
|
|
524
|
+
normalized_kind,
|
|
525
|
+
normalized_payload,
|
|
526
|
+
timezone_name=normalized_timezone,
|
|
527
|
+
)
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
definition_status = "invalid"
|
|
530
|
+
definition_error = str(exc)
|
|
531
|
+
return replace(
|
|
532
|
+
automation,
|
|
533
|
+
task_path=task_path,
|
|
534
|
+
task_meta_path=task_meta_path,
|
|
535
|
+
output_dir=output_dir,
|
|
536
|
+
result_json_path=result_json_path,
|
|
537
|
+
schedule_kind=normalized_kind,
|
|
538
|
+
schedule_payload=normalized_payload,
|
|
539
|
+
timezone=normalized_timezone,
|
|
540
|
+
definition_status=definition_status,
|
|
541
|
+
definition_error=definition_error,
|
|
542
|
+
next_run_at=next_run_at if automation.enabled else None,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _automation_to_row(automation: PersistedAutomationDefinition) -> dict[str, Any]:
|
|
547
|
+
return {
|
|
548
|
+
"id": automation.id,
|
|
549
|
+
"name": automation.name,
|
|
550
|
+
"description": automation.description,
|
|
551
|
+
"version": automation.version,
|
|
552
|
+
"task_path": str(automation.task_path),
|
|
553
|
+
"task_meta_path": str(automation.task_meta_path),
|
|
554
|
+
"entrypoint": automation.entrypoint,
|
|
555
|
+
"enabled": 1 if automation.enabled else 0,
|
|
556
|
+
"definition_status": automation.definition_status,
|
|
557
|
+
"definition_error": automation.definition_error,
|
|
558
|
+
"schedule_kind": automation.schedule_kind,
|
|
559
|
+
"schedule_payload_json": json.dumps(
|
|
560
|
+
automation.schedule_payload, ensure_ascii=False, sort_keys=True
|
|
561
|
+
),
|
|
562
|
+
"timezone": automation.timezone,
|
|
563
|
+
"output_dir": str(automation.output_dir),
|
|
564
|
+
"result_json_path": str(automation.result_json_path)
|
|
565
|
+
if automation.result_json_path
|
|
566
|
+
else None,
|
|
567
|
+
"stdout_mode": automation.stdout_mode,
|
|
568
|
+
"input_overrides_json": json.dumps(
|
|
569
|
+
automation.input_overrides, ensure_ascii=False, sort_keys=True
|
|
570
|
+
),
|
|
571
|
+
"before_run_hooks_json": json.dumps(list(automation.before_run_hooks), ensure_ascii=False),
|
|
572
|
+
"after_success_hooks_json": json.dumps(
|
|
573
|
+
list(automation.after_success_hooks), ensure_ascii=False
|
|
574
|
+
),
|
|
575
|
+
"after_failure_hooks_json": json.dumps(
|
|
576
|
+
list(automation.after_failure_hooks), ensure_ascii=False
|
|
577
|
+
),
|
|
578
|
+
"retry_attempts": automation.retry_attempts,
|
|
579
|
+
"retry_backoff_seconds": automation.retry_backoff_seconds,
|
|
580
|
+
"timeout_seconds": automation.timeout_seconds,
|
|
581
|
+
"log_level": automation.log_level,
|
|
582
|
+
"created_at": automation.created_at,
|
|
583
|
+
"updated_at": automation.updated_at,
|
|
584
|
+
"last_run_at": automation.last_run_at,
|
|
585
|
+
"next_run_at": automation.next_run_at,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _row_to_automation(row: sqlite3.Row) -> PersistedAutomationDefinition:
|
|
590
|
+
return PersistedAutomationDefinition(
|
|
591
|
+
id=str(row["id"]),
|
|
592
|
+
name=str(row["name"]),
|
|
593
|
+
task_path=Path(str(row["task_path"])),
|
|
594
|
+
task_meta_path=Path(str(row["task_meta_path"])),
|
|
595
|
+
output_dir=Path(str(row["output_dir"])),
|
|
596
|
+
description=str(row["description"] or ""),
|
|
597
|
+
version=str(row["version"] or "0.1.0"),
|
|
598
|
+
entrypoint=str(row["entrypoint"] or "run"),
|
|
599
|
+
enabled=bool(row["enabled"]),
|
|
600
|
+
definition_status=str(row["definition_status"] or "valid"),
|
|
601
|
+
definition_error=str(row["definition_error"]) if row["definition_error"] else None,
|
|
602
|
+
schedule_kind=str(row["schedule_kind"] or "manual"),
|
|
603
|
+
schedule_payload=json.loads(row["schedule_payload_json"] or "{}"),
|
|
604
|
+
timezone=str(row["timezone"] or "UTC"),
|
|
605
|
+
result_json_path=Path(str(row["result_json_path"])) if row["result_json_path"] else None,
|
|
606
|
+
stdout_mode=str(row["stdout_mode"] or "json"),
|
|
607
|
+
input_overrides=json.loads(row["input_overrides_json"] or "{}"),
|
|
608
|
+
before_run_hooks=tuple(json.loads(row["before_run_hooks_json"] or "[]")),
|
|
609
|
+
after_success_hooks=tuple(json.loads(row["after_success_hooks_json"] or "[]")),
|
|
610
|
+
after_failure_hooks=tuple(json.loads(row["after_failure_hooks_json"] or "[]")),
|
|
611
|
+
retry_attempts=int(row["retry_attempts"] or 0),
|
|
612
|
+
retry_backoff_seconds=int(row["retry_backoff_seconds"] or 0),
|
|
613
|
+
timeout_seconds=float(row["timeout_seconds"])
|
|
614
|
+
if row["timeout_seconds"] is not None
|
|
615
|
+
else None,
|
|
616
|
+
log_level=str(row["log_level"] or "info"),
|
|
617
|
+
created_at=str(row["created_at"]) if row["created_at"] else None,
|
|
618
|
+
updated_at=str(row["updated_at"]) if row["updated_at"] else None,
|
|
619
|
+
last_run_at=str(row["last_run_at"]) if row["last_run_at"] else None,
|
|
620
|
+
next_run_at=str(row["next_run_at"]) if row["next_run_at"] else None,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _run_to_row(run: AutomationRunRecord) -> dict[str, Any]:
|
|
625
|
+
return {
|
|
626
|
+
"run_id": run.run_id,
|
|
627
|
+
"automation_id": run.automation_id,
|
|
628
|
+
"trigger_type": run.trigger_type,
|
|
629
|
+
"status": run.status,
|
|
630
|
+
"effective_inputs_json": json.dumps(
|
|
631
|
+
run.effective_inputs, ensure_ascii=False, sort_keys=True
|
|
632
|
+
),
|
|
633
|
+
"attempt_number": run.attempt_number,
|
|
634
|
+
"queued_at": run.queued_at,
|
|
635
|
+
"started_at": run.started_at,
|
|
636
|
+
"finished_at": run.finished_at,
|
|
637
|
+
"error_code": run.error_code,
|
|
638
|
+
"error_message": run.error_message,
|
|
639
|
+
"result_json_path": str(run.result_json_path) if run.result_json_path else None,
|
|
640
|
+
"artifacts_dir": str(run.artifacts_dir) if run.artifacts_dir else None,
|
|
641
|
+
"log_path": str(run.log_path) if run.log_path else None,
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _row_to_run(row: sqlite3.Row) -> AutomationRunRecord:
|
|
646
|
+
return AutomationRunRecord(
|
|
647
|
+
run_id=str(row["run_id"]),
|
|
648
|
+
automation_id=str(row["automation_id"]),
|
|
649
|
+
trigger_type=str(row["trigger_type"]),
|
|
650
|
+
status=str(row["status"]),
|
|
651
|
+
effective_inputs=json.loads(row["effective_inputs_json"] or "{}"),
|
|
652
|
+
attempt_number=int(row["attempt_number"] or 0),
|
|
653
|
+
queued_at=str(row["queued_at"]) if row["queued_at"] else None,
|
|
654
|
+
started_at=str(row["started_at"]) if row["started_at"] else None,
|
|
655
|
+
finished_at=str(row["finished_at"]) if row["finished_at"] else None,
|
|
656
|
+
error_code=str(row["error_code"]) if row["error_code"] else None,
|
|
657
|
+
error_message=str(row["error_message"]) if row["error_message"] else None,
|
|
658
|
+
result_json_path=Path(str(row["result_json_path"])) if row["result_json_path"] else None,
|
|
659
|
+
artifacts_dir=Path(str(row["artifacts_dir"])) if row["artifacts_dir"] else None,
|
|
660
|
+
log_path=Path(str(row["log_path"])) if row["log_path"] else None,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _row_to_event(row: sqlite3.Row) -> AutomationRunEvent:
|
|
665
|
+
return AutomationRunEvent(
|
|
666
|
+
run_id=str(row["run_id"]),
|
|
667
|
+
event_type=str(row["event_type"]),
|
|
668
|
+
message=str(row["message"] or ""),
|
|
669
|
+
created_at=str(row["created_at"]),
|
|
670
|
+
payload=json.loads(row["payload_json"] or "{}"),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _utcnow() -> str:
|
|
675
|
+
return datetime.now(timezone.utc).isoformat()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _load_task_metadata(task_meta_path: Path) -> dict[str, Any]:
|
|
679
|
+
try:
|
|
680
|
+
return json.loads(task_meta_path.read_text(encoding="utf-8"))
|
|
681
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
682
|
+
raise ValueError(f"Task metadata is invalid JSON: {task_meta_path}: {exc}") from exc
|