Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/automation_engine.py +312 -83
- api/rollup_sql.py +18 -6
- api/routes_automation.py +9 -17
- api/routes_platform_admin.py +4 -1
- platform/harness/runtime.py +4 -0
api/automation_engine.py
CHANGED
|
@@ -2469,11 +2469,19 @@ def retire_automation_board_state(rt, tables=None):
|
|
| 2469 |
#: ββ WAVE 31 Β· T35 (D-134) β the definitions memo, and the two things that make it safe.
|
| 2470 |
#: `tenant -> (monotonic_at, defs)`. Read ONLY through `all_definitions(..., cached=True)`, which
|
| 2471 |
#: exactly one caller uses (`grid_hook`).
|
| 2472 |
-
_DEFS_MEMO = {}
|
| 2473 |
#: Deliberately SHORT. This exists to collapse one burst of row events into one read, not to be a
|
| 2474 |
#: cache β an import of 20,000 rows arrives in far less than this, and a stale trigger for two
|
| 2475 |
#: seconds is bounded and recoverable where a stale one for a minute is a mystery.
|
| 2476 |
-
_DEFS_TTL = 2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2477 |
|
| 2478 |
|
| 2479 |
def _store_update(rt, fn, flush="sync", keeps_defs=False):
|
|
@@ -2501,12 +2509,14 @@ def _store_update(rt, fn, flush="sync", keeps_defs=False):
|
|
| 2501 |
trigger set for another tenant. The memo holds at most a handful of entries and the correct,
|
| 2502 |
boring thing costs nothing.
|
| 2503 |
"""
|
| 2504 |
-
if not keeps_defs:
|
| 2505 |
-
_DEFS_MEMO.clear()
|
| 2506 |
-
|
|
|
|
|
|
|
| 2507 |
|
| 2508 |
|
| 2509 |
-
def all_definitions(rt, cached=False):
|
| 2510 |
"""Every automation definition for this tenant.
|
| 2511 |
|
| 2512 |
ββ `cached=True` IS D-134's FIX, AND IT IS OPT-IN FOR A REASON. `grid_hook` runs ONCE PER ROW
|
|
@@ -2536,7 +2546,33 @@ def all_definitions(rt, cached=False):
|
|
| 2536 |
out = {str(aid): _without_retired_board(defn)[0] for aid, defn in raw.items()}
|
| 2537 |
if cached:
|
| 2538 |
_DEFS_MEMO[str(getattr(rt, "key", "") or "")] = (time.monotonic(), out)
|
| 2539 |
-
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2540 |
|
| 2541 |
|
| 2542 |
def _new_id(existing):
|
|
@@ -3086,9 +3122,13 @@ def _no_step(_text):
|
|
| 3086 |
is the part a test is allowed to call on its own."""
|
| 3087 |
|
| 3088 |
|
| 3089 |
-
def _release(tenant, auto_id):
|
| 3090 |
-
with _RUN_LOCK:
|
| 3091 |
-
_RUNNING.pop((tenant, str(auto_id)), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3092 |
|
| 3093 |
|
| 3094 |
def _commit_run(rt, auto_id, state, summary, counts, ok, affected=None, steps=None, notes=None):
|
|
@@ -12626,11 +12666,13 @@ def _table_handle(row, url_field):
|
|
| 12626 |
or "").strip().lower()
|
| 12627 |
|
| 12628 |
|
| 12629 |
-
def compute_metric_cells(rt, table_key, today=None):
|
| 12630 |
"""Recompute every metric cell on ONE table from the master series. One coalesced write,
|
| 12631 |
only when something actually changed (the flush-ceiling law); zero reads when the table
|
| 12632 |
has no metric fields or the master is off. Returns the number of rows touched."""
|
| 12633 |
-
|
|
|
|
|
|
|
| 12634 |
mfields = metric_fields_of_table(t)
|
| 12635 |
if not mfields:
|
| 12636 |
return 0
|
|
@@ -12664,8 +12706,13 @@ def compute_metric_cells(rt, table_key, today=None):
|
|
| 12664 |
tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
|
| 12665 |
return cur
|
| 12666 |
|
| 12667 |
-
|
| 12668 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12669 |
|
| 12670 |
|
| 12671 |
# ββ ββ THE RELATIONAL PASS (2026-08-07) β derived LINK cells and ROLLUP cells ββββββββββββββββ
|
|
@@ -13204,14 +13251,28 @@ def _refresh_relations_inplace(tables, log=print):
|
|
| 13204 |
return touched
|
| 13205 |
|
| 13206 |
|
| 13207 |
-
def refresh_relations(rt, log=print):
|
| 13208 |
"""The tick half of the relational pass β the twin of `refresh_metrics`.
|
| 13209 |
|
| 13210 |
β Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
|
| 13211 |
when table B gains a row, and A has no way to know that happened. Cheap by construction β a
|
| 13212 |
table declaring neither kind costs one dict scan.
|
| 13213 |
"""
|
| 13214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13215 |
str(key): {**(table or {}),
|
| 13216 |
"rows": {str(rid): dict(row or {})
|
| 13217 |
for rid, row in ((table or {}).get("rows") or {}).items()}}
|
|
@@ -13244,15 +13305,17 @@ def refresh_relations(rt, log=print):
|
|
| 13244 |
return actual[0]
|
| 13245 |
|
| 13246 |
|
| 13247 |
-
def refresh_metrics(rt, today=None, log=print):
|
| 13248 |
"""The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
|
| 13249 |
metric can never go staler than one tick while a scheduler exists. Cheap by construction β
|
| 13250 |
a table without metric fields costs a dict scan and nothing else."""
|
| 13251 |
-
|
| 13252 |
-
|
| 13253 |
-
|
| 13254 |
-
|
| 13255 |
-
|
|
|
|
|
|
|
| 13256 |
except Exception as e: # noqa: BLE001
|
| 13257 |
log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
|
| 13258 |
return touched
|
|
@@ -14441,7 +14504,7 @@ def run_async(rt, tenant, auto_id, username="automation", log=print, rows=None):
|
|
| 14441 |
# THE TICK + the in-process scheduler
|
| 14442 |
# ---------------------------------------------------------------------------------------------
|
| 14443 |
|
| 14444 |
-
def pending_collect_ids(rt):
|
| 14445 |
"""β 2026-08-06 β automations holding a snapshot the vendor is still building.
|
| 14446 |
|
| 14447 |
β THE DEFECT THIS CLOSES, reported live: *"why is the SMALL 10 records test search taking
|
|
@@ -14458,7 +14521,8 @@ def pending_collect_ids(rt):
|
|
| 14458 |
make an unscheduled automation's paid result depend on someone remembering to press a button.
|
| 14459 |
"""
|
| 14460 |
out = []
|
| 14461 |
-
|
|
|
|
| 14462 |
if not isinstance(d, dict):
|
| 14463 |
continue
|
| 14464 |
if (d.get("trigger") or {}).get("paused"):
|
|
@@ -14487,81 +14551,240 @@ def pending_collect_ids(rt):
|
|
| 14487 |
return sorted(out)
|
| 14488 |
|
| 14489 |
|
| 14490 |
-
def due_ids(rt, now=None):
|
| 14491 |
-
"""Which of this tenant's automations a tick at `now` should start. Pure over the store."""
|
| 14492 |
-
|
|
|
|
| 14493 |
|
| 14494 |
|
| 14495 |
-
def tick(rt, tenant, now=None, log=print):
|
| 14496 |
"""Fire every due automation for ONE tenant + poll every email trigger (C3). Returns the
|
| 14497 |
ids started. The email polls are bounded and fail-quiet per automation β one broken
|
| 14498 |
mailbox connection must not stop the tenant's schedules."""
|
| 14499 |
-
started = []
|
|
|
|
| 14500 |
# β COLLECT-FIRST (2026-08-06). A snapshot the vendor has finished building is a result the
|
| 14501 |
# tenant has already been charged for; it is collected whether or not this automation is on a
|
| 14502 |
# schedule. `_claim` makes the union safe β an id in both lists starts once.
|
| 14503 |
-
for aid in dict.fromkeys(list(pending_collect_ids(rt
|
|
|
|
| 14504 |
if run_async(rt, tenant, aid, username="scheduler", log=log):
|
| 14505 |
started.append(aid)
|
| 14506 |
-
for aid, d in
|
| 14507 |
if (d.get("trigger") or {}).get("key") == "email":
|
| 14508 |
try:
|
| 14509 |
if email_poll(rt, tenant, aid, d, log=log) is not None:
|
| 14510 |
started.append(aid)
|
| 14511 |
except Exception as e: # noqa: BLE001
|
| 14512 |
log(f"[aios-auto] email poll {tenant}/{aid} failed: {type(e).__name__}: {e}")
|
| 14513 |
-
#
|
| 14514 |
-
#
|
| 14515 |
-
|
| 14516 |
-
|
| 14517 |
-
|
| 14518 |
-
log(f"[aios-auto] metric refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14519 |
-
# β 2026-08-07 β the relational pass, on the same tick and for the same reason. A rollup on
|
| 14520 |
-
# table A goes stale when table B gains a row, and A cannot know that happened; the tick is
|
| 14521 |
-
# the only place that sees both. β SEPARATE try/except from the metrics above deliberately β
|
| 14522 |
-
# one pass failing must not silently cancel the other, which a shared block would do.
|
| 14523 |
-
try:
|
| 14524 |
-
refresh_relations(rt, log=log)
|
| 14525 |
-
except Exception as e: # noqa: BLE001
|
| 14526 |
-
log(f"[aios-auto] relation refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14527 |
-
# β THE READ-THROUGH ROLLUPS, on the same tick and for a stronger version of the same reason.
|
| 14528 |
-
# A linked rollup goes stale when the LINKED TABLE gains a row; a source-backed one goes stale
|
| 14529 |
-
# when ODOO does β and its window moves on its own besides (a `ytd` column is wrong on 1
|
| 14530 |
-
# January without anybody writing anything). The tick is the only place that sees either.
|
| 14531 |
-
#
|
| 14532 |
-
# β ITS OWN try/except, like the two passes above, and the reason is the same one stated
|
| 14533 |
-
# there: this pass REFUSES loudly on a truncated group set, and a shared block would let that
|
| 14534 |
-
# honest refusal silently cancel the relational pass that had already succeeded.
|
| 14535 |
-
#
|
| 14536 |
-
# β IT WRITES NOTHING WHEN IT REFUSES β see `rollup_sql`'s header. A half-applied rollup mixes
|
| 14537 |
-
# two vintages of one column and looks completely normal, which is why the refusal is total.
|
| 14538 |
-
try:
|
| 14539 |
-
import rollup_sql
|
| 14540 |
-
for _tk in list((rt.get(UT_STORE_KEY) or {})):
|
| 14541 |
-
if rollup_sql.source_fields((rt.get(UT_STORE_KEY) or {}).get(_tk) or {}):
|
| 14542 |
-
# β `today` is DEFAULTED INSIDE `compute`, not passed: this scope has no
|
| 14543 |
-
# such local, and `refresh_metrics` above resolves it the same way.
|
| 14544 |
-
n = rollup_sql.compute(rt, _tk)
|
| 14545 |
-
if n:
|
| 14546 |
-
log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}")
|
| 14547 |
-
except Exception as e: # noqa: BLE001
|
| 14548 |
-
log(f"[aios-auto] source rollup {tenant} failed: {type(e).__name__}: {e}")
|
| 14549 |
if started:
|
| 14550 |
log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
|
| 14551 |
-
return started
|
| 14552 |
-
|
| 14553 |
-
|
| 14554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14555 |
"""Every registered tenant. Fail-quiet per tenant: one tenant's broken store must not stop
|
| 14556 |
the others' schedules."""
|
| 14557 |
-
from harness import runtime as _rt
|
| 14558 |
-
out = {}
|
| 14559 |
-
|
| 14560 |
-
|
| 14561 |
-
|
| 14562 |
-
|
| 14563 |
-
|
| 14564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14565 |
|
| 14566 |
|
| 14567 |
#: How often the in-process scheduler wakes. A minute is the cron resolution; anything finer
|
|
@@ -14585,7 +14808,7 @@ def scheduler_loop(log=print):
|
|
| 14585 |
log(f"[aios-auto] scheduler tick failed: {type(e).__name__}: {e}")
|
| 14586 |
|
| 14587 |
|
| 14588 |
-
def start_scheduler(log=print):
|
| 14589 |
"""Start the loop once per process. **OPT-IN: `AIOS_AUTOMATIONS=1`.**
|
| 14590 |
|
| 14591 |
β AMENDED 2026-08-03 (D), and the reason is a measurement rather than a preference. The wave
|
|
@@ -14602,9 +14825,15 @@ def start_scheduler(log=print):
|
|
| 14602 |
external EventBridge tick (R5) does not depend on it either way, since that POSTs the
|
| 14603 |
endpoint rather than riding this thread.
|
| 14604 |
"""
|
| 14605 |
-
if os.environ.get("AIOS_AUTOMATIONS") != "1":
|
| 14606 |
-
return False
|
| 14607 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14608 |
return False
|
| 14609 |
th = threading.Thread(target=scheduler_loop, kwargs={"log": log},
|
| 14610 |
daemon=True, name="automation-scheduler")
|
|
|
|
| 2469 |
#: ββ WAVE 31 Β· T35 (D-134) β the definitions memo, and the two things that make it safe.
|
| 2470 |
#: `tenant -> (monotonic_at, defs)`. Read ONLY through `all_definitions(..., cached=True)`, which
|
| 2471 |
#: exactly one caller uses (`grid_hook`).
|
| 2472 |
+
_DEFS_MEMO = {}
|
| 2473 |
#: Deliberately SHORT. This exists to collapse one burst of row events into one read, not to be a
|
| 2474 |
#: cache β an import of 20,000 rows arrives in far less than this, and a stale trigger for two
|
| 2475 |
#: seconds is bounded and recoverable where a stale one for a minute is a mystery.
|
| 2476 |
+
_DEFS_TTL = 2.0
|
| 2477 |
+
|
| 2478 |
+
# The durable external scheduler has a different read pattern from a row-event burst. A live
|
| 2479 |
+
# Space is one process / one replica today, and every automation-definition write in this module
|
| 2480 |
+
# passes through `_store_update`, so the scheduler can retain definitions until a REAL write
|
| 2481 |
+
# invalidates them. This is the cost boundary for Neon: an idle 15-minute wake-up must not turn
|
| 2482 |
+
# into 96 reads/day of the same tenant JSON value merely because a timer fired.
|
| 2483 |
+
_TICK_DEFS_MEMO = {}
|
| 2484 |
+
_TICK_CACHE_LOCK = threading.RLock()
|
| 2485 |
|
| 2486 |
|
| 2487 |
def _store_update(rt, fn, flush="sync", keeps_defs=False):
|
|
|
|
| 2509 |
trigger set for another tenant. The memo holds at most a handful of entries and the correct,
|
| 2510 |
boring thing costs nothing.
|
| 2511 |
"""
|
| 2512 |
+
if not keeps_defs:
|
| 2513 |
+
_DEFS_MEMO.clear()
|
| 2514 |
+
with _TICK_CACHE_LOCK:
|
| 2515 |
+
_TICK_DEFS_MEMO.clear()
|
| 2516 |
+
return rt.update(STORE_KEY, fn, flush=flush)
|
| 2517 |
|
| 2518 |
|
| 2519 |
+
def all_definitions(rt, cached=False):
|
| 2520 |
"""Every automation definition for this tenant.
|
| 2521 |
|
| 2522 |
ββ `cached=True` IS D-134's FIX, AND IT IS OPT-IN FOR A REASON. `grid_hook` runs ONCE PER ROW
|
|
|
|
| 2546 |
out = {str(aid): _without_retired_board(defn)[0] for aid, defn in raw.items()}
|
| 2547 |
if cached:
|
| 2548 |
_DEFS_MEMO[str(getattr(rt, "key", "") or "")] = (time.monotonic(), out)
|
| 2549 |
+
return out
|
| 2550 |
+
|
| 2551 |
+
|
| 2552 |
+
def tick_definitions(rt):
|
| 2553 |
+
"""Definitions for the external scheduler, cached until `_store_update` changes them.
|
| 2554 |
+
|
| 2555 |
+
Live has one replica, all product writes use `_store_update`, and a restart naturally drops
|
| 2556 |
+
this process cache. The existing two-second memo remains dedicated to row-event bursts.
|
| 2557 |
+
"""
|
| 2558 |
+
key = str(getattr(rt, "key", "") or "")
|
| 2559 |
+
with _TICK_CACHE_LOCK:
|
| 2560 |
+
hit = _TICK_DEFS_MEMO.get(key)
|
| 2561 |
+
if hit is not None:
|
| 2562 |
+
return hit
|
| 2563 |
+
fresh = all_definitions(rt)
|
| 2564 |
+
with _TICK_CACHE_LOCK:
|
| 2565 |
+
return _TICK_DEFS_MEMO.setdefault(key, fresh)
|
| 2566 |
+
|
| 2567 |
+
|
| 2568 |
+
def invalidate_tick_cache(tenant=None):
|
| 2569 |
+
"""Drop scheduler control-plane state after an out-of-process tenant change."""
|
| 2570 |
+
with _TICK_CACHE_LOCK:
|
| 2571 |
+
if tenant is None:
|
| 2572 |
+
_TICK_DEFS_MEMO.clear()
|
| 2573 |
+
_TICK_TENANT_CACHE.clear()
|
| 2574 |
+
else:
|
| 2575 |
+
_TICK_DEFS_MEMO.pop(str(tenant), None)
|
| 2576 |
|
| 2577 |
|
| 2578 |
def _new_id(existing):
|
|
|
|
| 3122 |
is the part a test is allowed to call on its own."""
|
| 3123 |
|
| 3124 |
|
| 3125 |
+
def _release(tenant, auto_id):
|
| 3126 |
+
with _RUN_LOCK:
|
| 3127 |
+
_RUNNING.pop((tenant, str(auto_id)), None)
|
| 3128 |
+
# A completed/manual/event run may have changed rows that feed a metric, link or source
|
| 3129 |
+
# rollup. Mark the derived lane dirty; the next durable tick will refresh it once. Merely
|
| 3130 |
+
# waking the scheduler does not mark anything dirty and therefore does not poll Neon.
|
| 3131 |
+
_mark_derived_dirty(tenant)
|
| 3132 |
|
| 3133 |
|
| 3134 |
def _commit_run(rt, auto_id, state, summary, counts, ok, affected=None, steps=None, notes=None):
|
|
|
|
| 12666 |
or "").strip().lower()
|
| 12667 |
|
| 12668 |
|
| 12669 |
+
def compute_metric_cells(rt, table_key, today=None, tables=None, persist=False):
|
| 12670 |
"""Recompute every metric cell on ONE table from the master series. One coalesced write,
|
| 12671 |
only when something actually changed (the flush-ceiling law); zero reads when the table
|
| 12672 |
has no metric fields or the master is off. Returns the number of rows touched."""
|
| 12673 |
+
owned = tables is not None
|
| 12674 |
+
blob = tables if owned else None
|
| 12675 |
+
t = ((blob or {}).get(str(table_key)) if owned else ut_get(rt, table_key))
|
| 12676 |
mfields = metric_fields_of_table(t)
|
| 12677 |
if not mfields:
|
| 12678 |
return 0
|
|
|
|
| 12706 |
tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
|
| 12707 |
return cur
|
| 12708 |
|
| 12709 |
+
if owned:
|
| 12710 |
+
_up(blob)
|
| 12711 |
+
if persist:
|
| 12712 |
+
rt.update(UT_STORE_KEY, _up, flush="sync")
|
| 12713 |
+
return len(changes)
|
| 12714 |
+
rt.update(UT_STORE_KEY, _up, flush="sync")
|
| 12715 |
+
return len(changes)
|
| 12716 |
|
| 12717 |
|
| 12718 |
# ββ ββ THE RELATIONAL PASS (2026-08-07) β derived LINK cells and ROLLUP cells ββββββββββββββββ
|
|
|
|
| 13251 |
return touched
|
| 13252 |
|
| 13253 |
|
| 13254 |
+
def refresh_relations(rt, log=print, tables=None, persist=True):
|
| 13255 |
"""The tick half of the relational pass β the twin of `refresh_metrics`.
|
| 13256 |
|
| 13257 |
β Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
|
| 13258 |
when table B gains a row, and A has no way to know that happened. Cheap by construction β a
|
| 13259 |
table declaring neither kind costs one dict scan.
|
| 13260 |
"""
|
| 13261 |
+
if tables is not None:
|
| 13262 |
+
local = _refresh_relations_inplace(tables, log=log)
|
| 13263 |
+
if not local or not persist:
|
| 13264 |
+
return local
|
| 13265 |
+
actual = [0]
|
| 13266 |
+
|
| 13267 |
+
def _up(cur):
|
| 13268 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 13269 |
+
actual[0] = _refresh_relations_inplace(cur, log=log)
|
| 13270 |
+
return cur
|
| 13271 |
+
|
| 13272 |
+
rt.update(UT_STORE_KEY, _up, flush="async")
|
| 13273 |
+
return actual[0]
|
| 13274 |
+
|
| 13275 |
+
snapshot = {
|
| 13276 |
str(key): {**(table or {}),
|
| 13277 |
"rows": {str(rid): dict(row or {})
|
| 13278 |
for rid, row in ((table or {}).get("rows") or {}).items()}}
|
|
|
|
| 13305 |
return actual[0]
|
| 13306 |
|
| 13307 |
|
| 13308 |
+
def refresh_metrics(rt, today=None, log=print, tables=None, persist=True):
|
| 13309 |
"""The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
|
| 13310 |
metric can never go staler than one tick while a scheduler exists. Cheap by construction β
|
| 13311 |
a table without metric fields costs a dict scan and nothing else."""
|
| 13312 |
+
snapshot = tables if tables is not None else ut_all(rt)
|
| 13313 |
+
touched = 0
|
| 13314 |
+
for tk, t in snapshot.items():
|
| 13315 |
+
if metric_fields_of_table(t):
|
| 13316 |
+
try:
|
| 13317 |
+
touched += compute_metric_cells(rt, tk, today=today, tables=snapshot,
|
| 13318 |
+
persist=persist)
|
| 13319 |
except Exception as e: # noqa: BLE001
|
| 13320 |
log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
|
| 13321 |
return touched
|
|
|
|
| 14504 |
# THE TICK + the in-process scheduler
|
| 14505 |
# ---------------------------------------------------------------------------------------------
|
| 14506 |
|
| 14507 |
+
def pending_collect_ids(rt, definitions=None):
|
| 14508 |
"""β 2026-08-06 β automations holding a snapshot the vendor is still building.
|
| 14509 |
|
| 14510 |
β THE DEFECT THIS CLOSES, reported live: *"why is the SMALL 10 records test search taking
|
|
|
|
| 14521 |
make an unscheduled automation's paid result depend on someone remembering to press a button.
|
| 14522 |
"""
|
| 14523 |
out = []
|
| 14524 |
+
definitions = all_definitions(rt) if definitions is None else definitions
|
| 14525 |
+
for aid, d in definitions.items():
|
| 14526 |
if not isinstance(d, dict):
|
| 14527 |
continue
|
| 14528 |
if (d.get("trigger") or {}).get("paused"):
|
|
|
|
| 14551 |
return sorted(out)
|
| 14552 |
|
| 14553 |
|
| 14554 |
+
def due_ids(rt, now=None, definitions=None):
|
| 14555 |
+
"""Which of this tenant's automations a tick at `now` should start. Pure over the store."""
|
| 14556 |
+
definitions = all_definitions(rt) if definitions is None else definitions
|
| 14557 |
+
return sorted(aid for aid, d in definitions.items() if is_due(d, now))
|
| 14558 |
|
| 14559 |
|
| 14560 |
+
def tick(rt, tenant, now=None, log=print):
|
| 14561 |
"""Fire every due automation for ONE tenant + poll every email trigger (C3). Returns the
|
| 14562 |
ids started. The email polls are bounded and fail-quiet per automation β one broken
|
| 14563 |
mailbox connection must not stop the tenant's schedules."""
|
| 14564 |
+
started = []
|
| 14565 |
+
definitions = tick_definitions(rt)
|
| 14566 |
# β COLLECT-FIRST (2026-08-06). A snapshot the vendor has finished building is a result the
|
| 14567 |
# tenant has already been charged for; it is collected whether or not this automation is on a
|
| 14568 |
# schedule. `_claim` makes the union safe β an id in both lists starts once.
|
| 14569 |
+
for aid in dict.fromkeys(list(pending_collect_ids(rt, definitions=definitions))
|
| 14570 |
+
+ list(due_ids(rt, now, definitions=definitions))):
|
| 14571 |
if run_async(rt, tenant, aid, username="scheduler", log=log):
|
| 14572 |
started.append(aid)
|
| 14573 |
+
for aid, d in definitions.items():
|
| 14574 |
if (d.get("trigger") or {}).get("key") == "email":
|
| 14575 |
try:
|
| 14576 |
if email_poll(rt, tenant, aid, d, log=log) is not None:
|
| 14577 |
started.append(aid)
|
| 14578 |
except Exception as e: # noqa: BLE001
|
| 14579 |
log(f"[aios-auto] email poll {tenant}/{aid} failed: {type(e).__name__}: {e}")
|
| 14580 |
+
# Derived cells are maintenance, not part of the scheduler's HTTP acknowledgement. The
|
| 14581 |
+
# background lane reads the large workspace at most once per UTC day, plus once after a real
|
| 14582 |
+
# automation run marks it dirty. An idle wake-up therefore stays rows-free, and Lambda never
|
| 14583 |
+
# times out waiting for a 40 MB tenant document to cross the public network.
|
| 14584 |
+
_start_derived_refresh(rt, tenant, now=now, log=log)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14585 |
if started:
|
| 14586 |
log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
|
| 14587 |
+
return started
|
| 14588 |
+
|
| 14589 |
+
|
| 14590 |
+
# Postgres-only memo for the expensive derived half of the tick. The marker combines the
|
| 14591 |
+
# durable workspace revision with the UTC calendar day: unchanged minutes do no full-document
|
| 14592 |
+
# read, while midnight and a real workspace write each trigger one correct refresh.
|
| 14593 |
+
_DERIVED_TICK_STATE = {}
|
| 14594 |
+
_DERIVED_TICK_LOCKS = {}
|
| 14595 |
+
_DERIVED_TICK_LOCKS_GUARD = threading.Lock()
|
| 14596 |
+
_DERIVED_DIRTY_GENERATION = {}
|
| 14597 |
+
_DERIVED_RUNNING = {}
|
| 14598 |
+
_DERIVED_WORK_SEMAPHORE = threading.Semaphore(1)
|
| 14599 |
+
_TICK_TENANT_CACHE = {}
|
| 14600 |
+
_SCHEDULER_METRICS = {
|
| 14601 |
+
"ticks": 0,
|
| 14602 |
+
"lastTickAt": "",
|
| 14603 |
+
"lastTenantCount": 0,
|
| 14604 |
+
"workspaceRefreshes": 0,
|
| 14605 |
+
"lastWorkspaceRefreshAt": "",
|
| 14606 |
+
}
|
| 14607 |
+
|
| 14608 |
+
|
| 14609 |
+
def _derived_tick_lock(tenant):
|
| 14610 |
+
key = str(tenant)
|
| 14611 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14612 |
+
return _DERIVED_TICK_LOCKS.setdefault(key, threading.Lock())
|
| 14613 |
+
|
| 14614 |
+
|
| 14615 |
+
def _mark_derived_dirty(tenant):
|
| 14616 |
+
"""Record a real automation run without reading the workspace it may have changed."""
|
| 14617 |
+
key = str(tenant)
|
| 14618 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14619 |
+
_DERIVED_DIRTY_GENERATION[key] = _DERIVED_DIRTY_GENERATION.get(key, 0) + 1
|
| 14620 |
+
|
| 14621 |
+
|
| 14622 |
+
def _derived_refresh_due(rt, tenant, now=None, state=None, backend=None):
|
| 14623 |
+
actual_backend = backend if backend is not None else os.environ.get("STORE_BACKEND", "hf")
|
| 14624 |
+
if str(actual_backend).strip().lower() != "pg":
|
| 14625 |
+
return True, None
|
| 14626 |
+
stamp = now or _dt.datetime.now(_dt.timezone.utc)
|
| 14627 |
+
if isinstance(stamp, _dt.datetime):
|
| 14628 |
+
stamp = (stamp.replace(tzinfo=_dt.timezone.utc) if stamp.tzinfo is None
|
| 14629 |
+
else stamp.astimezone(_dt.timezone.utc))
|
| 14630 |
+
day = stamp.date().isoformat() if hasattr(stamp, "date") else str(stamp)[:10]
|
| 14631 |
+
memo = _DERIVED_TICK_STATE if state is None else state
|
| 14632 |
+
previous = memo.get(str(tenant))
|
| 14633 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14634 |
+
dirty = bool(_DERIVED_DIRTY_GENERATION.get(str(tenant), 0)) if state is None else False
|
| 14635 |
+
# The old order queried `store_kv.rev` every 15 minutes and therefore woke a suspended Neon
|
| 14636 |
+
# compute even when nothing had changed. Human row routes already coalesce relation refreshes,
|
| 14637 |
+
# Odoo rebuilds refresh their own cells, and automation completion marks this lane dirty. So a
|
| 14638 |
+
# same-day clean marker is sufficient evidence to do literally no database work on an idle tick.
|
| 14639 |
+
if (not dirty and isinstance(previous, (tuple, list)) and len(previous) > 1
|
| 14640 |
+
and str(previous[1]) == day):
|
| 14641 |
+
return False, tuple(previous)
|
| 14642 |
+
try:
|
| 14643 |
+
rev = (rt.revision(UT_STORE_KEY) or {}).get("token")
|
| 14644 |
+
if not rev:
|
| 14645 |
+
return True, None
|
| 14646 |
+
except Exception:
|
| 14647 |
+
return True, None
|
| 14648 |
+
marker = (str(rev), day)
|
| 14649 |
+
return memo.get(str(tenant)) != marker, marker
|
| 14650 |
+
|
| 14651 |
+
|
| 14652 |
+
def _refresh_source_rollups(rt, tenant, log=print, tables=None):
|
| 14653 |
+
import rollup_sql
|
| 14654 |
+
snapshot = tables if tables is not None else (rt.get(UT_STORE_KEY) or {})
|
| 14655 |
+
for table_key, table in list(snapshot.items()):
|
| 14656 |
+
if rollup_sql.source_fields(table or {}):
|
| 14657 |
+
n = rollup_sql.compute(rt, table_key, tables=snapshot, persist=True)
|
| 14658 |
+
if n:
|
| 14659 |
+
log(f"[aios-auto] source rollups {tenant}/{table_key}: {n}")
|
| 14660 |
+
|
| 14661 |
+
|
| 14662 |
+
def _derived_refresh_worker(rt, tenant, marker, generation, log=print):
|
| 14663 |
+
"""Run one coalesced derived pass away from the external tick's HTTP response."""
|
| 14664 |
+
key = str(tenant)
|
| 14665 |
+
ok = True
|
| 14666 |
+
# One rows-bearing pass at a time across the whole process. Four tenants currently total
|
| 14667 |
+
# about 67 MB of workspace JSON; serialising that maintenance avoids a restart turning into
|
| 14668 |
+
# four simultaneous Neon result streams and a compute autoscale spike.
|
| 14669 |
+
with _DERIVED_WORK_SEMAPHORE, _derived_tick_lock(key):
|
| 14670 |
+
try:
|
| 14671 |
+
tables = dict(rt.get(UT_STORE_KEY) or {})
|
| 14672 |
+
except Exception as e: # noqa: BLE001
|
| 14673 |
+
tables = None
|
| 14674 |
+
ok = False
|
| 14675 |
+
log(f"[aios-auto] derived workspace snapshot {key} failed: "
|
| 14676 |
+
f"{type(e).__name__}: {e}")
|
| 14677 |
+
if tables is not None:
|
| 14678 |
+
for label, fn in (
|
| 14679 |
+
("metric", lambda: refresh_metrics(rt, log=log, tables=tables)),
|
| 14680 |
+
("relation", lambda: refresh_relations(rt, log=log, tables=tables)),
|
| 14681 |
+
("source rollup", lambda: _refresh_source_rollups(
|
| 14682 |
+
rt, key, log=log, tables=tables))):
|
| 14683 |
+
try:
|
| 14684 |
+
fn()
|
| 14685 |
+
except Exception as e: # noqa: BLE001
|
| 14686 |
+
ok = False
|
| 14687 |
+
log(f"[aios-auto] {label} refresh {key} failed: "
|
| 14688 |
+
f"{type(e).__name__}: {e}")
|
| 14689 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14690 |
+
_DERIVED_RUNNING.pop(key, None)
|
| 14691 |
+
if ok and marker is not None:
|
| 14692 |
+
_DERIVED_TICK_STATE[key] = marker
|
| 14693 |
+
# Do not erase a write that landed after this worker took its snapshot. Its newer
|
| 14694 |
+
# generation remains dirty and the next external tick repairs it.
|
| 14695 |
+
if _DERIVED_DIRTY_GENERATION.get(key, 0) == generation:
|
| 14696 |
+
_DERIVED_DIRTY_GENERATION.pop(key, None)
|
| 14697 |
+
_SCHEDULER_METRICS["workspaceRefreshes"] += 1
|
| 14698 |
+
_SCHEDULER_METRICS["lastWorkspaceRefreshAt"] = _iso()
|
| 14699 |
+
|
| 14700 |
+
|
| 14701 |
+
def _start_derived_refresh(rt, tenant, now=None, log=print):
|
| 14702 |
+
"""Start at most one rows-bearing maintenance worker for a tenant. Returns whether started."""
|
| 14703 |
+
due, marker = _derived_refresh_due(rt, tenant, now=now)
|
| 14704 |
+
if not due:
|
| 14705 |
+
return False
|
| 14706 |
+
key = str(tenant)
|
| 14707 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14708 |
+
if key in _DERIVED_RUNNING:
|
| 14709 |
+
return False
|
| 14710 |
+
generation = _DERIVED_DIRTY_GENERATION.get(key, 0)
|
| 14711 |
+
_DERIVED_RUNNING[key] = generation
|
| 14712 |
+
thread = threading.Thread(
|
| 14713 |
+
target=_derived_refresh_worker,
|
| 14714 |
+
args=(rt, key, marker, generation, log),
|
| 14715 |
+
daemon=True,
|
| 14716 |
+
name=f"automation-derived-{key}")
|
| 14717 |
+
thread.start()
|
| 14718 |
+
return True
|
| 14719 |
+
|
| 14720 |
+
|
| 14721 |
+
def _pg_tick_refusal(tenant_slug, backend=None, write_refusal=None):
|
| 14722 |
+
actual_backend = backend if backend is not None else os.environ.get("STORE_BACKEND", "hf")
|
| 14723 |
+
if str(actual_backend).strip().lower() != "pg":
|
| 14724 |
+
return None
|
| 14725 |
+
if write_refusal is None:
|
| 14726 |
+
import core.store_pg as _pg
|
| 14727 |
+
write_refusal = _pg.write_refusal
|
| 14728 |
+
return write_refusal(str(tenant_slug))
|
| 14729 |
+
|
| 14730 |
+
|
| 14731 |
+
def _scheduler_tenants(runtime_module, now=None):
|
| 14732 |
+
"""Known tenants, refreshed once per UTC day rather than on every external wake-up."""
|
| 14733 |
+
stamp = now or _dt.datetime.now(_dt.timezone.utc)
|
| 14734 |
+
if isinstance(stamp, _dt.datetime):
|
| 14735 |
+
stamp = (stamp.replace(tzinfo=_dt.timezone.utc) if stamp.tzinfo is None
|
| 14736 |
+
else stamp.astimezone(_dt.timezone.utc))
|
| 14737 |
+
day = stamp.date().isoformat() if hasattr(stamp, "date") else str(stamp)[:10]
|
| 14738 |
+
with _TICK_CACHE_LOCK:
|
| 14739 |
+
if _TICK_TENANT_CACHE.get("day") == day:
|
| 14740 |
+
return list(_TICK_TENANT_CACHE.get("slugs") or [])
|
| 14741 |
+
slugs = list(runtime_module.known_tenants())
|
| 14742 |
+
with _TICK_CACHE_LOCK:
|
| 14743 |
+
_TICK_TENANT_CACHE.update({"day": day, "slugs": tuple(slugs)})
|
| 14744 |
+
return slugs
|
| 14745 |
+
|
| 14746 |
+
|
| 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),
|
| 14759 |
+
"source": source,
|
| 14760 |
+
"backend": backend,
|
| 14761 |
+
"definitionCaches": len(_TICK_DEFS_MEMO),
|
| 14762 |
+
"tenantCacheDay": str(_TICK_TENANT_CACHE.get("day") or ""),
|
| 14763 |
+
"derivedRunning": len(_DERIVED_RUNNING),
|
| 14764 |
+
**dict(_SCHEDULER_METRICS),
|
| 14765 |
+
}
|
| 14766 |
+
|
| 14767 |
+
|
| 14768 |
+
def tick_all(now=None, log=print):
|
| 14769 |
"""Every registered tenant. Fail-quiet per tenant: one tenant's broken store must not stop
|
| 14770 |
the others' schedules."""
|
| 14771 |
+
from harness import runtime as _rt
|
| 14772 |
+
out = {}
|
| 14773 |
+
slugs = _scheduler_tenants(_rt, now=now)
|
| 14774 |
+
for slug in slugs:
|
| 14775 |
+
try:
|
| 14776 |
+
refusal = _pg_tick_refusal(slug)
|
| 14777 |
+
if refusal:
|
| 14778 |
+
log(f"[aios-auto] tick {slug} skipped before runtime read: {refusal}")
|
| 14779 |
+
continue
|
| 14780 |
+
out[slug] = tick(_rt.get_runtime(slug), slug, now=now, log=log)
|
| 14781 |
+
except Exception as e: # noqa: BLE001
|
| 14782 |
+
log(f"[aios-auto] tick {slug} skipped: {type(e).__name__}: {e}")
|
| 14783 |
+
with _TICK_CACHE_LOCK:
|
| 14784 |
+
_SCHEDULER_METRICS["ticks"] += 1
|
| 14785 |
+
_SCHEDULER_METRICS["lastTickAt"] = _iso()
|
| 14786 |
+
_SCHEDULER_METRICS["lastTenantCount"] = len(slugs)
|
| 14787 |
+
return out
|
| 14788 |
|
| 14789 |
|
| 14790 |
#: How often the in-process scheduler wakes. A minute is the cron resolution; anything finer
|
|
|
|
| 14808 |
log(f"[aios-auto] scheduler tick failed: {type(e).__name__}: {e}")
|
| 14809 |
|
| 14810 |
|
| 14811 |
+
def start_scheduler(log=print):
|
| 14812 |
"""Start the loop once per process. **OPT-IN: `AIOS_AUTOMATIONS=1`.**
|
| 14813 |
|
| 14814 |
β AMENDED 2026-08-03 (D), and the reason is a measurement rather than a preference. The wave
|
|
|
|
| 14825 |
external EventBridge tick (R5) does not depend on it either way, since that POSTs the
|
| 14826 |
endpoint rather than riding this thread.
|
| 14827 |
"""
|
| 14828 |
+
if os.environ.get("AIOS_AUTOMATIONS") != "1":
|
| 14829 |
+
return False
|
| 14830 |
+
# PostgreSQL production has one durable wake-up: EventBridge -> Lambda -> protected endpoint.
|
| 14831 |
+
# Refuse the old resident minute loop even if a stale secret is reintroduced; two schedulers
|
| 14832 |
+
# were the measured cause of the Neon transfer incident this guard closes.
|
| 14833 |
+
if str(os.environ.get("STORE_BACKEND") or "hf").strip().lower() == "pg":
|
| 14834 |
+
log("[aios-auto] in-process scheduler refused on PostgreSQL; use the external tick")
|
| 14835 |
+
return False
|
| 14836 |
+
if _SCHEDULER[0] is not None:
|
| 14837 |
return False
|
| 14838 |
th = threading.Thread(target=scheduler_loop, kwargs={"log": log},
|
| 14839 |
daemon=True, name="automation-scheduler")
|
api/rollup_sql.py
CHANGED
|
@@ -125,8 +125,8 @@ def _today():
|
|
| 125 |
return _dt.date.today().strftime("%Y-%m-%d")
|
| 126 |
|
| 127 |
|
| 128 |
-
def compute(rt, table_key, today=None, tables=None):
|
| 129 |
-
"""Write
|
| 130 |
|
| 131 |
`tables` (a live `user_tables` dict) is the gate's injection point β the same shape
|
| 132 |
`automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
|
|
@@ -167,13 +167,25 @@ def compute(rt, table_key, today=None, tables=None):
|
|
| 167 |
hit = values.get(join)
|
| 168 |
if hit is None and join.endswith(".0"):
|
| 169 |
hit = values.get(join[:-2])
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
written[fkey] = n
|
| 173 |
return cur
|
| 174 |
|
| 175 |
if owned:
|
| 176 |
_apply(blob)
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 178 |
rt.update(ut.STORE_KEY, _apply, flush="async")
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return _dt.date.today().strftime("%Y-%m-%d")
|
| 126 |
|
| 127 |
|
| 128 |
+
def compute(rt, table_key, today=None, tables=None, persist=False):
|
| 129 |
+
"""Write changed source-backed rollup cells on `table_key`.
|
| 130 |
|
| 131 |
`tables` (a live `user_tables` dict) is the gate's injection point β the same shape
|
| 132 |
`automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
|
|
|
|
| 167 |
hit = values.get(join)
|
| 168 |
if hit is None and join.endswith(".0"):
|
| 169 |
hit = values.get(join[:-2])
|
| 170 |
+
value = "" if hit is None else str(hit)
|
| 171 |
+
if row.get(fkey) != value:
|
| 172 |
+
row[fkey] = value
|
| 173 |
+
n += 1
|
| 174 |
written[fkey] = n
|
| 175 |
return cur
|
| 176 |
|
| 177 |
if owned:
|
| 178 |
_apply(blob)
|
| 179 |
+
changed = {k: n for k, n in written.items() if n}
|
| 180 |
+
if not changed or not persist:
|
| 181 |
+
return changed
|
| 182 |
+
written.clear()
|
| 183 |
rt.update(ut.STORE_KEY, _apply, flush="async")
|
| 184 |
+
return {k: n for k, n in written.items() if n}
|
| 185 |
+
|
| 186 |
+
_apply(blob)
|
| 187 |
+
if not any(written.values()):
|
| 188 |
+
return {}
|
| 189 |
+
written.clear()
|
| 190 |
+
rt.update(ut.STORE_KEY, _apply, flush="async")
|
| 191 |
+
return {k: n for k, n in written.items() if n}
|
api/routes_automation.py
CHANGED
|
@@ -560,19 +560,10 @@ def _triggers_vocab(session):
|
|
| 560 |
return out
|
| 561 |
|
| 562 |
|
| 563 |
-
def _tick_state():
|
| 564 |
-
"""
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
`automation_engine.py` module bottom) and an external cron POSTing `/automations/tick`,
|
| 568 |
-
gated on `AIOS_AUTOMATION_TICK_TOKEN` (AWS EventBridge in production). The Step-1 Trigger
|
| 569 |
-
card must be honest in both directions: "schedules won't fire" on a deployment where
|
| 570 |
-
EventBridge demonstrably fires them daily is the exact lie R9 forbids. `external` means
|
| 571 |
-
"the door is OPEN", never "the caller is alive" β the client's copy says so."""
|
| 572 |
-
inproc = os.environ.get("AIOS_AUTOMATIONS") == "1"
|
| 573 |
-
ext = bool(os.environ.get("AIOS_AUTOMATION_TICK_TOKEN"))
|
| 574 |
-
return {"enabled": bool(inproc or ext),
|
| 575 |
-
"source": "in-process" if inproc else ("external" if ext else "")}
|
| 576 |
|
| 577 |
|
| 578 |
#: W29-T01 β the Board retirement ran, per tenant, this process. Same shape and same reasoning as
|
|
@@ -1773,10 +1764,11 @@ def tick(request: Request, x_aios_tick_token: str = Header(default="")):
|
|
| 1773 |
raise err(403, "tick_disabled",
|
| 1774 |
"AIOS_AUTOMATION_TICK_TOKEN is not configured. The tick endpoint is closed")
|
| 1775 |
got = x_aios_tick_token or request.headers.get("X-AIOS-TICK-TOKEN") or ""
|
| 1776 |
-
if got != want:
|
| 1777 |
-
raise err(403, "bad_tick_token", "that token is not valid for this deployment")
|
| 1778 |
-
started = engine.tick_all()
|
| 1779 |
-
return {"started": started, "at": time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
| 1780 |
|
| 1781 |
|
| 1782 |
@router.post("/automations/ig/purge")
|
|
|
|
| 560 |
return out
|
| 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
|
|
|
|
| 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")
|
api/routes_platform_admin.py
CHANGED
|
@@ -568,6 +568,7 @@ def platform_connectors(tenant: str = "", session: Session = Depends(padmin_gate
|
|
| 568 |
@router.get("/automations")
|
| 569 |
def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 570 |
"""Every tenant's automations, their schedules and run history, plus the fleet cost model."""
|
|
|
|
| 571 |
out, errors, retained = [], {}, 20
|
| 572 |
for t, rt in _scan(tenant):
|
| 573 |
slug = t["slug"]
|
|
@@ -580,11 +581,13 @@ def platform_automations(tenant: str = "", session: Session = Depends(padmin_gat
|
|
| 580 |
continue
|
| 581 |
retained = max_runs
|
| 582 |
out += rows
|
|
|
|
| 583 |
return {"automations": out, "count": len(out),
|
| 584 |
"enabled": sum(1 for a in out if a["enabled"]),
|
| 585 |
"historyRetained": retained,
|
| 586 |
"cost": _automation_cost(out), "errors": errors,
|
| 587 |
-
"tickEnabled":
|
|
|
|
| 588 |
|
| 589 |
|
| 590 |
@router.get("/aws")
|
|
|
|
| 568 |
@router.get("/automations")
|
| 569 |
def platform_automations(tenant: str = "", session: Session = Depends(padmin_gate)):
|
| 570 |
"""Every tenant's automations, their schedules and run history, plus the fleet cost model."""
|
| 571 |
+
import automation_engine as engine
|
| 572 |
out, errors, retained = [], {}, 20
|
| 573 |
for t, rt in _scan(tenant):
|
| 574 |
slug = t["slug"]
|
|
|
|
| 581 |
continue
|
| 582 |
retained = max_runs
|
| 583 |
out += rows
|
| 584 |
+
scheduler = engine.scheduler_status()
|
| 585 |
return {"automations": out, "count": len(out),
|
| 586 |
"enabled": sum(1 for a in out if a["enabled"]),
|
| 587 |
"historyRetained": retained,
|
| 588 |
"cost": _automation_cost(out), "errors": errors,
|
| 589 |
+
"tickEnabled": bool(scheduler.get("enabled")),
|
| 590 |
+
"scheduler": scheduler}
|
| 591 |
|
| 592 |
|
| 593 |
@router.get("/aws")
|
platform/harness/runtime.py
CHANGED
|
@@ -179,6 +179,10 @@ class TenantRuntime:
|
|
| 179 |
"""
|
| 180 |
return self._store().get_projection(self.store_key(name), drop=drop)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
def put(self, name, data):
|
| 183 |
return self._store().put(self.store_key(name), data)
|
| 184 |
|
|
|
|
| 179 |
"""
|
| 180 |
return self._store().get_projection(self.store_key(name), drop=drop)
|
| 181 |
|
| 182 |
+
def revision(self, name):
|
| 183 |
+
"""Return the tiny durable change token for this tenant's addressed bucket."""
|
| 184 |
+
return self._store().revision(self.store_key(name))
|
| 185 |
+
|
| 186 |
def put(self, name, data):
|
| 187 |
return self._store().put(self.store_key(name), data)
|
| 188 |
|