fsanyoto commited on
Commit
0427303
·
verified ·
1 Parent(s): 5a07a58

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/deps.py CHANGED
@@ -27,6 +27,7 @@ if str(_RI) not in sys.path:
27
  sys.path.insert(0, str(_RI))
28
 
29
  import core.perms as perms # noqa: E402
 
30
  import core.users as users # noqa: E402
31
  from harness import runtime # noqa: E402
32
 
@@ -663,6 +664,25 @@ def require_session(request: Request, response: Response) -> Session:
663
  return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
664
 
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  def module_gate(module_key):
667
  """A dependency that 401s without a session and 403s without the grant for `module_key`."""
668
  def _dep(session: Session = Depends(require_session)) -> Session:
 
27
  sys.path.insert(0, str(_RI))
28
 
29
  import core.perms as perms # noqa: E402
30
+ import core.store as store # noqa: E402
31
  import core.users as users # noqa: E402
32
  from harness import runtime # noqa: E402
33
 
 
664
  return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
665
 
666
 
667
+ def require_changes_session(request: Request, response: Response) -> Session | None:
668
+ """Authenticate the passive Pg change endpoint without reopening the database.
669
+
670
+ Pg change tokens are intentionally disabled: a current client refreshes the data it is viewing
671
+ on focus, while an already-open legacy client may still call this route every ten seconds. A
672
+ verified signed cookie is enough to return that no-data response; resolving the account record
673
+ would itself turn those legacy requests into a permanent Neon keepalive.
674
+ """
675
+ if store.backend() != "pg":
676
+ return require_session(request, response)
677
+ if request.headers.get("x-aios-change-check") == "focus-v1":
678
+ return require_session(request, response)
679
+ raw = request.cookies.get(aios_session.COOKIE_NAME)
680
+ claims = aios_session.read(raw) if raw else None
681
+ if not claims or not str(claims.get("t") or "").strip() or not str(claims.get("u") or "").strip():
682
+ raise err(401, "invalid_session", "your session has expired — sign in again")
683
+ return None
684
+
685
+
686
  def module_gate(module_key):
687
  """A dependency that 401s without a session and 403s without the grant for `module_key`."""
688
  def _dep(session: Session = Depends(require_session)) -> Session:
api/main.py CHANGED
@@ -1083,13 +1083,12 @@ def _store_resync_loop():
1083
  def _prewarm():
1084
  import time as _t
1085
  t0 = _t.time()
1086
- # The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can
1087
- # take many minutes of XML-RPC to close, and the first live probe of this arrangement
1088
- # showed the pool warm (42s) silently queued behind it the whole app cold for every
1089
- # visitor while a background column channel caught up. Measure cells retry via the
1090
- # transient rule until the sync lands; nothing else waits on it.
1091
- import threading as _th
1092
- _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
1093
  try:
1094
  from harness import runtime as _runtime
1095
  rt = _runtime.get_runtime("royal-imports")
@@ -1220,6 +1219,11 @@ if (os.environ.get("AIOS_ENABLE_QA_TENANT") == "1"
1220
  name="qa-sandbox-schema").start()
1221
 
1222
  if os.environ.get("AIOS_PREWARM") == "1":
1223
- import threading as _threading
1224
- _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
1225
- _threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()
 
 
 
 
 
 
1083
  def _prewarm():
1084
  import time as _t
1085
  t0 = _t.time()
1086
+ # The Odoo seed/sync can run for minutes and is separate from the finite cache warm below.
1087
+ # Keep it opt-in: Live Pg serves its seeded analytical mirror at boot and lets explicit
1088
+ # workers perform real sync work, so a routine restart can still scale Neon back to zero.
1089
+ if os.environ.get("AIOS_BOOT_SYNC") == "1":
1090
+ import threading as _th
1091
+ _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
 
1092
  try:
1093
  from harness import runtime as _runtime
1094
  rt = _runtime.get_runtime("royal-imports")
 
1219
  name="qa-sandbox-schema").start()
1220
 
1221
  if os.environ.get("AIOS_PREWARM") == "1":
1222
+ import threading as _threading
1223
+ _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
1224
+
1225
+ # A boot prewarm is finite; the Odoo/store resync loop is resident work. Keep the latter
1226
+ # opt-in so a Pg-backed Live Space can scale its Neon compute to zero between real requests.
1227
+ if os.environ.get("AIOS_STORE_RESYNC") == "1":
1228
+ import threading as _threading_resync
1229
+ _threading_resync.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()
api/routes_automation.py CHANGED
@@ -5,11 +5,9 @@ cron, a URL the SSRF rail refuses, a key field that is not one of the mapped col
5
  in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file
6
  does auth, shape and status codes.
7
 
8
- THE TICK ENDPOINT IS THE ONE UNAUTHENTICATED ROUTE, AND IT IS FAIL-CLOSED TWICE OVER. It takes
9
- no session (an external cron has no cookie), so it is gated on a shared secret in
10
- `X-AIOS-TICK-TOKEN`; and when `AIOS_AUTOMATION_TICK_TOKEN` is UNSET the route refuses everything
11
- rather than admitting everyone. A "no token configured means no check" default is how an internal
12
- trigger becomes a public one — the same class of mistake as an empty-200 permission answer.
13
  """
14
  import os
15
  import re
@@ -1878,10 +1876,10 @@ def run_rows(auto_id: str, session: Session = Depends(_GATE)):
1878
  # The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in
1879
  # verify_automation.py section T rather than assumed.
1880
  #
1881
- # THE DEPLOY MUST SET `AIOS_AUTOMATIONS=1` (with `AIOS_AUTOMATION_TICK_TOKEN`) or schedules
1882
- # only ever fire from the external cron POSTing /automations/tick. Both paths work; neither is
1883
- # implicit. Booked in the session-D mailbox.
1884
- engine.start_scheduler()
1885
 
1886
  # C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module
1887
  # is where the registration belongs — it is the one place that already imports both sides, so
 
5
  in `automation_engine`, which is a pure-ish module a gate can drive without a server. This file
6
  does auth, shape and status codes.
7
 
8
+ The former unauthenticated tick endpoint is permanently retired and returns HTTP 410. Production
9
+ definitions now reconcile exact native schedules through `automation_control`; direct events start
10
+ one finite worker immediately. No shared tick secret or polling clock remains.
 
 
11
  """
12
  import os
13
  import re
 
1876
  # The loop also SLEEPS FIRST (`engine.scheduler_loop`) — defence in depth, proven in
1877
  # verify_automation.py section T rather than assumed.
1878
  #
1879
+ # Production Pg refuses this resident path even if `AIOS_AUTOMATIONS` drifts on. Exact AWS
1880
+ # schedules and direct dispatch use `automation_control`; this call survives only for isolated
1881
+ # local/HF development fixtures that explicitly opt in.
1882
+ engine.start_scheduler()
1883
 
1884
  # C3 (wave 22): register the trigger listener onto the platform's row-event seam. THIS module
1885
  # is where the registration belongs — it is the one place that already imports both sides, so
api/routes_changes.py CHANGED
@@ -63,7 +63,7 @@ import core.user_tables as user_tables
63
  import modules.customer_data as customer_data
64
  import modules.product_data as product_data
65
 
66
- from deps import Session, err, require_session
67
 
68
  router = APIRouter(prefix="/api/v1")
69
 
@@ -198,16 +198,20 @@ def _may_watch(session, scope):
198
 
199
 
200
  @router.get("/changes")
