fsanyoto commited on
Commit
5a07a58
Β·
verified Β·
1 Parent(s): 5337d43

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
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
- d["statusNote"] = note
 
 
 
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
- """Start a run on a background thread. False when one is already in flight (β†’ 409)."""
14495
- if running(tenant, auto_id):
14496
- return False
14497
- th = threading.Thread(target=run_now, args=(rt, tenant, auto_id, username, log, rows),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- external = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN"))
 
 
 
 
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 = "external" if external else ("in-process" if in_process else "")
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 automation_engine as engine
 
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 a schedule fire here? PostgreSQL production has one external scheduler only."""
 
 
565
  status = engine.scheduler_status()
566
- return {"enabled": bool(status.get("enabled")), "source": status.get("source") or ""}
 
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
- return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes}
 
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
- return {"automation": _wire(defn, session.tenant, rt=session.runtime), "notes": _notes}
 
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
- return {"deleted": str(auto_id)}
 
 
 
 
 
 
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
- if not engine.run_async(session.runtime, session.tenant, auto_id, username=session.uname):
1683
- raise err(409, "automation_running", "that automation is already running")
 
 
 
 
 
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
- return {"automation": _wire(defn, session.tenant, rt=session.runtime)}
 
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
- want = os.environ.get("AIOS_AUTOMATION_TICK_TOKEN") or ""
1763
- if not want:
1764
- raise err(403, "tick_disabled",
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 automation_engine as engine
1357
- engine.disable_for_table(session.runtime, table_key)
1358
- except Exception:
1359
- pass
 
 
 
 
 
 
 
 
 
 
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 = ('SPACE_ID', 'SPACE_AUTHOR_NAME', 'SPACE_REPO_NAME', 'SPACE_HOST')
 
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
  --