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