201
- def changes(scope: str = "", session: Session = Depends(require_session)):
202
- """One tiny read per tab per interval. The response is small, uncacheable and boring."""
203
  names = buckets_for(scope)
204
  if names is None:
205
  raise err(400, "bad_scope", "name the table you are watching, e.g. ?scope=customer")
 
 
 
 
206
  # ⛔ W29-T82 — THE SAME REFUSAL EVERY SIBLING DOOR GIVES, word for word. A session that may
207
  # not open this table must not be able to tell whether it exists, and "404 unknown_table" is
208
  # the sentence the other five row doors answer with. A different code here would itself be
209
  # the tell.
210
- if names and not _may_watch(session, _clean_scope(scope)):
211
  raise err(404, "unknown_table", f"no database {_clean_scope(scope)!r} here")
212
  if not names:
213
  # ⛔ A well-formed scope this map does not know is a 404, NOT an empty `tokens` object.
 
63
  import modules.customer_data as customer_data
64
  import modules.product_data as product_data
65
 
66
+ from deps import Session, err, require_changes_session, require_session
67
 
68
  router = APIRouter(prefix="/api/v1")
69
 
 
198
 
199
 
200
  @router.get("/changes")
201
+ def changes(scope: str = "", session: Session | None = Depends(require_changes_session)):
202
+ """Serve legacy change checks without making Pg a resident client."""
203
  names = buckets_for(scope)
204
  if names is None:
205
  raise err(400, "bad_scope", "name the table you are watching, e.g. ?scope=customer")
206
+ # Pg clients refresh data on user focus. Returning null tokens is database-free so an old
207
+ # browser bundle cannot keep Neon awake while the current no-poll bundle rolls out.
208
+ if names and store.backend() == "pg" and session is None:
209
+ return {"scope": _clean_scope(scope), "tokens": {label: None for label in names}, "backend": "pg"}
210
  # ⛔ W29-T82 — THE SAME REFUSAL EVERY SIBLING DOOR GIVES, word for word. A session that may
211
  # not open this table must not be able to tell whether it exists, and "404 unknown_table" is
212
  # the sentence the other five row doors answer with. A different code here would itself be
213
  # the tell.
214
+ if names and (session is None or not _may_watch(session, _clean_scope(scope))):
215
  raise err(404, "unknown_table", f"no database {_clean_scope(scope)!r} here")
216
  if not names:
217
  # ⛔ A well-formed scope this map does not know is a 404, NOT an empty `tokens` object.
api/routes_forms.py CHANGED
@@ -231,7 +231,15 @@ def _public_form(rt, table_key: str, spec: dict) -> dict:
231
  import aios_grid
232
  import core.user_tables as user_tables
233
 
234
- defn = user_tables.get(table_key, st=rt) or {}
 
 
 
 
 
 
 
 
235
  by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
236
  required = {str(k) for k in (spec.get("required") or [])}
237
  out = []
 
231
  import aios_grid
232
  import core.user_tables as user_tables
233
 
234
+ # A public form renders its declared fields only; no submitted table row belongs on this
235
+ # path. In production `rt` is tenant-bound, so lend the rows-free projection. The `None`
236
+ # fallback preserves the isolated helper fixture used by the form verifier.
237
+ lend_defs = getattr(user_tables, 'lend_defs', None)
238
+ # Some isolated route verifiers supply a deliberately tiny `user_tables` double. Production
239
+ # always has `lend_defs`; preserving the double's direct handle keeps that fixture focused on
240
+ # its public-form contract rather than forcing it to impersonate the entire store API.
241
+ definitions = lend_defs(rt) if rt is not None and callable(lend_defs) else rt
242
+ defn = user_tables.get(table_key, st=definitions) or {}
243
  by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
244
  required = {str(k) for k in (spec.get("required") or [])}
245
  out = []
