Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/automation_control.py +214 -0
- api/automation_engine.py +33 -11
- api/automation_worker.py +220 -0
- api/routes_automation.py +53 -32
- api/routes_tables.py +15 -5
- platform/core/data_binding.py +11 -2
- platform/harness/pg/schema.sql +24 -0
api/automation_control.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AWS automation control-plane client and exact schedule compiler.
|
| 2 |
+
|
| 3 |
+
Production deliberately has no global tick. The API tells a small signed AWS control
|
| 4 |
+
function exactly which schedules one automation needs; direct events start one worker
|
| 5 |
+
immediately. With no enabled definitions the scheduler group is empty and ECS has no task.
|
| 6 |
+
|
| 7 |
+
This module has no AWS dependency. The web process talks to the control function over its
|
| 8 |
+
Function URL using an HMAC over ``timestamp + newline + body``. Local/test deployments with no
|
| 9 |
+
control URL keep the historical in-process behavior; production Postgres fails closed if the
|
| 10 |
+
control plane is missing.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import datetime as dt
|
| 15 |
+
import hashlib
|
| 16 |
+
import hmac
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import time
|
| 21 |
+
import uuid
|
| 22 |
+
|
| 23 |
+
import requests
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ControlPlaneError(RuntimeError):
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 6))
|
| 31 |
+
_DOW_NAMES = ("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT")
|
| 32 |
+
_MANAGED_RECURRING = ("schedule", "email")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def configured() -> bool:
|
| 36 |
+
return bool(str(os.environ.get("AIOS_AUTOMATION_CONTROL_URL") or "").strip()
|
| 37 |
+
and str(os.environ.get("AIOS_AUTOMATION_CONTROL_TOKEN") or "").strip())
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _production_requires_control() -> bool:
|
| 41 |
+
if str(os.environ.get("STORE_BACKEND") or "").lower() != "pg":
|
| 42 |
+
return False
|
| 43 |
+
try:
|
| 44 |
+
from core import data_binding
|
| 45 |
+
return data_binding.is_production_deployment()
|
| 46 |
+
except Exception:
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _parse_part(spec: str, lo: int, hi: int) -> set[int]:
|
| 51 |
+
out: set[int] = set()
|
| 52 |
+
for raw in str(spec).split(","):
|
| 53 |
+
part = raw.strip()
|
| 54 |
+
if not part:
|
| 55 |
+
raise ValueError("empty cron field")
|
| 56 |
+
step = 1
|
| 57 |
+
if "/" in part:
|
| 58 |
+
part, step_raw = part.split("/", 1)
|
| 59 |
+
step = int(step_raw)
|
| 60 |
+
if step < 1:
|
| 61 |
+
raise ValueError("cron step must be positive")
|
| 62 |
+
if part in ("*", "?"):
|
| 63 |
+
first, last = lo, hi
|
| 64 |
+
elif "-" in part:
|
| 65 |
+
first_raw, last_raw = part.split("-", 1)
|
| 66 |
+
first, last = int(first_raw), int(last_raw)
|
| 67 |
+
else:
|
| 68 |
+
first = last = int(part)
|
| 69 |
+
if first < lo or last > hi or first > last:
|
| 70 |
+
raise ValueError(f"cron value {part!r} is outside {lo}..{hi}")
|
| 71 |
+
out.update(range(first, last + 1, step))
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _field(values: set[int], lo: int, hi: int, names=None) -> str:
|
| 76 |
+
if values == set(range(lo, hi + 1)):
|
| 77 |
+
return "*"
|
| 78 |
+
ordered = sorted(values)
|
| 79 |
+
return ",".join(str(names[v] if names else v) for v in ordered)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def aws_cron_expressions(expr: str) -> list[str]:
|
| 83 |
+
"""Translate one validated POSIX five-field cron into one or two AWS cron expressions.
|
| 84 |
+
|
| 85 |
+
POSIX uses OR when both day-of-month and weekday are restricted. AWS requires one of those
|
| 86 |
+
fields to be ``?``, so that case becomes two schedules. The durable worker job id includes
|
| 87 |
+
scheduled time and deduplicates the overlap when both arms match.
|
| 88 |
+
"""
|
| 89 |
+
parts = str(expr or "").split()
|
| 90 |
+
if len(parts) != 5:
|
| 91 |
+
raise ValueError("a cron schedule has exactly five fields")
|
| 92 |
+
vals = [_parse_part(p, lo, hi) for p, (lo, hi) in zip(parts, _FIELD_RANGES)]
|
| 93 |
+
minute = _field(vals[0], 0, 59)
|
| 94 |
+
hour = _field(vals[1], 0, 23)
|
| 95 |
+
dom = _field(vals[2], 1, 31)
|
| 96 |
+
month = _field(vals[3], 1, 12)
|
| 97 |
+
dow = _field(vals[4], 0, 6, _DOW_NAMES)
|
| 98 |
+
dom_restricted = parts[2] not in ("*", "?")
|
| 99 |
+
dow_restricted = parts[4] not in ("*", "?")
|
| 100 |
+
if dom_restricted and dow_restricted:
|
| 101 |
+
return [f"cron({minute} {hour} {dom} {month} ? *)",
|
| 102 |
+
f"cron({minute} {hour} ? {month} {dow} *)"]
|
| 103 |
+
if dom_restricted:
|
| 104 |
+
return [f"cron({minute} {hour} {dom} {month} ? *)"]
|
| 105 |
+
if dow_restricted:
|
| 106 |
+
return [f"cron({minute} {hour} ? {month} {dow} *)"]
|
| 107 |
+
return [f"cron({minute} {hour} * {month} ? *)"]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def generation(defn: dict) -> str:
|
| 111 |
+
relevant = {k: defn.get(k) for k in ("id", "kind", "config", "schedule", "trigger", "flow")}
|
| 112 |
+
body = json.dumps(relevant, sort_keys=True, separators=(",", ":"), default=str).encode()
|
| 113 |
+
return hashlib.sha256(body).hexdigest()[:20]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def schedule_scope(tenant: str, auto_id: str) -> str:
|
| 117 |
+
digest = hashlib.sha256(f"{tenant}\0{auto_id}".encode()).hexdigest()[:20]
|
| 118 |
+
return f"a-{digest}"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _base_job(tenant: str, defn: dict, mode: str) -> dict:
|
| 122 |
+
return {"tenant": str(tenant), "automationId": str(defn.get("id") or ""),
|
| 123 |
+
"generation": generation(defn), "mode": mode,
|
| 124 |
+
"scheduledTime": "<aws.scheduler.scheduled-time>"}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def recurring_specs(tenant: str, defn: dict) -> list[dict]:
|
| 128 |
+
scope = schedule_scope(tenant, defn.get("id"))
|
| 129 |
+
specs: list[dict] = []
|
| 130 |
+
schedule = defn.get("schedule") or {}
|
| 131 |
+
if schedule.get("enabled"):
|
| 132 |
+
for index, expression in enumerate(aws_cron_expressions(schedule.get("cron") or "")):
|
| 133 |
+
specs.append({"name": f"{scope}-schedule-{index}", "mode": "schedule",
|
| 134 |
+
"expression": expression, "timezone": "UTC",
|
| 135 |
+
"job": _base_job(tenant, defn, "schedule")})
|
| 136 |
+
trigger = defn.get("trigger") or {}
|
| 137 |
+
if (trigger.get("key") == "email" and trigger.get("enabled", True)
|
| 138 |
+
and not trigger.get("paused") and trigger.get("configured", True)):
|
| 139 |
+
specs.append({"name": f"{scope}-email-0", "mode": "email",
|
| 140 |
+
"expression": "rate(15 minutes)", "timezone": "UTC",
|
| 141 |
+
"job": _base_job(tenant, defn, "email")})
|
| 142 |
+
return specs
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def pending_specs(tenant: str, defn: dict, when: dt.datetime | None = None) -> list[dict]:
|
| 146 |
+
from automation_engine import pending_collect_ids
|
| 147 |
+
|
| 148 |
+
if str(defn.get("id") or "") not in pending_collect_ids(
|
| 149 |
+
None, definitions={defn["id"]: defn}):
|
| 150 |
+
return []
|
| 151 |
+
when = when or (dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=15))
|
| 152 |
+
when = when.astimezone(dt.timezone.utc).replace(microsecond=0)
|
| 153 |
+
expression = f"at({when.strftime('%Y-%m-%dT%H:%M:%S')})"
|
| 154 |
+
scope = schedule_scope(tenant, defn.get("id"))
|
| 155 |
+
return [{"name": f"{scope}-pending-0", "mode": "pending", "expression": expression,
|
| 156 |
+
"timezone": "UTC", "oneTime": True, "job": _base_job(tenant, defn, "pending")}]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _signed_post(payload: dict) -> dict:
|
| 160 |
+
url = str(os.environ.get("AIOS_AUTOMATION_CONTROL_URL") or "").strip()
|
| 161 |
+
token = str(os.environ.get("AIOS_AUTOMATION_CONTROL_TOKEN") or "").strip()
|
| 162 |
+
if not url or not token:
|
| 163 |
+
if _production_requires_control():
|
| 164 |
+
raise ControlPlaneError("production automation control plane is not configured")
|
| 165 |
+
return {"local": True}
|
| 166 |
+
body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
| 167 |
+
stamp = str(int(time.time()))
|
| 168 |
+
signature = hmac.new(token.encode(), stamp.encode() + b"\n" + body,
|
| 169 |
+
hashlib.sha256).hexdigest()
|
| 170 |
+
try:
|
| 171 |
+
response = requests.post(url, data=body, timeout=15,
|
| 172 |
+
headers={"Content-Type": "application/json",
|
| 173 |
+
"X-AIOS-Timestamp": stamp,
|
| 174 |
+
"X-AIOS-Signature": signature})
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
raise ControlPlaneError(f"automation control request failed: {type(exc).__name__}") from exc
|
| 177 |
+
if response.status_code >= 300:
|
| 178 |
+
raise ControlPlaneError(f"automation control answered HTTP {response.status_code}")
|
| 179 |
+
try:
|
| 180 |
+
return response.json()
|
| 181 |
+
except Exception as exc:
|
| 182 |
+
raise ControlPlaneError("automation control returned invalid JSON") from exc
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def sync_definition(tenant: str, defn: dict) -> dict:
|
| 186 |
+
return _signed_post({"action": "sync", "tenant": str(tenant),
|
| 187 |
+
"automationId": str(defn.get("id") or ""),
|
| 188 |
+
"managedModes": list(_MANAGED_RECURRING),
|
| 189 |
+
"schedules": recurring_specs(tenant, defn)})
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def sync_pending(tenant: str, defn: dict, when: dt.datetime | None = None) -> dict:
|
| 193 |
+
return _signed_post({"action": "sync", "tenant": str(tenant),
|
| 194 |
+
"automationId": str(defn.get("id") or ""),
|
| 195 |
+
"managedModes": ["pending"],
|
| 196 |
+
"schedules": pending_specs(tenant, defn, when=when)})
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def delete_definition(tenant: str, auto_id: str) -> dict:
|
| 200 |
+
return _signed_post({"action": "sync", "tenant": str(tenant),
|
| 201 |
+
"automationId": str(auto_id),
|
| 202 |
+
"managedModes": ["schedule", "email", "pending"], "schedules": []})
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def dispatch(tenant: str, auto_id: str, *, mode="manual", username="automation",
|
| 206 |
+
rows=None, request_id=None) -> dict:
|
| 207 |
+
job = {"tenant": str(tenant), "automationId": str(auto_id), "mode": str(mode),
|
| 208 |
+
"username": str(username or "automation"), "rows": list(rows or [])[:100],
|
| 209 |
+
"requestId": str(request_id or uuid.uuid4())}
|
| 210 |
+
return _signed_post({"action": "dispatch", "job": job})
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def safe_schedule_name(name: str) -> bool:
|
| 214 |
+
return bool(re.fullmatch(r"a-[0-9a-f]{20}-(schedule-[01]|email-0|pending-0)", str(name)))
|
api/automation_engine.py
CHANGED
|
@@ -956,10 +956,13 @@ def disable_for_table(rt, table_key, note="target database deleted"):
|
|
| 956 |
for aid, d in (cur or {}).items():
|
| 957 |
if isinstance(d, dict) and (d.get("config") or {}).get("targetTable") == key:
|
| 958 |
sch = d.get("schedule")
|
| 959 |
-
if not isinstance(sch, dict):
|
| 960 |
-
sch = d["schedule"] = {}
|
| 961 |
-
sch["enabled"] = False
|
| 962 |
-
|
|
|
|
|
|
|
|
|
|
| 963 |
touched.append(str(aid))
|
| 964 |
return cur
|
| 965 |
|
|
@@ -14490,11 +14493,26 @@ def run_now(rt, tenant, auto_id, username="automation", log=print, rows=None):
|
|
| 14490 |
_release(tenant, auto_id)
|
| 14491 |
|
| 14492 |
|
| 14493 |
-
def run_async(rt, tenant, auto_id, username="automation", log=print, rows=None):
|
| 14494 |
-
"""
|
| 14495 |
-
|
| 14496 |
-
|
| 14497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14498 |
daemon=True, name=f"automation-{tenant}-{auto_id}")
|
| 14499 |
th.start()
|
| 14500 |
return True
|
|
@@ -14747,12 +14765,16 @@ def _scheduler_tenants(runtime_module, now=None):
|
|
| 14747 |
def scheduler_status():
|
| 14748 |
"""Safe process-local observability for the operator plane and tick response."""
|
| 14749 |
backend = str(os.environ.get("STORE_BACKEND") or "hf").lower()
|
| 14750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14751 |
# A thread object surviving a test/config transition must never advertise a forbidden Pg
|
| 14752 |
# scheduler. Production's source of truth is the backend invariant, not stale process state.
|
| 14753 |
in_process = bool(backend != "pg" and _SCHEDULER[0] is not None
|
| 14754 |
and _SCHEDULER[0].is_alive())
|
| 14755 |
-
source = "
|
| 14756 |
with _TICK_CACHE_LOCK, _DERIVED_TICK_LOCKS_GUARD:
|
| 14757 |
return {
|
| 14758 |
"enabled": bool(external or in_process),
|
|
|
|
| 956 |
for aid, d in (cur or {}).items():
|
| 957 |
if isinstance(d, dict) and (d.get("config") or {}).get("targetTable") == key:
|
| 958 |
sch = d.get("schedule")
|
| 959 |
+
if not isinstance(sch, dict):
|
| 960 |
+
sch = d["schedule"] = {}
|
| 961 |
+
sch["enabled"] = False
|
| 962 |
+
trg = d.get("trigger")
|
| 963 |
+
if isinstance(trg, dict):
|
| 964 |
+
trg["paused"] = True
|
| 965 |
+
d["statusNote"] = note
|
| 966 |
touched.append(str(aid))
|
| 967 |
return cur
|
| 968 |
|
|
|
|
| 14493 |
_release(tenant, auto_id)
|
| 14494 |
|
| 14495 |
|
| 14496 |
+
def run_async(rt, tenant, auto_id, username="automation", log=print, rows=None):
|
| 14497 |
+
"""Dispatch one run without keeping production work resident in the web process.
|
| 14498 |
+
|
| 14499 |
+
Production uses the signed AWS control plane and one Fargate task. The worker sets
|
| 14500 |
+
``AIOS_AUTOMATION_INLINE=1`` so a nested trigger finishes before that finite task exits.
|
| 14501 |
+
Local development retains the historical daemon thread when no control plane is configured.
|
| 14502 |
+
"""
|
| 14503 |
+
if running(tenant, auto_id):
|
| 14504 |
+
return False
|
| 14505 |
+
if os.environ.get("AIOS_AUTOMATION_INLINE") == "1":
|
| 14506 |
+
return run_now(rt, tenant, auto_id, username=username, log=log, rows=rows) is not None
|
| 14507 |
+
try:
|
| 14508 |
+
import automation_control as _control
|
| 14509 |
+
if _control.configured():
|
| 14510 |
+
mode = "event" if username in MACHINE_OWNERS else "manual"
|
| 14511 |
+
_control.dispatch(tenant, auto_id, mode=mode, username=username, rows=rows)
|
| 14512 |
+
return True
|
| 14513 |
+
except ImportError:
|
| 14514 |
+
pass
|
| 14515 |
+
th = threading.Thread(target=run_now, args=(rt, tenant, auto_id, username, log, rows),
|
| 14516 |
daemon=True, name=f"automation-{tenant}-{auto_id}")
|
| 14517 |
th.start()
|
| 14518 |
return True
|
|
|
|
| 14765 |
def scheduler_status():
|
| 14766 |
"""Safe process-local observability for the operator plane and tick response."""
|
| 14767 |
backend = str(os.environ.get("STORE_BACKEND") or "hf").lower()
|
| 14768 |
+
try:
|
| 14769 |
+
import automation_control as _control
|
| 14770 |
+
external = _control.configured()
|
| 14771 |
+
except Exception:
|
| 14772 |
+
external = False
|
| 14773 |
# A thread object surviving a test/config transition must never advertise a forbidden Pg
|
| 14774 |
# scheduler. Production's source of truth is the backend invariant, not stale process state.
|
| 14775 |
in_process = bool(backend != "pg" and _SCHEDULER[0] is not None
|
| 14776 |
and _SCHEDULER[0].is_alive())
|
| 14777 |
+
source = "exact-aws" if external else ("in-process" if in_process else "")
|
| 14778 |
with _TICK_CACHE_LOCK, _DERIVED_TICK_LOCKS_GUARD:
|
| 14779 |
return {
|
| 14780 |
"enabled": bool(external or in_process),
|
api/automation_worker.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One finite AIOS automation job for ECS/Fargate.
|
| 2 |
+
|
| 3 |
+
The container has no loop and no service. It claims one durable Postgres job, holds a tenant +
|
| 4 |
+
automation advisory lock, performs only the requested work, reconciles follow-up schedules, and
|
| 5 |
+
exits. At-least-once delivery is therefore safe and idle AWS compute is literally zero tasks.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import datetime as dt
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import traceback
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _load_job() -> dict:
|
| 18 |
+
raw = str(os.environ.get("AIOS_AUTOMATION_JOB") or "").strip()
|
| 19 |
+
if not raw:
|
| 20 |
+
raise RuntimeError("AIOS_AUTOMATION_JOB is required")
|
| 21 |
+
job = json.loads(raw)
|
| 22 |
+
if not isinstance(job, dict):
|
| 23 |
+
raise RuntimeError("AIOS_AUTOMATION_JOB must be an object")
|
| 24 |
+
tenant = str(job.get("tenant") or "").strip().lower()
|
| 25 |
+
mode = str(job.get("mode") or "").strip().lower()
|
| 26 |
+
if not tenant or mode not in {"schedule", "email", "pending", "manual", "event", "probe"}:
|
| 27 |
+
raise RuntimeError("job tenant/mode is invalid")
|
| 28 |
+
job["tenant"], job["mode"] = tenant, mode
|
| 29 |
+
job["automationId"] = str(job.get("automationId") or "")
|
| 30 |
+
return job
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _job_id(job: dict) -> str:
|
| 34 |
+
supplied = str(job.get("requestId") or job.get("jobId") or "").strip()
|
| 35 |
+
if supplied:
|
| 36 |
+
return supplied[:180]
|
| 37 |
+
identity = "\0".join(str(job.get(k) or "") for k in
|
| 38 |
+
("tenant", "automationId", "mode", "generation", "scheduledTime"))
|
| 39 |
+
return "scheduled-" + hashlib.sha256(identity.encode()).hexdigest()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _scheduled_for(job: dict):
|
| 43 |
+
raw = str(job.get("scheduledTime") or "").strip()
|
| 44 |
+
if not raw or raw.startswith("<aws.scheduler."):
|
| 45 |
+
return None
|
| 46 |
+
try:
|
| 47 |
+
return dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
| 48 |
+
except ValueError:
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _finish(con, job_id: str, status: str, detail: dict):
|
| 53 |
+
with con.cursor() as cur:
|
| 54 |
+
cur.execute("""
|
| 55 |
+
UPDATE control.automation_jobs
|
| 56 |
+
SET status=%s, finished_at=now(), detail=detail || %s::jsonb
|
| 57 |
+
WHERE job_id=%s
|
| 58 |
+
""", (status, json.dumps(detail, default=str), job_id))
|
| 59 |
+
con.commit()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _claim(job: dict):
|
| 63 |
+
import psycopg
|
| 64 |
+
from core import store_pg
|
| 65 |
+
|
| 66 |
+
store_pg.check_write(job["tenant"], operation="automation worker job claim")
|
| 67 |
+
url = str(os.environ.get("DATABASE_URL") or "").strip()
|
| 68 |
+
if not url:
|
| 69 |
+
raise RuntimeError("DATABASE_URL is required")
|
| 70 |
+
con = psycopg.connect(url)
|
| 71 |
+
lock_name = f"aios-automation:{job['tenant']}:{job['automationId'] or job['mode']}"
|
| 72 |
+
with con.cursor() as cur:
|
| 73 |
+
cur.execute("SELECT pg_try_advisory_lock(hashtextextended(%s, 0))", (lock_name,))
|
| 74 |
+
if not cur.fetchone()[0]:
|
| 75 |
+
con.close()
|
| 76 |
+
return None, "concurrent"
|
| 77 |
+
jid = _job_id(job)
|
| 78 |
+
cur.execute("""
|
| 79 |
+
INSERT INTO control.automation_jobs
|
| 80 |
+
(job_id, tenant_slug, automation_id, trigger_kind, generation, scheduled_for,
|
| 81 |
+
status, attempts, started_at, detail)
|
| 82 |
+
VALUES (%s,%s,%s,%s,%s,%s,'running',1,now(),'{}'::jsonb)
|
| 83 |
+
ON CONFLICT (job_id) DO NOTHING
|
| 84 |
+
""", (jid, job["tenant"], job["automationId"], job["mode"],
|
| 85 |
+
str(job.get("generation") or ""), _scheduled_for(job)))
|
| 86 |
+
inserted = cur.rowcount == 1
|
| 87 |
+
cur.execute("SELECT status, attempts, detail FROM control.automation_jobs WHERE job_id=%s",
|
| 88 |
+
(jid,))
|
| 89 |
+
status, attempts, detail = cur.fetchone()
|
| 90 |
+
if status in ("completed", "skipped"):
|
| 91 |
+
con.commit()
|
| 92 |
+
con.close()
|
| 93 |
+
return None, status
|
| 94 |
+
cur.execute("""
|
| 95 |
+
UPDATE control.automation_jobs
|
| 96 |
+
SET status='running', attempts=%s, started_at=now(), finished_at=NULL
|
| 97 |
+
WHERE job_id=%s
|
| 98 |
+
""", (max(1, int(attempts) + (0 if inserted else 1)), jid))
|
| 99 |
+
con.commit()
|
| 100 |
+
return con, dict(detail or {})
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _definition_refusal(job: dict, defn: dict | None, engine, control) -> str:
|
| 104 |
+
mode = job["mode"]
|
| 105 |
+
if mode == "probe":
|
| 106 |
+
return ""
|
| 107 |
+
if not defn:
|
| 108 |
+
return "automation no longer exists"
|
| 109 |
+
expected = str(job.get("generation") or "")
|
| 110 |
+
if expected and expected != control.generation(defn):
|
| 111 |
+
return "stale schedule generation"
|
| 112 |
+
if mode == "schedule" and not (defn.get("schedule") or {}).get("enabled"):
|
| 113 |
+
return "schedule is disabled"
|
| 114 |
+
trigger = defn.get("trigger") or {}
|
| 115 |
+
if mode == "email" and not (trigger.get("key") == "email"
|
| 116 |
+
and trigger.get("enabled", True)
|
| 117 |
+
and not trigger.get("paused")
|
| 118 |
+
and trigger.get("configured", True)):
|
| 119 |
+
return "email trigger is disabled or stale"
|
| 120 |
+
if mode == "pending" and str(defn.get("id")) not in engine.pending_collect_ids(
|
| 121 |
+
None, definitions={str(defn.get("id")): defn}):
|
| 122 |
+
return "paid vendor result is no longer pending"
|
| 123 |
+
return ""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _run_business(job: dict, rt, defn: dict, engine):
|
| 127 |
+
mode = job["mode"]
|
| 128 |
+
auto_id = job["automationId"]
|
| 129 |
+
if mode == "probe":
|
| 130 |
+
return {"probe": "ok"}, False
|
| 131 |
+
if mode == "email":
|
| 132 |
+
result = engine.email_poll(rt, job["tenant"], auto_id, defn)
|
| 133 |
+
return {"email": "fired" if result is not None else "idle"}, result is not None
|
| 134 |
+
entry = engine.run_now(rt, job["tenant"], auto_id,
|
| 135 |
+
username=str(job.get("username") or "automation"),
|
| 136 |
+
rows=list(job.get("rows") or [])[:100] or None)
|
| 137 |
+
if entry is None:
|
| 138 |
+
return {"run": "not-started"}, False
|
| 139 |
+
return {"run": entry}, True
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def main() -> int:
|
| 143 |
+
job = _load_job()
|
| 144 |
+
os.environ["AIOS_TENANT"] = job["tenant"]
|
| 145 |
+
os.environ["AIOS_AUTOMATION_INLINE"] = "1"
|
| 146 |
+
os.environ.setdefault("STORE_BACKEND", "pg")
|
| 147 |
+
os.environ.setdefault("SAFE_MODE", "1")
|
| 148 |
+
|
| 149 |
+
# Imports happen after tenant/identity env is final. Nothing imports FastAPI or starts an
|
| 150 |
+
# application server in this worker image.
|
| 151 |
+
import automation_control as control
|
| 152 |
+
import automation_engine as engine
|
| 153 |
+
from harness import runtime
|
| 154 |
+
|
| 155 |
+
con, prior = _claim(job)
|
| 156 |
+
if con is None:
|
| 157 |
+
print(json.dumps({"jobId": _job_id(job), "result": prior}, sort_keys=True))
|
| 158 |
+
return 0
|
| 159 |
+
jid = _job_id(job)
|
| 160 |
+
try:
|
| 161 |
+
rt = runtime.get_runtime(job["tenant"])
|
| 162 |
+
defs = engine.all_definitions(rt)
|
| 163 |
+
defn = defs.get(job["automationId"])
|
| 164 |
+
refusal = _definition_refusal(job, defn, engine, control)
|
| 165 |
+
if refusal:
|
| 166 |
+
_finish(con, jid, "skipped", {"reason": refusal})
|
| 167 |
+
print(json.dumps({"jobId": jid, "result": "skipped", "reason": refusal},
|
| 168 |
+
sort_keys=True))
|
| 169 |
+
return 0
|
| 170 |
+
|
| 171 |
+
detail = dict(prior or {})
|
| 172 |
+
did_work = bool(detail.get("businessComplete"))
|
| 173 |
+
if not detail.get("businessComplete"):
|
| 174 |
+
outcome, did_work = _run_business(job, rt, defn, engine)
|
| 175 |
+
detail.update(outcome)
|
| 176 |
+
detail["businessComplete"] = True
|
| 177 |
+
with con.cursor() as cur:
|
| 178 |
+
cur.execute("UPDATE control.automation_jobs SET detail=%s::jsonb WHERE job_id=%s",
|
| 179 |
+
(json.dumps(detail, default=str), jid))
|
| 180 |
+
con.commit()
|
| 181 |
+
|
| 182 |
+
# Derived cells are refreshed only after a real run, never on an idle email check and
|
| 183 |
+
# never because a clock woke up. These functions persist only when values changed.
|
| 184 |
+
if did_work and not detail.get("derivedComplete"):
|
| 185 |
+
engine.refresh_relations(rt)
|
| 186 |
+
engine._refresh_source_rollups(rt, job["tenant"])
|
| 187 |
+
detail["derivedComplete"] = True
|
| 188 |
+
with con.cursor() as cur:
|
| 189 |
+
cur.execute("UPDATE control.automation_jobs SET detail=%s::jsonb WHERE job_id=%s",
|
| 190 |
+
(json.dumps(detail, default=str), jid))
|
| 191 |
+
con.commit()
|
| 192 |
+
|
| 193 |
+
if job["mode"] != "probe":
|
| 194 |
+
current = engine.all_definitions(rt).get(job["automationId"])
|
| 195 |
+
if current:
|
| 196 |
+
control.sync_definition(job["tenant"], current)
|
| 197 |
+
control.sync_pending(job["tenant"], current)
|
| 198 |
+
else:
|
| 199 |
+
control.delete_definition(job["tenant"], job["automationId"])
|
| 200 |
+
_finish(con, jid, "completed", {"controlSynced": True})
|
| 201 |
+
print(json.dumps({"jobId": jid, "result": "completed", "didWork": did_work},
|
| 202 |
+
sort_keys=True))
|
| 203 |
+
return 0
|
| 204 |
+
except Exception as exc:
|
| 205 |
+
try:
|
| 206 |
+
_finish(con, jid, "failed", {"error": type(exc).__name__,
|
| 207 |
+
"message": str(exc)[:500]})
|
| 208 |
+
except Exception:
|
| 209 |
+
pass
|
| 210 |
+
traceback.print_exc()
|
| 211 |
+
return 1
|
| 212 |
+
finally:
|
| 213 |
+
try:
|
| 214 |
+
con.close()
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
raise SystemExit(main())
|
api/routes_automation.py
CHANGED
|
@@ -18,8 +18,9 @@ from datetime import datetime, timezone
|
|
| 18 |
|
| 19 |
from fastapi import APIRouter, Body, Depends, Header, Request
|
| 20 |
|
| 21 |
-
import ai_review
|
| 22 |
-
import
|
|
|
|
| 23 |
import oauth_connect
|
| 24 |
import routes_oauth
|
| 25 |
import scope_cache
|
|
@@ -51,7 +52,17 @@ import routes_connectors # noqa: E40
|
|
| 51 |
#: the day the row lands the wall is already the one that was tested).
|
| 52 |
MODULE = "automation"
|
| 53 |
|
| 54 |
-
_GATE = module_gate(MODULE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def _wire(defn, tenant, rt=None):
|
|
@@ -561,9 +572,12 @@ def _triggers_vocab(session):
|
|
| 561 |
|
| 562 |
|
| 563 |
def _tick_state():
|
| 564 |
-
"""Can
|
|
|
|
|
|
|
| 565 |
status = engine.scheduler_status()
|
| 566 |
-
return {"enabled": bool(status.get("
|
|
|
|
| 567 |
|
| 568 |
|
| 569 |
#: W29-T01 β the Board retirement ran, per tenant, this process. Same shape and same reasoning as
|
|
@@ -1567,9 +1581,10 @@ def create_automation(body: dict = Body(default=None), session: Session = Depend
|
|
| 1567 |
_notes = []
|
| 1568 |
defn, error = engine.create(session.runtime, body or {}, username=session.uname,
|
| 1569 |
notes=_notes)
|
| 1570 |
-
if error:
|
| 1571 |
-
raise err(400, "invalid_automation", error)
|
| 1572 |
-
|
|
|
|
| 1573 |
|
| 1574 |
|
| 1575 |
@router.patch("/automations/{auto_id}")
|
|
@@ -1602,10 +1617,11 @@ def patch_automation(auto_id: str, body: dict = Body(default=None),
|
|
| 1602 |
raise err(400 if error != "no such automation" else 404,
|
| 1603 |
"invalid_automation" if error != "no such automation" else "unknown_automation",
|
| 1604 |
error)
|
| 1605 |
-
# An action the user removed takes its annotation with it, or the mark outlives its subject
|
| 1606 |
-
# and refuses an id nobody can see.
|
| 1607 |
-
_prune_marks(session, auto_id)
|
| 1608 |
-
|
|
|
|
| 1609 |
|
| 1610 |
|
| 1611 |
@router.delete("/automations/{auto_id}")
|
|
@@ -1642,11 +1658,17 @@ def delete_automation(auto_id: str, session: Session = Depends(_GATE)):
|
|
| 1642 |
_sys = str(_defn.get("system") or "").strip()
|
| 1643 |
if _sys:
|
| 1644 |
raise err(409, "system_agent", engine.system_agent_refusal(_sys))
|
| 1645 |
-
engine.remove(session.runtime, auto_id)
|
| 1646 |
# R9's other half: the user MAY delete an agent-authored step, and deleting the whole
|
| 1647 |
# automation takes its annotations with it rather than stranding them in the document.
|
| 1648 |
-
_prune_marks(session, auto_id)
|
| 1649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
|
| 1651 |
|
| 1652 |
@router.post("/automations/preview")
|
|
@@ -1679,8 +1701,13 @@ def run_automation(auto_id: str, session: Session = Depends(_GATE)):
|
|
| 1679 |
refusal = engine.run_refusal(defn)
|
| 1680 |
if refusal:
|
| 1681 |
raise err(400, "action_unconfigured", refusal)
|
| 1682 |
-
|
| 1683 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1684 |
return {"started": str(auto_id), "startedAt": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
| 1685 |
|
| 1686 |
|
|
@@ -1697,11 +1724,12 @@ def toggle_automation_node(auto_id: str, node_id: str, session: Session = Depend
|
|
| 1697 |
if engine.running(session.tenant, auto_id):
|
| 1698 |
raise err(409, "automation_running", "it is running. Wait for it to finish")
|
| 1699 |
defn, error = engine.toggle_node(session.runtime, auto_id, node_id)
|
| 1700 |
-
if error:
|
| 1701 |
-
raise err(404 if error == "no such automation" else 400,
|
| 1702 |
-
"unknown_automation" if error == "no such automation" else "node_not_toggleable",
|
| 1703 |
-
error)
|
| 1704 |
-
|
|
|
|
| 1705 |
|
| 1706 |
|
| 1707 |
@router.post("/automations/{auto_id}/hook/{token}")
|
|
@@ -1759,16 +1787,9 @@ def tick(request: Request, x_aios_tick_token: str = Header(default="")):
|
|
| 1759 |
|
| 1760 |
Unauthenticated BY DESIGN and gated on a shared secret instead β an EventBridge rule has no
|
| 1761 |
cookie. Refuses when the secret is not configured (see the module header)."""
|
| 1762 |
-
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
"AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed")
|
| 1766 |
-
got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or ""
|
| 1767 |
-
if got != want:
|
| 1768 |
-
raise err(403, "bad_tick_token", "that token is not valid for this deployment")
|
| 1769 |
-
started = engine.tick_all()
|
| 1770 |
-
return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 1771 |
-
"scheduler": engine.scheduler_status()}
|
| 1772 |
|
| 1773 |
|
| 1774 |
@router.post("/automations/ig/purge")
|
|
|
|
| 18 |
|
| 19 |
from fastapi import APIRouter, Body, Depends, Header, Request
|
| 20 |
|
| 21 |
+
import ai_review
|
| 22 |
+
import automation_control
|
| 23 |
+
import automation_engine as engine
|
| 24 |
import oauth_connect
|
| 25 |
import routes_oauth
|
| 26 |
import scope_cache
|
|
|
|
| 52 |
#: the day the row lands the wall is already the one that was tested).
|
| 53 |
MODULE = "automation"
|
| 54 |
|
| 55 |
+
_GATE = module_gate(MODULE)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _sync_schedule_or_503(tenant, defn):
|
| 59 |
+
"""Apply the exact native schedules after a definition write, never silently drift."""
|
| 60 |
+
try:
|
| 61 |
+
return automation_control.sync_definition(tenant, defn)
|
| 62 |
+
except automation_control.ControlPlaneError as exc:
|
| 63 |
+
raise err(503, "automation_control_unavailable",
|
| 64 |
+
"the definition was saved, but its AWS schedule could not be reconciled. "
|
| 65 |
+
f"Nothing was reported as armed: {exc}")
|
| 66 |
|
| 67 |
|
| 68 |
def _wire(defn, tenant, rt=None):
|
|
|
|
| 572 |
|
| 573 |
|
| 574 |
def _tick_state():
|
| 575 |
+
"""Can this deployment reconcile exact AWS schedules and dispatch finite workers?"""
|
| 576 |
+
if automation_control.configured():
|
| 577 |
+
return {"enabled": True, "source": "exact-aws"}
|
| 578 |
status = engine.scheduler_status()
|
| 579 |
+
return {"enabled": bool(status.get("source") == "in-process"),
|
| 580 |
+
"source": "in-process" if status.get("source") == "in-process" else ""}
|
| 581 |
|
| 582 |
|
| 583 |
#: W29-T01 β the Board retirement ran, per tenant, this process. Same shape and same reasoning as
|
|
|
|
| 1581 |
_notes = []
|
| 1582 |
defn, error = engine.create(session.runtime, body or {}, username=session.uname,
|
| 1583 |
notes=_notes)
|
| 1584 |
+
if error:
|
| 1585 |
+
raise err(400, "invalid_automation", error)
|
| 1586 |
+
_sync_schedule_or_503(session.tenant, defn)
|
| 1587 |
+
return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes}
|
| 1588 |
|
| 1589 |
|
| 1590 |
@router.patch("/automations/{auto_id}")
|
|
|
|
| 1617 |
raise err(400 if error != "no such automation" else 404,
|
| 1618 |
"invalid_automation" if error != "no such automation" else "unknown_automation",
|
| 1619 |
error)
|
| 1620 |
+
# An action the user removed takes its annotation with it, or the mark outlives its subject
|
| 1621 |
+
# and refuses an id nobody can see.
|
| 1622 |
+
_prune_marks(session, auto_id)
|
| 1623 |
+
_sync_schedule_or_503(session.tenant, defn)
|
| 1624 |
+
return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes}
|
| 1625 |
|
| 1626 |
|
| 1627 |
@router.delete("/automations/{auto_id}")
|
|
|
|
| 1658 |
_sys = str(_defn.get("system") or "").strip()
|
| 1659 |
if _sys:
|
| 1660 |
raise err(409, "system_agent", engine.system_agent_refusal(_sys))
|
| 1661 |
+
engine.remove(session.runtime, auto_id)
|
| 1662 |
# R9's other half: the user MAY delete an agent-authored step, and deleting the whole
|
| 1663 |
# automation takes its annotations with it rather than stranding them in the document.
|
| 1664 |
+
_prune_marks(session, auto_id)
|
| 1665 |
+
try:
|
| 1666 |
+
automation_control.delete_definition(session.tenant, auto_id)
|
| 1667 |
+
except automation_control.ControlPlaneError as exc:
|
| 1668 |
+
raise err(503, "automation_control_unavailable",
|
| 1669 |
+
"the automation was deleted, but its AWS schedules could not be removed: "
|
| 1670 |
+
f"{exc}")
|
| 1671 |
+
return {"deleted": str(auto_id)}
|
| 1672 |
|
| 1673 |
|
| 1674 |
@router.post("/automations/preview")
|
|
|
|
| 1701 |
refusal = engine.run_refusal(defn)
|
| 1702 |
if refusal:
|
| 1703 |
raise err(400, "action_unconfigured", refusal)
|
| 1704 |
+
try:
|
| 1705 |
+
started = engine.run_async(session.runtime, session.tenant, auto_id,
|
| 1706 |
+
username=session.uname)
|
| 1707 |
+
except automation_control.ControlPlaneError as exc:
|
| 1708 |
+
raise err(503, "automation_control_unavailable", str(exc))
|
| 1709 |
+
if not started:
|
| 1710 |
+
raise err(409, "automation_running", "that automation is already running")
|
| 1711 |
return {"started": str(auto_id), "startedAt": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
| 1712 |
|
| 1713 |
|
|
|
|
| 1724 |
if engine.running(session.tenant, auto_id):
|
| 1725 |
raise err(409, "automation_running", "it is running. Wait for it to finish")
|
| 1726 |
defn, error = engine.toggle_node(session.runtime, auto_id, node_id)
|
| 1727 |
+
if error:
|
| 1728 |
+
raise err(404 if error == "no such automation" else 400,
|
| 1729 |
+
"unknown_automation" if error == "no such automation" else "node_not_toggleable",
|
| 1730 |
+
error)
|
| 1731 |
+
_sync_schedule_or_503(session.tenant, defn)
|
| 1732 |
+
return {"automation": _wire(defn, session.tenant, rt=session.runtime)}
|
| 1733 |
|
| 1734 |
|
| 1735 |
@router.post("/automations/{auto_id}/hook/{token}")
|
|
|
|
| 1787 |
|
| 1788 |
Unauthenticated BY DESIGN and gated on a shared secret instead β an EventBridge rule has no
|
| 1789 |
cookie. Refuses when the secret is not configured (see the module header)."""
|
| 1790 |
+
raise err(410, "tick_retired",
|
| 1791 |
+
"the global automation tick is retired. Enabled automations own exact AWS "
|
| 1792 |
+
"schedules and direct events dispatch scale-to-zero workers")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1793 |
|
| 1794 |
|
| 1795 |
@router.post("/automations/ig/purge")
|
api/routes_tables.py
CHANGED
|
@@ -1352,11 +1352,21 @@ def delete_table(table_key: str, session: Session = Depends(require_session)):
|
|
| 1352 |
defn = _defn_or_refuse(session, table_key)
|
| 1353 |
if not (session.admin or defn.get("createdBy") == session.uname):
|
| 1354 |
raise err(403, "forbidden", "only the database's creator or an admin can delete it")
|
| 1355 |
-
try:
|
| 1356 |
-
import
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1360 |
try:
|
| 1361 |
_ut().delete(table_key, st=session.runtime)
|
| 1362 |
except Exception:
|
|
|
|
| 1352 |
defn = _defn_or_refuse(session, table_key)
|
| 1353 |
if not (session.admin or defn.get("createdBy") == session.uname):
|
| 1354 |
raise err(403, "forbidden", "only the database's creator or an admin can delete it")
|
| 1355 |
+
try:
|
| 1356 |
+
import automation_control
|
| 1357 |
+
import automation_engine as engine
|
| 1358 |
+
touched = engine.disable_for_table(session.runtime, table_key)
|
| 1359 |
+
defs = engine.all_definitions(session.runtime)
|
| 1360 |
+
for auto_id in touched:
|
| 1361 |
+
automation_control.sync_definition(session.tenant, defs[auto_id])
|
| 1362 |
+
except automation_control.ControlPlaneError as exc:
|
| 1363 |
+
raise err(503, "automation_control_unavailable",
|
| 1364 |
+
"the database is still present, but its automations could not be disarmed in "
|
| 1365 |
+
f"AWS: {exc}")
|
| 1366 |
+
except Exception as exc:
|
| 1367 |
+
raise err(503, "automation_disable_failed",
|
| 1368 |
+
f"the database is still present because its automations could not be disabled: "
|
| 1369 |
+
f"{type(exc).__name__}")
|
| 1370 |
try:
|
| 1371 |
_ut().delete(table_key, st=session.runtime)
|
| 1372 |
except Exception:
|
platform/core/data_binding.py
CHANGED
|
@@ -59,6 +59,11 @@ import os
|
|
| 59 |
#: agree β a constant two features share is a constant that drifts ([[constant-two-features-share]]).
|
| 60 |
PRODUCTION_SPACES = frozenset({'fsanyoto/runloopable'})
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
#: The stores that hold REAL customer business data. Enumerated 2026-08-18 from the live control
|
| 63 |
#: plane (`royal-imports/cfo-os-data::tenants.json`), not guessed.
|
| 64 |
#:
|
|
@@ -80,7 +85,8 @@ PRODUCTION_DEFAULT_STORE = 'royal-imports/cfo-os-data'
|
|
| 80 |
#: The env vars a Hugging Face Space container sets to describe itself, in the order they are
|
| 81 |
#: trusted. `SPACE_ID` is the canonical one; the pair is the documented fallback. Read as a LIST so
|
| 82 |
#: `describe()` can report every one of them and what it held.
|
| 83 |
-
_IDENTITY_VARS = ('
|
|
|
|
| 84 |
|
| 85 |
#: The env a NON-production deployment uses to name the stores it may write, beyond `OS_DATA_REPO`.
|
| 86 |
#: Comma-separated.
|
|
@@ -121,6 +127,9 @@ def deployment_id():
|
|
| 121 |
platform inside the container, so it cannot be forgotten, mistyped, or left behind by a Space
|
| 122 |
that changed role without being redeployed.
|
| 123 |
"""
|
|
|
|
|
|
|
|
|
|
| 124 |
sid = _env('SPACE_ID')
|
| 125 |
if sid:
|
| 126 |
return sid
|
|
@@ -132,7 +141,7 @@ def deployment_id():
|
|
| 132 |
|
| 133 |
def is_production_deployment():
|
| 134 |
"""True only on the deployment that owns production data. Unknown β False (fail closed)."""
|
| 135 |
-
return deployment_id() in PRODUCTION_SPACES
|
| 136 |
|
| 137 |
|
| 138 |
def is_space():
|
|
|
|
| 59 |
#: agree β a constant two features share is a constant that drifts ([[constant-two-features-share]]).
|
| 60 |
PRODUCTION_SPACES = frozenset({'fsanyoto/runloopable'})
|
| 61 |
|
| 62 |
+
# Provisioning pins this exact identity into the scale-to-zero ECS task definition. It is the
|
| 63 |
+
# only non-Space process allowed to write production tenant schemas; unknown identities remain
|
| 64 |
+
# refused by the same default-deny guard.
|
| 65 |
+
PRODUCTION_WORKERS = frozenset({'aws:aios-automation-worker'})
|
| 66 |
+
|
| 67 |
#: The stores that hold REAL customer business data. Enumerated 2026-08-18 from the live control
|
| 68 |
#: plane (`royal-imports/cfo-os-data::tenants.json`), not guessed.
|
| 69 |
#:
|
|
|
|
| 85 |
#: The env vars a Hugging Face Space container sets to describe itself, in the order they are
|
| 86 |
#: trusted. `SPACE_ID` is the canonical one; the pair is the documented fallback. Read as a LIST so
|
| 87 |
#: `describe()` can report every one of them and what it held.
|
| 88 |
+
_IDENTITY_VARS = ('AIOS_DEPLOYMENT_ID', 'SPACE_ID', 'SPACE_AUTHOR_NAME',
|
| 89 |
+
'SPACE_REPO_NAME', 'SPACE_HOST')
|
| 90 |
|
| 91 |
#: The env a NON-production deployment uses to name the stores it may write, beyond `OS_DATA_REPO`.
|
| 92 |
#: Comma-separated.
|
|
|
|
| 127 |
platform inside the container, so it cannot be forgotten, mistyped, or left behind by a Space
|
| 128 |
that changed role without being redeployed.
|
| 129 |
"""
|
| 130 |
+
explicit = _env('AIOS_DEPLOYMENT_ID')
|
| 131 |
+
if explicit:
|
| 132 |
+
return explicit
|
| 133 |
sid = _env('SPACE_ID')
|
| 134 |
if sid:
|
| 135 |
return sid
|
|
|
|
| 141 |
|
| 142 |
def is_production_deployment():
|
| 143 |
"""True only on the deployment that owns production data. Unknown β False (fail closed)."""
|
| 144 |
+
return deployment_id() in (PRODUCTION_SPACES | PRODUCTION_WORKERS)
|
| 145 |
|
| 146 |
|
| 147 |
def is_space():
|
platform/harness/pg/schema.sql
CHANGED
|
@@ -116,6 +116,30 @@ CREATE TABLE IF NOT EXISTS control.audit (
|
|
| 116 |
CREATE INDEX IF NOT EXISTS audit_at_idx ON control.audit (at DESC);
|
| 117 |
CREATE INDEX IF NOT EXISTS audit_tenant_idx ON control.audit (tenant_slug, at DESC);
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
-- PER-TENANT: one schema `t_<slug>` per tenant, holding what the HF store holds today.
|
| 121 |
--
|
|
|
|
| 116 |
CREATE INDEX IF NOT EXISTS audit_at_idx ON control.audit (at DESC);
|
| 117 |
CREATE INDEX IF NOT EXISTS audit_tenant_idx ON control.audit (tenant_slug, at DESC);
|
| 118 |
|
| 119 |
+
-- Durable idempotency and audit for the scale-to-zero automation worker. EventBridge and Step
|
| 120 |
+
-- Functions are at-least-once transports; this row is what makes a retried scheduled minute one
|
| 121 |
+
-- business run instead of two. No scheduler heartbeat is stored here -- rows exist only when
|
| 122 |
+
-- actual work was dispatched.
|
| 123 |
+
CREATE TABLE IF NOT EXISTS control.automation_jobs (
|
| 124 |
+
job_id text PRIMARY KEY,
|
| 125 |
+
tenant_slug text NOT NULL REFERENCES control.tenants(slug) ON DELETE CASCADE,
|
| 126 |
+
automation_id text NOT NULL,
|
| 127 |
+
trigger_kind text NOT NULL,
|
| 128 |
+
generation text NOT NULL DEFAULT '',
|
| 129 |
+
scheduled_for timestamptz,
|
| 130 |
+
status text NOT NULL CHECK (status IN ('running', 'completed', 'skipped',
|
| 131 |
+
'failed')),
|
| 132 |
+
attempts integer NOT NULL DEFAULT 0,
|
| 133 |
+
created_at timestamptz NOT NULL DEFAULT now(),
|
| 134 |
+
started_at timestamptz,
|
| 135 |
+
finished_at timestamptz,
|
| 136 |
+
detail jsonb NOT NULL DEFAULT '{}'::jsonb
|
| 137 |
+
);
|
| 138 |
+
CREATE INDEX IF NOT EXISTS automation_jobs_tenant_at_idx
|
| 139 |
+
ON control.automation_jobs (tenant_slug, created_at DESC);
|
| 140 |
+
CREATE INDEX IF NOT EXISTS automation_jobs_open_idx
|
| 141 |
+
ON control.automation_jobs (status, created_at) WHERE status IN ('running', 'failed');
|
| 142 |
+
|
| 143 |
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
-- PER-TENANT: one schema `t_<slug>` per tenant, holding what the HF store holds today.
|
| 145 |
--
|