| from __future__ import annotations |
|
|
| import os |
| import threading |
| from datetime import datetime, timezone |
| from typing import Any |
|
|
| from ..config import settings |
| from .connectors import ConnectorError, publish_to_account, request_json, send_integration, sync_account_analytics |
| from .intelligence import score_analytics |
| from .models import AnalyticsEvent, AutomationJob, Integration, SocialAccount, Variant |
| from .operations import audit, notify |
| from .store import now_iso, store |
|
|
|
|
| def _save_job(data: dict[str, Any], state: str, result: dict[str, Any] | None = None, log: str | None = None) -> AutomationJob: |
| data["state"] = state |
| data["updated_at"] = now_iso() |
| if result is not None: |
| data["result"] = result |
| if log: |
| data.setdefault("logs", []).append(log) |
| job = AutomationJob.model_validate(data) |
| store.save("jobs", job) |
| return job |
|
|
|
|
| def _account(platform: str, workspace_id: str | None) -> SocialAccount: |
| accounts = [item for item in store.list("accounts", SocialAccount) if item.platform == platform and item.active] |
| if workspace_id: |
| accounts = [item for item in accounts if item.workspace_id == workspace_id] |
| if not accounts: |
| raise ConnectorError(f"No active {platform} account is connected") |
| return accounts[0] |
|
|
|
|
| def _execute_render(data: dict[str, Any]) -> dict[str, Any]: |
| payload = data["payload"] |
| base_url = os.getenv("MAESTER_INTERNAL_BASE_URL", "http://127.0.0.1:7860").rstrip("/") |
| result = request_json("POST", f"{base_url}/services/render/render", payload["render_payload"], token=None, allow_private=True) |
| variant = store.get("variants", payload["variant_id"], Variant) |
| metadata = {**variant.metadata, "render_job": result} |
| store.save("variants", variant.model_copy(update={"job_state": "rendering", "metadata": metadata, "updated_at": now_iso()})) |
| return result |
|
|
|
|
| def _execute_publish(data: dict[str, Any]) -> dict[str, Any]: |
| payload = data["payload"] |
| variant = store.get("variants", payload["variant_id"], Variant) |
| results = [] |
| for platform in payload["platforms"]: |
| account = _account(platform, payload.get("workspace_id")) |
| results.append(publish_to_account(account, {**payload, "render": variant.metadata.get("render_job", {}), "variant": variant.model_dump()})) |
| published = all(item.get("status") == "published" for item in results) |
| state = "published" if published else variant.job_state |
| approval = "published" if published else variant.approval_state |
| store.save("variants", variant.model_copy(update={"job_state": state, "approval_state": approval, "updated_at": now_iso()})) |
| return {"platforms": results, "published": published} |
|
|
|
|
| def _execute_analytics(data: dict[str, Any]) -> dict[str, Any]: |
| payload = data["payload"] |
| account = store.get("accounts", payload["account_id"], SocialAccount) |
| incoming = sync_account_analytics(account) |
| saved = 0 |
| for raw in incoming: |
| variant_id = raw.get("variant_id") |
| if not variant_id: |
| continue |
| try: |
| variant = store.get("variants", variant_id, Variant) |
| except KeyError: |
| continue |
| event = AnalyticsEvent.model_validate({**raw, "variant_id": variant_id, "platform": account.platform, "source": "connector"}) |
| event_data = event.model_dump() |
| event_data.update({"id": f"analytics_{data['id']}_{saved}", "captured_at": event.captured_at or now_iso(), "score": score_analytics(event_data)}) |
| store.save_data("analytics", event_data["id"], event_data) |
| store.save("variants", variant.model_copy(update={"analytics": {**variant.analytics, account.platform: event_data}, "updated_at": now_iso()})) |
| saved += 1 |
| return {"account_id": account.id, "events_ingested": saved} |
|
|
|
|
| def _execute_notification(data: dict[str, Any]) -> dict[str, Any]: |
| payload = data["payload"] |
| integration = store.get("integrations", payload["integration_id"], Integration) |
| return send_integration(integration, payload.get("message", {})) |
|
|
|
|
| def execute_job(data: dict[str, Any]) -> AutomationJob: |
| try: |
| if data["kind"] == "render": |
| result = _execute_render(data) |
| return _save_job(data, "rendering", result, "Render submitted to the render engine.") |
| if data["kind"] == "publish": |
| result = _execute_publish(data) |
| state = "published" if result["published"] else "ready" |
| return _save_job(data, state, result, "Publishing connector dispatch completed.") |
| if data["kind"] == "analytics_sync": |
| return _save_job(data, "ready", _execute_analytics(data), "Analytics synchronization completed.") |
| if data["kind"] == "notification": |
| return _save_job(data, "ready", _execute_notification(data), "Notification delivered.") |
| raise RuntimeError(f"Unsupported job kind: {data['kind']}") |
| except Exception as exc: |
| attempts = int(data.get("attempts", 1)) |
| retryable = attempts < int(data.get("max_attempts", settings.job_max_attempts)) and not isinstance(exc, ConnectorError) |
| state = "queued" if retryable else "failed" |
| job = _save_job(data, state, {"error": str(exc)}, f"Attempt {attempts} failed: {exc}") |
| if state == "failed": |
| notify("Automation job failed", f"{job.kind} job {job.id} failed: {exc}", metadata={"job_id": job.id}) |
| audit("job.failed", "job", job.id, metadata={"error": str(exc)}) |
| return job |
|
|
|
|
| def process_next(kinds: list[str] | None = None) -> AutomationJob | None: |
| data = store.claim_job(kinds) |
| return execute_job(data) if data else None |
|
|
|
|
| class JobWorker: |
| def __init__(self) -> None: |
| self._stop = threading.Event() |
| self._thread: threading.Thread | None = None |
|
|
| def start(self) -> None: |
| if self._thread and self._thread.is_alive(): |
| return |
| self._stop.clear() |
| self._thread = threading.Thread(target=self._run, name="maester-automation-worker", daemon=True) |
| self._thread.start() |
|
|
| def stop(self) -> None: |
| self._stop.set() |
| if self._thread: |
| self._thread.join(timeout=5) |
|
|
| def _run(self) -> None: |
| while not self._stop.wait(2): |
| process_next() |
|
|
| @property |
| def running(self) -> bool: |
| return bool(self._thread and self._thread.is_alive()) |
|
|
|
|
| worker = JobWorker() |
|
|