api/routes_nav.py CHANGED
@@ -764,10 +764,14 @@ def save_nav_meta(body: dict = Body(default=None),
764
  # because a user table is not a module. Same split `/nav/schema/{key}` makes.
765
  # · everything else — admin only, and icon only. There is no owner of `customer_data` to
766
  # defer to, and its label is a compiled registry literal (refused below regardless).
767
- if key.startswith("ut_"):
768
- import core.user_tables as user_tables
769
- if not user_tables.get(key, st=session.runtime) or not user_tables.may_open(
770
- key, session.uname, session.admin, st=session.runtime):
 
 
 
 
771
  raise err(403, "forbidden", "that database belongs to another user")
772
  else:
773
  if not session.admin:
@@ -886,11 +890,14 @@ def nav_schema(key: str, session: Session = Depends(require_session)):
886
  # Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
887
  # (`may_open`) rather than the module grant machinery — `session.require` would 403 every
888
  # ut key because a user table is deliberately not a module.
889
- if key.startswith("ut_"):
890
- import core.user_tables as user_tables
891
- defn = user_tables.get(key, st=session.runtime)
892
- if not defn or not user_tables.may_open(key, session.uname, session.admin,
893
- st=session.runtime):
 
 
 
894
  raise err(403, "forbidden", "that database belongs to another user")
895
  # ⭐⭐ W38-T18 — THE FIELD WALL ON A `ut_*` DATABASE, WHICH THIS DOOR HAS NEVER RUN.
896
  # `may_open` answers *IF* you reach the database and says nothing about WHICH COLUMNS, so
 
764
  # because a user table is not a module. Same split `/nav/schema/{key}` makes.
765
  # · everything else — admin only, and icon only. There is no owner of `customer_data` to
766
  # defer to, and its label is a compiled registry literal (refused below regardless).
767
+ if key.startswith("ut_"):
768
+ import core.user_tables as user_tables
769
+ # This write edits metadata, never a record. The table wall therefore needs its
770
+ # definition only; lending the rows-free JSONB projection keeps a rename/icon click from
771
+ # copying every table row just to prove the caller owns this database.
772
+ definitions = user_tables.lend_defs(session.runtime)
773
+ if not user_tables.get(key, st=definitions) or not user_tables.may_open(
774
+ key, session.uname, session.admin, st=definitions):
775
  raise err(403, "forbidden", "that database belongs to another user")
776
  else:
777
  if not session.admin:
 
890
  # Wave 18 C3-UT: a user table's schema is its own definition, walled by ITS predicate
891
  # (`may_open`) rather than the module grant machinery — `session.require` would 403 every
892
  # ut key because a user table is deliberately not a module.
893
+ if key.startswith("ut_"):
894
+ import core.user_tables as user_tables
895
+ # Schema is definition-only by contract. Do not pay a whole `user_tables` read merely
896
+ # to open the drawer; the rows-free projection is also lent to the canonical wall.
897
+ definitions = user_tables.lend_defs(session.runtime)
898
+ defn = user_tables.get(key, st=definitions)
899
+ if not defn or not user_tables.may_open(key, session.uname, session.admin,
900
+ st=definitions):
901
  raise err(403, "forbidden", "that database belongs to another user")
902
  # ⭐⭐ W38-T18 — THE FIELD WALL ON A `ut_*` DATABASE, WHICH THIS DOOR HAS NEVER RUN.
903
  # `may_open` answers *IF* you reach the database and says nothing about WHICH COLUMNS, so
api/routes_odoo_tables.py CHANGED
@@ -153,15 +153,33 @@ def refresh_odoo_tables(session: Session = Depends(require_session)):
153
 
154
 
155
  @router.get("/odoo-tables/status")
156
- def odoo_tables_status(session: Session = Depends(require_session)):
 
157
  """Row counts + the newest `refreshed` stamp per table.
158
 
159
  ⚠ The stamp is the whole point while the resync wiring is outstanding: a locked collections
160
  worklist that quietly stopped updating is a worklist that lies, so its age must be readable
161
  without anybody running a refresh to find out.
 
 
 
 
 
 
162
  """
163
- rel, out = _rel(), {}
 
 
 
 
 
 
 
164
  import core.user_tables as user_tables
 
 
 
 
165
  # ⭐ W30-T31 — THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
166
  # the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
167
  # store that is not ready costs the size half of the question, never the whole endpoint.
@@ -187,7 +205,7 @@ def odoo_tables_status(session: Session = Depends(require_session)):
187
  # hard-coded list here would have reported "everything is fine" over two databases it had
188
  # stopped knowing about ([[gate-answers-the-wrong-question]] — the hard-coded count).
189
  for _bucket, key, _label, _fields in rel.TABLES:
190
- table = user_tables.get(key, st=session.runtime) or {}
191
  rows = (table.get("rows") or {})
192
  stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
193
  # ⛔⛔ A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
@@ -196,7 +214,7 @@ def odoo_tables_status(session: Session = Depends(require_session)):
196
  # operator would see a database that looks EMPTY and STALE on a route whose own docstring
197
  # says a worklist that quietly stopped updating must be legible without a refresh. So a
198
  # read-through table is counted from the MIRROR and says where its count came from.
199
- materialised = user_tables.materialises(key, st=session.runtime)
200
  if not materialised:
201
  try:
202
  rows = {}
 
153
 
154
 
155
  @router.get("/odoo-tables/status")
156
+ def odoo_tables_status(brief: bool = Query(False),
157
+ session: Session = Depends(require_session)):
158
  """Row counts + the newest `refreshed` stamp per table.
159
 
160
  ⚠ The stamp is the whole point while the resync wiring is outstanding: a locked collections
161
  worklist that quietly stopped updating is a worklist that lies, so its age must be readable
162
  without anybody running a refresh to find out.
163
+
164
+ `brief=1` is the grid's one question at open time: which declared tables read through the
165
+ mirror? It deliberately does not borrow the tenant document, take a mirror cursor, or count
166
+ rows. Those would be work solely for metadata the client never reads. The source registry
167
+ remains the authority, so a future conversion is still discovered server-side rather than by
168
+ a client-side key list.
169
  """
170
+ rel = _rel()
171
+ if brief:
172
+ sources = _sources()
173
+ return {"ok": True, "tables": {
174
+ key: {"readThrough": key in sources}
175
+ for _bucket, key, _label, _fields in rel.TABLES
176
+ }}
177
+
178
  import core.user_tables as user_tables
179
+ # The detailed operator view genuinely needs rows to report counts/freshness, but it needs
180
+ # them ONCE. Repeated `get()` calls each copy the same tenant document under the store lock.
181
+ tables = user_tables.all_tables(st=session.runtime)
182
+ out = {}
183
  # ⭐ W30-T31 — THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
184
  # the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
185
  # store that is not ready costs the size half of the question, never the whole endpoint.
 
205
  # hard-coded list here would have reported "everything is fine" over two databases it had
206
  # stopped knowing about ([[gate-answers-the-wrong-question]] — the hard-coded count).
207
  for _bucket, key, _label, _fields in rel.TABLES:
208
+ table = tables.get(key) or {}
209
  rows = (table.get("rows") or {})
210
  stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
211
  # ⛔⛔ A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
 
214
  # operator would see a database that looks EMPTY and STALE on a route whose own docstring
215
  # says a worklist that quietly stopped updating must be legible without a refresh. So a
216
  # read-through table is counted from the MIRROR and says where its count came from.
217
+ materialised = user_tables.materialises(key, defn=table)
218
  if not materialised:
219
  try:
220
  rows = {}
api/routes_platform_admin.py CHANGED
@@ -251,15 +251,18 @@ def _connector_rows(slug, rt):
251
 
252
  # ══════════════════════════════════════════════════════════════════════════ automations + cost
253
 
254
- #: THE UNIT MODEL, from `ops/provision_automation_cron.py` (its cost note at :25-28 and the
255
- #: function it actually provisions at :211 / :269). Restated as constants rather than as prose so
256
- #: the arithmetic below is re-checkable against the thing that was really deployed:
257
- #: EventBridge Scheduler `rate(15 minutes)` → a 128 MB, 30 s-timeout Lambda that POSTs the tick.
258
- TICK_CADENCE = "rate(15 minutes)"
259
- TICK_PER_DAY = 96 # 1440 / 15
260
- LAMBDA_MB = 128
261
- FREE_LAMBDA_REQUESTS = 1_000_000 # AWS always-free, per month
262
- FREE_SCHEDULER_INVOCATIONS = 14_000_000
 
 
 
263
  DAYS_PER_MONTH = 30.4
264
 
265
 
@@ -299,6 +302,7 @@ def _runs_per_day(cron):
299
  def _automation_rows(slug, rt):
300
  """A tenant's automations, their schedules, and their retained run history."""
301
  try:
 
302
  import automation_engine as engine
303
  defs = engine.all_definitions(rt) or {}
304
  max_runs = int(getattr(engine, "MAX_RUNS", 20))
@@ -309,18 +313,33 @@ def _automation_rows(slug, rt):
309
  if not isinstance(d, dict):
310
  continue
311
  sched = d.get("schedule") or {}
 
312
  status = d.get("status") or {}
313
  runs = list(d.get("runs") or [])
314
  cron = sched.get("cron") or ""
315
- per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
316
  rows.append({
317
  "tenant": slug,
318
  "id": auto_id,
319
  "name": d.get("name") or auto_id,
320
  "kind": d.get("kind") or "",
321
- "enabled": bool(sched.get("enabled")),
322
  "cron": cron,
323
  "runsPerDay": round(per_day, 2) if per_day is not None else None,
 
 
324
  "state": status.get("state") or "idle",
325
  "lastRunAt": status.get("lastRunAt") or "",
326
  "lastSummary": status.get("lastSummary") or "",
@@ -334,40 +353,34 @@ def _automation_rows(slug, rt):
334
 
335
 
336
  def _automation_cost(auto_rows):
337
- """The estimated monthly cost of running the automation fleet — and the honest shape of it.
338
-
339
- THE POINT THIS BLOCK EXISTS TO MAKE, which is not intuitive: **the external cron does not
340
- scale with automations or tenants.** One EventBridge schedule fires one Lambda, which POSTs
341
- one tick, and that tick runs every DUE automation for every tenant (`provision_automation_cron`
342
- states this as the reason it stays $0 "at ten tenants"). So the AWS bill is a function of the
343
- CADENCE alone — the fleet below adds work inside the API container, which is already paid for.
344
-
345
- WHAT IS NOT COMPUTED HERE, deliberately: Lambda GB-seconds. That needs the real average
346
- duration of the function, which only CloudWatch knows; assuming one would be inventing the
347
- larger half of the free-tier calculation. `/aws` reports the measured figure when credentials
348
- are available, and this block says so rather than filling the gap with a plausible number.
349
  """
350
- invocations = TICK_PER_DAY * DAYS_PER_MONTH
351
  fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
352
  unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
 
 
 
353
  return {
354
- "cadence": TICK_CADENCE,
 
 
355
  "invocationsPerMonth": int(round(invocations)),
356
- "freeRequestsPct": round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3),
357
- "freeSchedulerPct": round(100.0 * invocations / FREE_SCHEDULER_INVOCATIONS, 4),
358
- "lambdaMb": LAMBDA_MB,
359
- "usd": 0.0,
360
  "fleetRunsPerDay": round(fleet_runs, 2),
361
  "unknownCadence": unknown_cadence,
362
  "basis": (
363
- f"One EventBridge schedule ({TICK_CADENCE}) fires one {LAMBDA_MB} MB Lambda that "
364
- f"POSTs the tick; that ONE tick runs every due automation for every tenant, so the "
365
- f"AWS cost is set by the cadence and does not grow with the fleet. "
366
- f"{int(round(invocations)):,} invocations/month is "
367
- f"{round(100.0 * invocations / FREE_LAMBDA_REQUESTS, 3)}% of the 1,000,000-request "
368
- f"always-free tier, so the marginal cost of the automations below is $0.00. "
369
- f"Compute (GB-seconds) depends on measured durations and is NOT estimated here — "
370
- f"the AWS report reads the real figure when credentials are available."
371
  ),
372
  }
373
 
@@ -581,18 +594,24 @@ def platform_automations(tenant: str = "", session: Session = Depends(padmin_gat
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")
594
  def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
595
- """The AWS cron's usage report, verbatim, or an honest note explaining its absence."""
596
  days = max(1, min(int(days or 7), 90))
597
  return {"days": days, "report": _aws_report(days)}
598
 
 
251
 
252
  # ══════════════════════════════════════════════════════════════════════════ automations + cost
253
 
254
+ #: 2026-08-21 public us-east-2 Linux/x86 rates for the deployed 0.25-vCPU / 1-GiB task.
255
+ #: This is a recurring-run FLOOR, not an invoice: Linux Fargate has a 60-second minimum and the
256
+ #: task receives one public IPv4 address while it runs. The measured `/aws` report is authoritative.
257
+ #: Sources: https://aws.amazon.com/fargate/pricing/ and https://aws.amazon.com/vpc/pricing/
258
+ FARGATE_VCPU_USD_HOUR = 0.04048
259
+ FARGATE_GB_USD_HOUR = 0.004445
260
+ PUBLIC_IPV4_USD_HOUR = 0.005
261
+ TASK_VCPU = 0.25
262
+ TASK_GB = 1.0
263
+ MIN_BILLED_SECONDS = 60
264
+ MIN_RUN_USD = ((TASK_VCPU * FARGATE_VCPU_USD_HOUR + TASK_GB * FARGATE_GB_USD_HOUR
265
+ + PUBLIC_IPV4_USD_HOUR) * MIN_BILLED_SECONDS / 3600.0)
266
  DAYS_PER_MONTH = 30.4
267
 
268
 
 
302
  def _automation_rows(slug, rt):
303
  """A tenant's automations, their schedules, and their retained run history."""
304
  try:
305
+ import automation_control as control
306
  import automation_engine as engine
307
  defs = engine.all_definitions(rt) or {}
308
  max_runs = int(getattr(engine, "MAX_RUNS", 20))
 
313
  if not isinstance(d, dict):
314
  continue
315
  sched = d.get("schedule") or {}
316
+ trigger = d.get("trigger") or {}
317
  status = d.get("status") or {}
318
  runs = list(d.get("runs") or [])
319
  cron = sched.get("cron") or ""
320
+ schedule_per_day = _runs_per_day(cron) if sched.get("enabled") else 0.0
321
+ email_enabled = (trigger.get("key") == "email" and trigger.get("enabled", True)
322
+ and not trigger.get("paused") and trigger.get("configured", True))
323
+ per_day = (None if schedule_per_day is None
324
+ else schedule_per_day + (96.0 if email_enabled else 0.0))
325
+ specs_error = False
326
+ try:
327
+ specs = control.recurring_specs(slug, d)
328
+ except Exception:
329
+ # A stored legacy/custom cron must read as unknown, never as zero cost.
330
+ specs = []
331
+ specs_error = True
332
+ per_day = None
333
  rows.append({
334
  "tenant": slug,
335
  "id": auto_id,
336
  "name": d.get("name") or auto_id,
337
  "kind": d.get("kind") or "",
338
+ "enabled": bool(specs) or specs_error,
339
  "cron": cron,
340
  "runsPerDay": round(per_day, 2) if per_day is not None else None,
341
+ "nativeSchedules": None if specs_error else len(specs),
342
+ "scheduleModes": sorted({str(s.get("mode") or "") for s in specs}),
343
  "state": status.get("state") or "idle",
344
  "lastRunAt": status.get("lastRunAt") or "",
345
  "lastSummary": status.get("lastSummary") or "",
 
353
 
354
 
355
  def _automation_cost(auto_rows):
356
+ """Minimum scheduled-run estimate for the exact-schedule, finite-worker architecture.
357
+
358
+ It includes the 60-second Fargate floor plus one temporary public IPv4 allocation. It excludes
359
+ unknown direct/manual events, one-shot vendor collections, Step Functions transitions above
360
+ its free allowance, task runtime beyond 60 seconds, and Neon compute/transfer. `/aws` is the
361
+ measured source of truth; this is only a transparent planning floor.
 
 
 
 
 
 
362
  """
 
363
  fleet_runs = sum(r["runsPerDay"] or 0 for r in auto_rows if r.get("enabled"))
364
  unknown_cadence = sum(1 for r in auto_rows if r.get("enabled") and r.get("runsPerDay") is None)
365
+ native_schedules = sum(int(r.get("nativeSchedules") or 0) for r in auto_rows)
366
+ invocations = fleet_runs * DAYS_PER_MONTH
367
+ minimum_usd = invocations * MIN_RUN_USD
368
  return {
369
+ "mode": "exact-schedules-fargate",
370
+ "cadence": "per automation",
371
+ "nativeSchedules": native_schedules,
372
  "invocationsPerMonth": int(round(invocations)),
373
+ "perRunMinimumUsd": round(MIN_RUN_USD, 8),
374
+ "usd": round(minimum_usd, 4),
 
 
375
  "fleetRunsPerDay": round(fleet_runs, 2),
376
  "unknownCadence": unknown_cadence,
377
  "basis": (
378
+ f"{native_schedules} exact native schedule(s) imply about "
379
+ f"{int(round(invocations)):,} finite run(s)/month. At the 60-second Fargate plus "
380
+ f"public-IPv4 minimum (${MIN_RUN_USD:.8f}/run), the recurring floor is "
381
+ f"about ${minimum_usd:.4f}/month. There is no resident ECS service. Direct/manual "
382
+ f"runs, one-shot vendor collection, longer tasks, paid Step Functions transitions "
383
+ f"and Neon are excluded; the AWS report shows measured cost."
 
 
384
  ),
385
  }
386
 
 
594
  continue
595
  retained = max_runs
596
  out += rows
597
+ import automation_control as control
598
+ resident = engine.scheduler_status()
599
+ exact_enabled = control.configured()
600
  return {"automations": out, "count": len(out),
601
  "enabled": sum(1 for a in out if a["enabled"]),
602
  "historyRetained": retained,
603
  "cost": _automation_cost(out), "errors": errors,
604
+ "tickEnabled": False,
605
+ "controlEnabled": exact_enabled,
606
+ "executionMode": "exact-aws" if exact_enabled else "local-disabled",
607
+ "scheduler": {"enabled": exact_enabled,
608
+ "source": "exact-aws" if exact_enabled else "",
609
+ "resident": resident}}
610
 
611
 
612
  @router.get("/aws")
613
  def platform_aws(days: int = 7, session: Session = Depends(padmin_gate)):
614
+ """AWS automation usage, verbatim, or an honest note explaining its absence."""
615
  days = max(1, min(int(days or 7), 90))
616
  return {"days": days, "report": _aws_report(days)}
617
 
api/routes_tables.py CHANGED
@@ -39,7 +39,7 @@ import threading
39
  import json
40
  import time
41
 
42
- from fastapi import Body, Depends
43
  from fastapi import APIRouter
44
 
45
  from deps import Session, err, require_session
@@ -1073,9 +1073,10 @@ def nav_meta(session):
1073
  return {}
1074
 
1075
 
1076
- @router.get("/tables")
1077
- def list_tables(session: Session = Depends(require_session)):
1078
- """This session's user tables — the list the '+ New database' surface renders.
 
1079
 
1080
  ⭐⭐ W31-T12 (contract C1, D-175's third instance) — ONE DOCUMENT READ, NOT `1 + 2N`. This
1081
  route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:
@@ -1083,26 +1084,34 @@ def list_tables(session: Session = Depends(require_session)):
1083
  tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that — so a tenant
1084
  with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked
1085
  about every table; it is handed the document this function already holds. See
1086
- `user_tables.lend`'s own note for why inlining the predicate is the one fix that is not
1087
- available.
1088
- """
1089
- ut = _ut()
1090
- meta = nav_meta(session)
1091
- out = []
1092
- tables = ut.all_tables(st=session.runtime)
1093
- lent = ut.lend(session.runtime, **{ut.STORE_KEY: tables})
1094
- for key, t in sorted(tables.items(),
1095
- key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
1096
- if not ut.may_open(key, session.uname, session.admin, st=lent):
1097
- continue
1098
- out.append({"key": key, "label": ut_label(t, key, meta),
1099
- "source": t.get("source") or "Blank",
1100
- "recordsMutable": ut.records_mutable(key, st=lent),
1101
- "createdBy": t.get("createdBy") or "",
1102
- "created": t.get("created") or "",
1103
- "fields": [dict(f) for f in (t.get("fields") or [])],
1104
- "rowCount": len(t.get("rows") or {})})
1105
- return {"tables": out}
 
 
 
 
 
 
 
 
1106
 
1107
 
1108
  #: ⭐⭐ THE ROLLUP SOURCE OFFER — what makes the read-through rollup a FEATURE rather than a
 
39
  import json
40
  import time
41
 
42
+ from fastapi import Body, Depends, Query
43
  from fastapi import APIRouter
44
 
45
  from deps import Session, err, require_session
 
1073
  return {}
1074
 
1075
 
1076
+ @router.get("/tables")
1077
+ def list_tables(brief: bool = Query(False),
1078
+ session: Session = Depends(require_session)):
1079
+ """This session's user tables — the list the '+ New database' surface renders.
1080
 
1081
  ⭐⭐ W31-T12 (contract C1, D-175's third instance) — ONE DOCUMENT READ, NOT `1 + 2N`. This
1082
  route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:
 
1084
  tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that — so a tenant
1085
  with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked
1086
  about every table; it is handed the document this function already holds. See
1087
+ `user_tables.lend`'s own note for why inlining the predicate is the one fix that is not
1088
+ available.
1089
+
1090
+ `brief=1` is for a link-field picker. It needs the tables it may target and their schemas,
1091
+ not row counts or record mutability. Reading a full tenant document to decorate that one
1092
+ field editor would move every stored row through Neon for no visible result, so this branch
1093
+ keeps the server-side permission wall but lends its rows-free projection instead.
1094
+ """
1095
+ ut = _ut()
1096
+ meta = nav_meta(session)
1097
+ out = []
1098
+ tables = (ut.all_defs(st=session.runtime) if brief
1099
+ else ut.all_tables(st=session.runtime))
1100
+ lent = ut.lend(session.runtime, **{ut.STORE_KEY: tables})
1101
+ for key, t in sorted(tables.items(),
1102
+ key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
1103
+ if not ut.may_open(key, session.uname, session.admin, st=lent):
1104
+ continue
1105
+ row = {"key": key, "label": ut_label(t, key, meta),
1106
+ "fields": [dict(f) for f in (t.get("fields") or [])]}
1107
+ if not brief:
1108
+ row.update({"source": t.get("source") or "Blank",
1109
+ "recordsMutable": ut.records_mutable(key, st=lent),
1110
+ "createdBy": t.get("createdBy") or "",
1111
+ "created": t.get("created") or "",
1112
+ "rowCount": len(t.get("rows") or {})})
1113
+ out.append(row)
1114
+ return {"tables": out}
1115
 
1116
 
1117
  #: ⭐⭐ THE ROLLUP SOURCE OFFER — what makes the read-through rollup a FEATURE rather than a
api/routes_templates.py CHANGED
@@ -49,10 +49,13 @@ def _target_or_refuse(session, table_key):
49
  raise err(400, "bad_request", "no database was named")
50
  if key.startswith('ut_'):
51
  import core.user_tables as user_tables
52
- defn = user_tables.get(key, st=session.runtime)
 
 
 
53
  if not defn:
54
  raise err(404, "unknown_table", "that database does not exist")
55
- if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
56
  raise err(403, "forbidden", "that database belongs to another user")
57
  fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
58
  # A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
 
49
  raise err(400, "bad_request", "no database was named")
50
  if key.startswith('ut_'):
51
  import core.user_tables as user_tables
52
+ # A template negotiates a field contract, never record values. Keep the table wall on
53
+ # the same rows-free projection as navigation rather than copying the tenant document.
54
+ definitions = user_tables.lend_defs(session.runtime)
55
+ defn = user_tables.get(key, st=definitions)
56
  if not defn:
57
  raise err(404, "unknown_table", "that database does not exist")
58
+ if not user_tables.may_open(key, session.uname, session.admin, st=definitions):
59
  raise err(403, "forbidden", "that database belongs to another user")
60
  fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
61
  # A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
platform/aios_grid.py CHANGED
@@ -1918,11 +1918,14 @@ MAX_FOLDER_NAME = 80
1918
  #: 12-shape guess of mine that contained shapes the client cannot draw). Do not extend this set
1919
  #: without the matching client geometry: an unknown shape falls back to the default folder mark,
1920
  #: which is also I14's "existing folders get the folder icon" for every pre-wave-9 folder.
1921
- FOLDER_ICON_SHAPES = {"folder", "star", "flag", "tag", "bookmark", "box", "circle", "square"}
1922
- #: Tones are the C1 pastels FILLS ONLY, never text (the standing palette rule). 'grey' is the
1923
- #: default, and is CLIENT's key name: not 'neutral', which is what HOST first guessed.
1924
- FOLDER_ICON_TONES = {"blue", "green", "yellow", "red", "grey"}
1925
- FOLDER_ICON_DEFAULT_TONE = "grey"
 
 
 
1926
 
1927
 
1928
  #: Wave-9 I17 (contract C4) — who may EDIT a saved view.
@@ -1982,9 +1985,14 @@ def clean_folder_icon(raw):
1982
  shape = raw.get("shape")
1983
  if shape not in FOLDER_ICON_SHAPES:
1984
  return None
1985
- tone = raw.get("tone")
1986
- return {"shape": shape,
1987
- "tone": tone if tone in FOLDER_ICON_TONES else FOLDER_ICON_DEFAULT_TONE}
 
 
 
 
 
1988
 
1989
 
1990
  def clean_folders(raw):
 
1918
  #: 12-shape guess of mine that contained shapes the client cannot draw). Do not extend this set
1919
  #: without the matching client geometry: an unknown shape falls back to the default folder mark,
1920
  #: which is also I14's "existing folders get the folder icon" for every pre-wave-9 folder.
1921
+ FOLDER_ICON_SHAPES = {
1922
+ "folder", "star", "flag", "tag", "bookmark", "grid", "chart", "map", "users",
1923
+ "clock", "heart", "bolt",
1924
+ }
1925
+ #: Tones are the C1 pastels — FILLS ONLY, never text. This mirrors the current client vocabulary;
1926
+ #: the old host spelling `grey` is accepted as a read/write migration alias for `neutral` below.
1927
+ FOLDER_ICON_TONES = {"neutral", "blue", "green", "yellow", "red"}
1928
+ FOLDER_ICON_DEFAULT_TONE = "neutral"
1929
 
1930
 
1931
  #: Wave-9 I17 (contract C4) — who may EDIT a saved view.
 
1985
  shape = raw.get("shape")
1986
  if shape not in FOLDER_ICON_SHAPES:
1987
  return None
1988
+ tone = raw.get("tone")
1989
+ # The first host implementation called the neutral tone `grey`. Accept that legacy value so
1990
+ # an existing folder cannot crash a newer client, but canonicalise it on the next workspace
1991
+ # write/read to the current shared vocabulary.
1992
+ if tone == "grey":
1993
+ tone = FOLDER_ICON_DEFAULT_TONE
1994
+ return {"shape": shape,
1995
+ "tone": tone if tone in FOLDER_ICON_TONES else FOLDER_ICON_DEFAULT_TONE}
1996
 
1997
 
1998
  def clean_folders(raw):
platform/core/store_pg.py CHANGED
@@ -230,7 +230,12 @@ def _pool():
230
  pass
231
  # min_size=0 so a process that never touches the store opens no connection at all — the
232
  # shared API serves plenty of requests (health, static, a cached payload) that never read.
233
- _POOL['pool'] = ConnectionPool(url, min_size=0, max_size=8, open=True, timeout=10.0)
 
 
 
 
 
234
  _POOL['url'] = url
235
  return _POOL['pool']
236
 
 
230
  pass
231
  # min_size=0 so a process that never touches the store opens no connection at all — the
232
  # shared API serves plenty of requests (health, static, a cached payload) that never read.
233
+ # Returned connections are deliberately short-lived: Neon's compute should suspend between
234
+ # real requests, not be held awake by this process's idle pool for psycopg's 10-minute
235
+ # default. Thirty seconds still reuses a normal request burst without creating a resident
236
+ # database client.
237
+ _POOL['pool'] = ConnectionPool(url, min_size=0, max_size=8, open=True, timeout=10.0,
238
+ max_idle=30.0)
239
  _POOL['url'] = url
240
  return _POOL['pool']
241
 
web/src/customer-grid/apiBridge.ts CHANGED
@@ -52,9 +52,9 @@ import {
52
  checkTenant,
53
  signal,
54
  } from "../apiContract";
55
- import { setStandaloneSink } from "./hostBridge";
56
- import { changedBuckets } from "./liveWorkspace";
57
- import type { ChangeTokens } from "./liveWorkspace";
58
  import { CUSTOMER_TOPIC } from "./types";
59
  import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row,
60
  SortSpec, TopicConfig } from "./types";
@@ -439,7 +439,9 @@ export function readThroughTables(): Promise<Set<string>> {
439
  if (!readThroughMemo) {
440
  readThroughMemo = (async () => {
441
  try {
442
- const res = await fetch(`${API_V1}/odoo-tables/status`, { credentials: CREDENTIALS });
 
 
443
  if (handledSession(res) || !res.ok) {
444
  readThroughMemo = null;
445
  return new Set<string>();
@@ -594,8 +596,10 @@ export async function fetchWorkspace(
594
  export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | null> {
595
  let res: Response;
596
  try {
597
- res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`,
598
- { credentials: CREDENTIALS });
 
 
599
  } catch {
600
  return null;
601
  }
@@ -610,89 +614,83 @@ export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | n
610
  return out;
611
  }
612
 
613
- /** ~10 s. The owner's ask is "a new record shows up in about ten seconds", and the request it
614
- * costs is a dict read server-side see `routes_changes.py` for why that is the whole design. */
615
- export const CHANGE_POLL_MS = 10_000;
616
-
617
- interface ChangeWatch {
618
- timer: ReturnType<typeof setInterval> | null;
619
- tokens: ChangeTokens | null;
620
- listeners: Set<(changed: string[]) => void>;
621
- inFlight: boolean;
 
622
  /** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler
623
  * call the SAME function. The visibility handler used to re-inline the body, which quietly
624
  * skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second
625
  * request on top of it, exactly what that guard exists to prevent. */
626
- poll: () => Promise<void>;
627
  }
628
 
629
- const changeWatches = new Map<string, ChangeWatch>();
630
- let visibilityBound = false;
 
 
 
 
 
631
 
632
  /**
633
  * Watch one scope for server-side changes. Returns its unsubscribe.
634
  *
635
- * ⭐ ONE TIMER PER SCOPE, REFCOUNTED, and that is a budget rather than tidiness. A linked-record
636
- * grid mounts a second `useCustomerData` on the same surface, and a per-hook interval would double
637
- * the poll rate for a table the user opened once. The done-when is "roughly 6 tiny calls per minute
638
- * per tab" per TAB, not per component.
639
- *
640
- * IT DOES NOT POLL A HIDDEN TAB, and it polls IMMEDIATELY on becoming visible again. A background
641
- * tab left open overnight would otherwise spend the night asking a question nobody can see the
642
- * answer to, and the one moment its answer certainly matters is the moment you look at it.
643
- * `visibilitychange` had zero listeners in this client before this.
644
  */
645
  export function subscribeChanges(scope: string,
646
  onChange: (changed: string[]) => void): () => void {
647
  if (typeof window === "undefined") return () => {};
648
  let watch = changeWatches.get(scope);
649
  if (!watch) {
650
- const w0: ChangeWatch = {
651
- timer: null, tokens: null, listeners: new Set(), inFlight: false,
652
- poll: async () => {
653
- // A slow answer must not queue a second request behind itself: on a cold Space the first
654
- // poll can outlive the interval, and without this the backlog grows while it stays slow.
655
- if (w0.inFlight || document.visibilityState !== "visible") return;
656
- w0.inFlight = true;
657
- try {
658
- const next = await fetchChangeTokens(scope);
659
- const changed = changedBuckets(w0.tokens, next);
660
- if (next) w0.tokens = next;
661
- if (changed.length) for (const fn of [...w0.listeners]) fn(changed);
662
- } finally {
663
- w0.inFlight = false;
664
- }
665
- },
666
- };
667
  watch = w0;
668
  changeWatches.set(scope, w0);
669
  }
670
- const w = watch;
671
- w.listeners.add(onChange);
672
-
673
- if (!w.timer) {
674
- void w.poll(); // take the baseline now, not in ten seconds
675
- w.timer = setInterval(() => { void w.poll(); }, CHANGE_POLL_MS);
676
- }
677
- if (!visibilityBound) {
678
- visibilityBound = true;
679
- window.addEventListener("visibilitychange", () => {
680
- if (document.visibilityState !== "visible") return;
681
- for (const watched of changeWatches.values())
682
- if (watched.timer) void watched.poll();
683
- });
684
- }
685
 
686
  return () => {
687
  w.listeners.delete(onChange);
688
- if (w.listeners.size === 0 && w.timer) {
689
- clearInterval(w.timer);
690
- w.timer = null;
691
  // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than
692
- // compare against a token from before it was unmounted: the payload it is about to fetch is
693
- // fresh by definition, so treating the interval it missed as "a change" would cost a second
694
- // full read of what it just read.
695
- w.tokens = null;
696
  }
697
  };
698
  }
@@ -872,9 +870,11 @@ export async function addTableRow(
872
  * and an unreachable server — and a create pane that explodes because a picker could not fetch
873
  * is worse than one that says it has nothing to offer.
874
  */
875
- export async function fetchLinkTargets(): Promise<LinkTarget[]> {
876
- try {
877
- const res = await fetch(`${API_V1}/tables`, { credentials: CREDENTIALS });
 
 
878
  if (handledSession(res) || !res.ok) return [];
879
  const body = (await readJson(res)) as { tables?: LinkTarget[] } | null;
880
  return Array.isArray(body?.tables) ? body.tables : [];
 
52
  checkTenant,
53
  signal,
54
  } from "../apiContract";
55
+ import { setStandaloneSink } from "./hostBridge";
56
+ import { changedBuckets } from "./liveWorkspace";
57
+ import type { ChangeTokens } from "./liveWorkspace";
58
  import { CUSTOMER_TOPIC } from "./types";
59
  import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row,
60
  SortSpec, TopicConfig } from "./types";
 
439
  if (!readThroughMemo) {
440
  readThroughMemo = (async () => {
441
  try {
442
+ // The grid needs only the read-through flag, never the operator status's counts/stamps.
443
+ // `brief=1` is server-derived from the connected-grid registry and reads no table rows.
444
+ const res = await fetch(`${API_V1}/odoo-tables/status?brief=1`, { credentials: CREDENTIALS });
445
  if (handledSession(res) || !res.ok) {
446
  readThroughMemo = null;
447
  return new Set<string>();
 
596
  export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | null> {
597
  let res: Response;
598
  try {
599
+ res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`,
600
+ // This header marks a current, user-return check. Legacy bundles lack it,
601
+ // so their former 10-second requests remain database-free on Pg.
602
+ { credentials: CREDENTIALS, headers: { "X-AIOS-Change-Check": "focus-v1" } });
603
  } catch {
604
  return null;
605
  }
 
614
  return out;
615
  }
616
 
617
+ /**
618
+ * A grid reloads only when the person returns to the tab or window. There is no timer and no
619
+ * background token request.
620
+ */
621
+
622
+ interface ChangeWatch {
623
+ listeners: Set<(changed: string[]) => void>;
624
+ tokens: ChangeTokens | null;
625
+ inFlight: boolean;
626
+ refresh: () => Promise<void>;
627
  /** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler
628
  * call the SAME function. The visibility handler used to re-inline the body, which quietly
629
  * skipped `inFlight` — so returning to a tab whose poll was still outstanding stacked a second
630
  * request on top of it, exactly what that guard exists to prevent. */
 
631
  }
632
 
633
+ const changeWatches = new Map<string, ChangeWatch>();
634
+ let visibilityBound = false;
635
+ function refreshVisibleChanges(): void {
636
+ if (document.visibilityState !== "visible") return;
637
+ for (const watched of changeWatches.values())
638
+ void watched.refresh();
639
+ }
640
 
641
  /**
642
  * Watch one scope for server-side changes. Returns its unsubscribe.
643
  *
644
+ * ⭐ ONE WATCH PER SCOPE, REFCOUNTED. A linked-record grid can mount a second `useCustomerData` on
645
+ * the same surface, and both listeners share one mount baseline and one return-to-tab refresh.
646
+ *
647
+ * IT NEVER POLLS. Own writes update the current tab from their response; a change made elsewhere
648
+ * is checked when the person returns to this tab or focuses its window. A forgotten tab left open
649
+ * overnight creates no database traffic.
 
 
 
650
  */
651
  export function subscribeChanges(scope: string,
652
  onChange: (changed: string[]) => void): () => void {
653
  if (typeof window === "undefined") return () => {};
654
  let watch = changeWatches.get(scope);
655
  if (!watch) {
656
+ const w0: ChangeWatch = {
657
+ listeners: new Set(), tokens: null, inFlight: false,
658
+ refresh: async () => {
659
+ if (w0.inFlight || document.visibilityState !== "visible") return;
660
+ w0.inFlight = true;
661
+ try {
662
+ const next = await fetchChangeTokens(scope);
663
+ const changed = changedBuckets(w0.tokens, next);
664
+ if (next) w0.tokens = next;
665
+ if (changed.length)
666
+ for (const listener of [...w0.listeners]) listener(changed);
667
+ } finally {
668
+ w0.inFlight = false;
669
+ }
670
+ },
671
+ };
 
672
  watch = w0;
673
  changeWatches.set(scope, w0);
674
  }
675
+ const w = watch;
676
+ w.listeners.add(onChange);
677
+
678
+ // One tiny revision baseline after the necessary initial data load lets later user returns
679
+ // preserve the browser cache when nothing changed. It is never an interval.
680
+ if (w.tokens === null && !w.inFlight) void w.refresh();
681
+
682
+ if (!visibilityBound) {
683
+ visibilityBound = true;
684
+ window.addEventListener("visibilitychange", refreshVisibleChanges);
685
+ window.addEventListener("focus", refreshVisibleChanges);
686
+ }
 
 
 
687
 
688
  return () => {
689
  w.listeners.delete(onChange);
690
+ if (w.listeners.size === 0) {
 
 
691
  // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than
692
+ // compare against a token from before it was unmounted.
693
+ changeWatches.delete(scope);
 
 
694
  }
695
  };
696
  }
 
870
  * and an unreachable server — and a create pane that explodes because a picker could not fetch
871
  * is worse than one that says it has nothing to offer.
872
  */
873
+ export async function fetchLinkTargets(): Promise<LinkTarget[]> {
874
+ try {
875
+ // A link picker uses only key/label/schema. `brief=1` keeps the server's table wall but does
876
+ // not transfer row counts or the row-bearing `user_tables` document for this one field menu.
877
+ const res = await fetch(`${API_V1}/tables?brief=1`, { credentials: CREDENTIALS });
878
  if (handledSession(res) || !res.ok) return [];
879
  const body = (await readJson(res)) as { tables?: LinkTarget[] } | null;
880
  return Array.isArray(body?.tables) ? body.tables : [];
web/src/customer-grid/iconShapes.ts CHANGED
@@ -350,13 +350,24 @@ export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
350
  * the two ends, so the name matters more than the word: a tone the host does not recognise
351
  * degrades to the default and the user's choice silently disappears on reload.
352
  */
353
- export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
- neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
- blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
356
- green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
357
- yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
358
- red: { fill: LP_RED, stroke: LP_RED_DEEP },
359
- };
 
 
 
 
 
 
 
 
 
 
 
360
 
361
  export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
362
  neutral: "Neutral",
 
350
  * the two ends, so the name matters more than the word: a tone the host does not recognise
351
  * degrades to the default and the user's choice silently disappears on reload.
352
  */
353
+ export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
+ neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
+ blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
356
+ green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
357
+ yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
358
+ red: { fill: LP_RED, stroke: LP_RED_DEEP },
359
+ };
360
+
361
+ /**
362
+ * The tone is persisted data, not a compile-time fact. Older hosts used `grey` for the neutral
363
+ * tone, and a stale workspace can still carry it after the palette was renamed. Every painter
364
+ * must degrade to the neutral ink rather than dereferencing an absent paint record and taking the
365
+ * whole React tree down while drawing an otherwise healthy database.
366
+ */
367
+ export function folderTonePaint(tone: unknown): { fill: string; stroke: string } {
368
+ return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
369
+ ?? FOLDER_TONE_PAINT.neutral;
370
+ }
371
 
372
  export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
373
  neutral: "Neutral",
web/src/customer-grid/icons.tsx CHANGED
@@ -22,7 +22,7 @@ import {
22
  CHART_KIND_SHAPES,
23
  CHART_KIND_TONE,
24
  FOLDER_SHAPE_PATHS,
25
- FOLDER_TONE_PAINT,
26
  MODE_SHAPES,
27
  TYPE_SHAPES,
28
  } from "./iconShapes";
@@ -130,7 +130,7 @@ export function ToneModeIcon({
130
  tone: FolderTone;
131
  size?: number;
132
  }) {
133
- const paint = FOLDER_TONE_PAINT[tone];
134
  return (
135
  <svg
136
  className="cg-mode-icon cg-tone-icon"
@@ -178,7 +178,7 @@ export function ChartKindIcon({
178
  kind: ChartKindKey;
179
  size?: number;
180
  }) {
181
- const paint = FOLDER_TONE_PAINT[CHART_KIND_TONE[kind]];
182
  return (
183
  <svg
184
  className="cg-mode-icon cg-tone-icon"
@@ -483,7 +483,7 @@ export function FolderMark({
483
  size?: number;
484
  }) {
485
  const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE;
486
- const paint = FOLDER_TONE_PAINT[icon?.tone ?? DEFAULT_FOLDER_TONE];
487
  return (
488
  <svg
489
  className="cg-folder-mark"
 
22
  CHART_KIND_SHAPES,
23
  CHART_KIND_TONE,
24
  FOLDER_SHAPE_PATHS,
25
+ folderTonePaint,
26
  MODE_SHAPES,
27
  TYPE_SHAPES,
28
  } from "./iconShapes";
 
130
  tone: FolderTone;
131
  size?: number;
132
  }) {
133
+ const paint = folderTonePaint(tone);
134
  return (
135
  <svg
136
  className="cg-mode-icon cg-tone-icon"
 
178
  kind: ChartKindKey;
179
  size?: number;
180
  }) {
181
+ const paint = folderTonePaint(CHART_KIND_TONE[kind]);
182
  return (
183
  <svg
184
  className="cg-mode-icon cg-tone-icon"
 
483
  size?: number;
484
  }) {
485
  const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE;
486
+ const paint = folderTonePaint(icon?.tone ?? DEFAULT_FOLDER_TONE);
487
  return (
488
  <svg
489
  className="cg-folder-mark"
web/src/settings/AdminPane.tsx CHANGED
@@ -200,10 +200,10 @@ export function AdminPane({ user }: { user: { username: string; name: string; ro
200
  <Total label="People" value={int(ov.totals.users)} />
201
  <Total label="Databases" value={int(ov.totals.databases)} />
202
  <Total label="Records" value={int(ov.totals.rows)} />
203
- <Total
204
- label="Automation cost"
205
- value={fleet ? `$${fleet.cost.usd.toFixed(2)}` : "—"}
206
- hint={fleet?.cost.basis}
207
  />
208
  </div>
209
  {ov.totals.unknownTenants > 0 ? (
@@ -329,8 +329,9 @@ export function AdminPane({ user }: { user: { username: string; name: string; ro
329
  table that is deliberately not here. */}
330
  <h4 className="padmin-h4">Automation cost</h4>
331
  <p className="set-help">
332
- {fleet.rows.filter((a) => a.enabled).length} scheduled of {fleet.rows.length}
333
- {fleet.cost.fleetRunsPerDay
 
334
  ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
335
  : ""}
336
  {fleet.cost.unknownCadence
 
200
  <Total label="People" value={int(ov.totals.users)} />
201
  <Total label="Databases" value={int(ov.totals.databases)} />
202
  <Total label="Records" value={int(ov.totals.rows)} />
203
+ <Total
204
+ label="Automation floor"
205
+ value={fleet ? (fleet.cost.usd === 0 ? "$0.00 idle" : `~$${fleet.cost.usd.toFixed(2)}`) : "—"}
206
+ hint={fleet?.cost.basis}
207
  />
208
  </div>
209
  {ov.totals.unknownTenants > 0 ? (
 
329
  table that is deliberately not here. */}
330
  <h4 className="padmin-h4">Automation cost</h4>
331
  <p className="set-help">
332
+ {fleet.rows.filter((a) => a.enabled).length} recurring of {fleet.rows.length}
333
+ {`, ${fleet.cost.nativeSchedules} exact schedule${fleet.cost.nativeSchedules === 1 ? "" : "s"}`}
334
+ {fleet.cost.fleetRunsPerDay
335
  ? `, about ${fleet.cost.fleetRunsPerDay} runs a day`
336
  : ""}
337
  {fleet.cost.unknownCadence
web/src/settings/platformAdminApi.ts CHANGED
@@ -136,6 +136,8 @@ export interface AutomationRow {
136
  cron: string;
137
  /** null = a cadence the server would not parse; shown as "custom", never as a number. */
138
  runsPerDay: number | null;
 
 
139
  state: string;
140
  lastRunAt: string;
141
  lastSummary: string;
@@ -146,11 +148,11 @@ export interface AutomationRow {
146
  }
147
 
148
  export interface FleetCost {
 
149
  cadence: string;
 
150
  invocationsPerMonth: number;
151
- freeRequestsPct: number;
152
- freeSchedulerPct: number;
153
- lambdaMb: number;
154
  usd: number;
155
  fleetRunsPerDay: number;
156
  unknownCadence: number;
@@ -214,6 +216,8 @@ export function getAutomations(
214
  cost: FleetCost;
215
  errors: Record<string, string>;
216
  tickEnabled: boolean;
 
 
217
  }>
218
  > {
219
  return call(`/automations${q(tenant)}`);
 
136
  cron: string;
137
  /** null = a cadence the server would not parse; shown as "custom", never as a number. */
138
  runsPerDay: number | null;
139
+ nativeSchedules: number | null;
140
+ scheduleModes: string[];
141
  state: string;
142
  lastRunAt: string;
143
  lastSummary: string;
 
148
  }
149
 
150
  export interface FleetCost {
151
+ mode: "exact-schedules-fargate";
152
  cadence: string;
153
+ nativeSchedules: number;
154
  invocationsPerMonth: number;
155
+ perRunMinimumUsd: number;
 
 
156
  usd: number;
157
  fleetRunsPerDay: number;
158
  unknownCadence: number;
 
216
  cost: FleetCost;
217
  errors: Record<string, string>;
218
  tickEnabled: boolean;
219
+ controlEnabled: boolean;
220
+ executionMode: "exact-aws" | "local-disabled";
221
  }>
222
  > {
223
  return call(`/automations${q(tenant)}`);