Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/automation_engine.py +103 -35
- api/rollup_sql.py +23 -6
- api/routes_changes.py +6 -1
- platform/harness/runtime.py +10 -0
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v33",
|
| 6 |
"sha": "2b115e5",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v34 (cb4caa1)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v34",
|
| 6 |
+
"sha": "cb4caa1",
|
| 7 |
+
"date": "2026-08-21",
|
| 8 |
+
"subject": "W39 Staging PostgreSQL egress controls"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v33",
|
| 12 |
"sha": "2b115e5",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v34 (cb4caa1)
|
api/automation_engine.py
CHANGED
|
@@ -14510,47 +14510,115 @@ def tick(rt, tenant, now=None, log=print):
|
|
| 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 |
-
|
| 14519 |
-
#
|
| 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 |
-
#
|
| 14533 |
-
#
|
| 14534 |
-
#
|
| 14535 |
-
#
|
| 14536 |
-
#
|
| 14537 |
-
#
|
| 14538 |
-
|
| 14539 |
-
|
| 14540 |
-
|
| 14541 |
-
|
| 14542 |
-
|
| 14543 |
-
|
| 14544 |
-
|
| 14545 |
-
|
| 14546 |
-
|
| 14547 |
-
|
| 14548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14549 |
if started:
|
| 14550 |
log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
|
| 14551 |
return started
|
| 14552 |
|
| 14553 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14554 |
def _pg_tick_refusal(tenant_slug, backend=None, write_refusal=None):
|
| 14555 |
"""The Pg write wall, before a background tick reads a tenant's whole workspace.
|
| 14556 |
|
|
|
|
| 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 |
+
# The automation definitions above are a small, separate `automations` bucket: schedule,
|
| 14514 |
+
# pending-result and email work must still be checked every minute. The THREE passes below
|
| 14515 |
+
# instead read the tenant-wide `user_tables` document (28.6 MB measured for tenant #0). On
|
| 14516 |
+
# Postgres its durable revision is a tiny row-column query, so a repeated minute with neither
|
| 14517 |
+
# a workspace write nor a new calendar day must not download that document merely to discover
|
| 14518 |
+
# that every derived value is unchanged. The day remains an independent trigger: date-window
|
| 14519 |
+
# metric and source-rollup cells may change at midnight without a user-table write.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14520 |
#
|
| 14521 |
+
# This is deliberately Pg-only. The HF revision counter is process-local and cannot witness a
|
| 14522 |
+
# second process's write, while Pg's `rev` is durable and schema-shared. An unavailable/odd
|
| 14523 |
+
# revision fails safe to the established refresh path; a failed derived pass is NOT memoised.
|
| 14524 |
+
# The in-process scheduler and the external tick can arrive together. Recheck the marker
|
| 14525 |
+
# *inside* one tenant lock so they cannot both pay for the same full document before either
|
| 14526 |
+
# has memoised it. The lock covers only the derived tail; schedules above remain independent.
|
| 14527 |
+
with _derived_tick_lock(tenant):
|
| 14528 |
+
_derived_due, _derived_marker = _derived_refresh_due(rt, tenant, now=now)
|
| 14529 |
+
if _derived_due:
|
| 14530 |
+
_derived_ok = True
|
| 14531 |
+
# C7's amendment: date-window metrics advance with the tick, so `today` is never staler
|
| 14532 |
+
# than one tick while a scheduler exists. A tenant without metric fields pays a dict scan.
|
| 14533 |
+
try:
|
| 14534 |
+
refresh_metrics(rt, log=log)
|
| 14535 |
+
except Exception as e: # noqa: BLE001
|
| 14536 |
+
_derived_ok = False
|
| 14537 |
+
log(f"[aios-auto] metric refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14538 |
+
# β 2026-08-07 β the relational pass, on the same tick and for the same reason. A rollup
|
| 14539 |
+
# on table A goes stale when table B gains a row, and A cannot know that happened; the
|
| 14540 |
+
# tick is the only place that sees both. Separate failure handling is retained.
|
| 14541 |
+
try:
|
| 14542 |
+
refresh_relations(rt, log=log)
|
| 14543 |
+
except Exception as e: # noqa: BLE001
|
| 14544 |
+
_derived_ok = False
|
| 14545 |
+
log(f"[aios-auto] relation refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14546 |
+
# Source-backed rollups move with Odoo and with their own date windows, so they share the
|
| 14547 |
+
# same durable-revision/day gate but retain their independent failure posture.
|
| 14548 |
+
try:
|
| 14549 |
+
_refresh_source_rollups(rt, tenant, log=log)
|
| 14550 |
+
except Exception as e: # noqa: BLE001
|
| 14551 |
+
_derived_ok = False
|
| 14552 |
+
log(f"[aios-auto] source rollup {tenant} failed: {type(e).__name__}: {e}")
|
| 14553 |
+
if _derived_ok and _derived_marker is not None:
|
| 14554 |
+
_DERIVED_TICK_STATE[str(tenant)] = _derived_marker
|
| 14555 |
if started:
|
| 14556 |
log(f"[aios-auto] tick {tenant}: started {', '.join(started)}")
|
| 14557 |
return started
|
| 14558 |
|
| 14559 |
|
| 14560 |
+
#: Postgres-only memo for the expensive derived half of `tick`. The key is the tenant slug; the
|
| 14561 |
+
#: marker contains both the durable bucket revision and the UTC calendar date. It is process
|
| 14562 |
+
#: memory by design: a new container pays one correct refresh, then resumes the no-idle-egress
|
| 14563 |
+
#: posture. Do not use it for schedules β those stay on the minute loop above.
|
| 14564 |
+
_DERIVED_TICK_STATE = {}
|
| 14565 |
+
_DERIVED_TICK_LOCKS = {}
|
| 14566 |
+
_DERIVED_TICK_LOCKS_GUARD = threading.Lock()
|
| 14567 |
+
|
| 14568 |
+
|
| 14569 |
+
def _derived_tick_lock(tenant):
|
| 14570 |
+
"""The one derived-work lock for this process and tenant, created without a map race."""
|
| 14571 |
+
key = str(tenant)
|
| 14572 |
+
with _DERIVED_TICK_LOCKS_GUARD:
|
| 14573 |
+
return _DERIVED_TICK_LOCKS.setdefault(key, threading.Lock())
|
| 14574 |
+
|
| 14575 |
+
|
| 14576 |
+
def _derived_refresh_due(rt, tenant, now=None, state=None, backend=None):
|
| 14577 |
+
"""Return `(due, marker)` for full-document derived work on a scheduler tick.
|
| 14578 |
+
|
| 14579 |
+
`marker is None` means no safe durable observation exists, so callers must preserve the
|
| 14580 |
+
previous behaviour. The helper is side-effect free; `tick` records a marker only after all
|
| 14581 |
+
three isolated passes completed, making a transient failure retry on the next minute.
|
| 14582 |
+
"""
|
| 14583 |
+
actual_backend = backend if backend is not None else os.environ.get("STORE_BACKEND", "hf")
|
| 14584 |
+
if str(actual_backend).strip().lower() != "pg":
|
| 14585 |
+
return True, None
|
| 14586 |
+
try:
|
| 14587 |
+
rev = (rt.revision(UT_STORE_KEY) or {}).get("token")
|
| 14588 |
+
if not rev:
|
| 14589 |
+
return True, None
|
| 14590 |
+
except Exception:
|
| 14591 |
+
return True, None
|
| 14592 |
+
# The deployed workers need one calendar, not their host-local timezone. Naive test times
|
| 14593 |
+
# represent UTC; an aware injected time is normalised before choosing the daily window.
|
| 14594 |
+
stamp = now or _dt.datetime.now(_dt.timezone.utc)
|
| 14595 |
+
if isinstance(stamp, _dt.datetime):
|
| 14596 |
+
if stamp.tzinfo is None:
|
| 14597 |
+
stamp = stamp.replace(tzinfo=_dt.timezone.utc)
|
| 14598 |
+
else:
|
| 14599 |
+
stamp = stamp.astimezone(_dt.timezone.utc)
|
| 14600 |
+
day = stamp.date().isoformat() if hasattr(stamp, "date") else str(stamp)[:10]
|
| 14601 |
+
marker = (str(rev), day)
|
| 14602 |
+
memo = _DERIVED_TICK_STATE if state is None else state
|
| 14603 |
+
return memo.get(str(tenant)) != marker, marker
|
| 14604 |
+
|
| 14605 |
+
|
| 14606 |
+
def _refresh_source_rollups(rt, tenant, log=print):
|
| 14607 |
+
"""Refresh every source-backed rollup; callers provide the no-op policy.
|
| 14608 |
+
|
| 14609 |
+
The existing per-pass error boundary lives in :func:`tick`. Keeping this helper focused on
|
| 14610 |
+
the actual full-document work makes the Postgres revision/day policy testable without a
|
| 14611 |
+
schedule, vendor call, or a hidden duplicate of the loop.
|
| 14612 |
+
"""
|
| 14613 |
+
import rollup_sql
|
| 14614 |
+
for _tk in list((rt.get(UT_STORE_KEY) or {})):
|
| 14615 |
+
if rollup_sql.source_fields((rt.get(UT_STORE_KEY) or {}).get(_tk) or {}):
|
| 14616 |
+
# `today` is DEFAULTED INSIDE `compute`, matching `refresh_metrics` above.
|
| 14617 |
+
n = rollup_sql.compute(rt, _tk)
|
| 14618 |
+
if n:
|
| 14619 |
+
log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}")
|
| 14620 |
+
|
| 14621 |
+
|
| 14622 |
def _pg_tick_refusal(tenant_slug, backend=None, write_refusal=None):
|
| 14623 |
"""The Pg write wall, before a background tick reads a tenant's whole workspace.
|
| 14624 |
|
api/rollup_sql.py
CHANGED
|
@@ -126,7 +126,14 @@ def _today():
|
|
| 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 +174,23 @@ 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 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
def compute(rt, table_key, today=None, tables=None):
|
| 129 |
+
"""Write changed source-backed rollup cells on `table_key`.
|
| 130 |
+
|
| 131 |
+
Returns ``{field_key: cells_changed}``. A store-backed call first applies the already-resolved
|
| 132 |
+
plans to its read snapshot. If no cell would change it deliberately never enters ``update``:
|
| 133 |
+
Pg's read-modify-write increments the document revision even when its JSON payload is byte-for-
|
| 134 |
+
byte equal, which would make an unchanged scheduler tick look like new source data forever.
|
| 135 |
+
When cells do differ, the plans are applied again inside the store's normal locked update so a
|
| 136 |
+
concurrent row edit is preserved and is picked up by the next revision-driven pass.
|
| 137 |
|
| 138 |
`tables` (a live `user_tables` dict) is the gate's injection point β the same shape
|
| 139 |
`automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
|
|
|
|
| 174 |
hit = values.get(join)
|
| 175 |
if hit is None and join.endswith(".0"):
|
| 176 |
hit = values.get(join[:-2])
|
| 177 |
+
value = "" if hit is None else str(hit)
|
| 178 |
+
if row.get(fkey) != value:
|
| 179 |
+
row[fkey] = value
|
| 180 |
+
n += 1
|
| 181 |
written[fkey] = n
|
| 182 |
return cur
|
| 183 |
|
| 184 |
if owned:
|
| 185 |
_apply(blob)
|
| 186 |
+
return {k: n for k, n in written.items() if n}
|
| 187 |
+
|
| 188 |
+
# A snapshot preflight costs one full read only on a due source-rollup pass. It avoids the far
|
| 189 |
+
# more expensive outcome on an idle tenant: a locked JSONB RMW, revision bump and then another
|
| 190 |
+
# full derived pass every minute because the scheduler can never observe a stable revision.
|
| 191 |
+
_apply(blob)
|
| 192 |
+
if not any(written.values()):
|
| 193 |
+
return {}
|
| 194 |
+
written.clear()
|
| 195 |
+
rt.update(ut.STORE_KEY, _apply, flush="async")
|
| 196 |
+
return {k: n for k, n in written.items() if n}
|
api/routes_changes.py
CHANGED
|
@@ -182,7 +182,12 @@ def _may_watch(session, scope):
|
|
| 182 |
hit = _WALL_CACHE.get(key)
|
| 183 |
if hit and hit[0] > now:
|
| 184 |
return hit[1]
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
# β ONLY A `True` IS CACHED, and the asymmetry is deliberate. `user_tables.all_tables()`
|
| 187 |
# SWALLOWS a store error and answers `{}`, which makes `may_open` False β so caching a
|
| 188 |
# negative would turn one transient store hiccup into "no database <name> here" for the whole
|
|
|
|
| 182 |
hit = _WALL_CACHE.get(key)
|
| 183 |
if hit and hit[0] > now:
|
| 184 |
return hit[1]
|
| 185 |
+
# `may_open` reads only a table definition (and, for a non-owner, the small shares bucket).
|
| 186 |
+
# Lend it the existing rows-free projection rather than a raw runtime: a cache miss remains
|
| 187 |
+
# the SAME ACL resolver and the same 30 s revocation bound, but Pg never sends the 28.6 MB
|
| 188 |
+
# `rows` payload just to decide whether this signed-in tab may watch its open database.
|
| 189 |
+
verdict = bool(user_tables.may_open(
|
| 190 |
+
scope, session.uname, session.admin, st=user_tables.lend_defs(session.runtime)))
|
| 191 |
# β ONLY A `True` IS CACHED, and the asymmetry is deliberate. `user_tables.all_tables()`
|
| 192 |
# SWALLOWS a store error and answers `{}`, which makes `may_open` False β so caching a
|
| 193 |
# negative would turn one transient store hiccup into "no database <name> here" for the whole
|
platform/harness/runtime.py
CHANGED
|
@@ -179,6 +179,16 @@ 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 |
+
"""The tiny durable change token for this tenant's addressed bucket.
|
| 184 |
+
|
| 185 |
+
`get_projection` is for a rows-free document; this is deliberately smaller still. It
|
| 186 |
+
delegates through the same bound store and `store_key()` seam as every other operation so
|
| 187 |
+
a shared-repository tenant asks for ``t/<tenant>/user_tables`` rather than tenant #0's
|
| 188 |
+
bucket. The Pg implementation reads only ``rev, updated_at`` from ``store_kv``.
|
| 189 |
+
"""
|
| 190 |
+
return self._store().revision(self.store_key(name))
|
| 191 |
+
|
| 192 |
def put(self, name, data):
|
| 193 |
return self._store().put(self.store_key(name), data)
|
| 194 |
|