File size: 57,139 Bytes
609fb78 f2f7ccf 609fb78 f2f7ccf 609fb78 f2f7ccf 609fb78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 | """AIOS web API β the ONE shared, stateless process the Streamlit exit is aimed at.
It REUSES the `platform/` data layer verbatim (nothing re-implemented): the canonical,
reconciled Odoo model stays server-side and the browser only ever sees derived JSON. Serves the
JSON API under `/api/*` and the built React bundle (`aios-web/web/dist`) at `/`.
WHAT CHANGED IN EXIT WAVE 1 (2026-07-30) β three things, all of them load-bearing:
* **Real sessions replace HTTP Basic.** The whole app used to sit behind one shared
`APP_PASSWORD` with the browser's native Basic prompt, which means every caller was the same
anonymous principal and no route could scope anything. Now a signed stateless cookie carries
a real `core/users` identity (X3), and every v1 route resolves BU + own-book scope from the
user RECORD. `/api/health` stays unauthenticated (liveness only).
* **The overlay fork is deleted.** `aios-web/api/data/overlay.json` was a second writable home
for user-owned fields the Streamlit app keeps in the tenant store. ONE store now (C1c).
* **The SSL shim is env-gated.** It used to run at import, unconditionally.
β TENANT RESOLUTION IS PER REQUEST (X7). Nothing tenant-shaped is held at module level; the
session's tenant claim resolves through `harness.runtime.get_runtime`, which is LRU-bounded. This
is the rule the ~30β80 MB/tenant target depends on β EXIT-0 measured the alternative at a ~0.6 GB
commit FLOOR per tenant, duplicated within 2% per tenant, nothing shared.
"""
import os
import sys
from pathlib import Path
# --- the local Odoo SSL quirk, now BEHIND A GATE -------------------------------------------------
# The Windows trust store reports the (valid) Odoo cert as expired, so local runs need an
# unverified default context; the HF Space and the container are unaffected. This used to run
# unconditionally at import β i.e. the shipped container disabled TLS verification for every
# outbound HTTPS call it ever made, to Odoo and to everything else, forever. That is a
# man-in-the-middle away from being someone else's data. It is now opt-in, must be set
# deliberately, and is never on by default.
# β NEVER set AIOS_INSECURE_SSL in a deployed environment. It exists for one developer laptop.
if os.environ.get("AIOS_INSECURE_SSL") == "1":
import ssl
ssl._create_default_https_context = ssl._create_unverified_context # noqa: S323
# --- reuse the tenant #0 data layer verbatim. RI_DIR overrides the location in the container
# (where the layout differs from the local sibling-dir default). ---
_HERE = Path(__file__).resolve()
_RI = Path(os.environ.get("RI_DIR") or (_HERE.parents[2] / "platform"))
for _p in (str(_RI), str(_HERE.parent)):
if _p not in sys.path:
sys.path.insert(0, _p)
from dotenv import load_dotenv # noqa: E402
load_dotenv(_RI / ".env") # Odoo creds + APP_PASSWORD, git-ignored, never committed/printed
from fastapi import Body, Depends, FastAPI, Request # noqa: E402
from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
from starlette.exceptions import HTTPException as StarletteHTTPException # noqa: E402
import aios_session # noqa: E402
import routes_admin # noqa: E402
import routes_alerts # noqa: E402 (wave 20 item 25 β the Alerts inbox)
import routes_assets # noqa: E402 (wave 18 C2-ASSET β catalog product imagery)
import routes_auth # noqa: E402
import routes_automation # noqa: E402 (wave 18 C4-AUTO β SESSION D's router, mounted by A)
import routes_changes # noqa: E402 (wave 29 item 20 / C6 β F's change token, mounted by A)
import routes_customers # noqa: E402
import routes_grid # noqa: E402
import routes_keychain # noqa: E402 (wave 18 C7 β keychain + connectors admin surfaces)
import routes_statements # noqa: E402 (EXIT-6 β the statement sender, off Streamlit)
import routes_nav # noqa: E402
import routes_pages # noqa: E402
import routes_platform_admin # noqa: E402 (wave 19 R3/R4 β the Loopable cross-tenant plane)
import routes_products # noqa: E402 (wave 15 C-TOPIC β gated, not yet in the nav)
import routes_records # noqa: E402
import routes_shares # noqa: E402 (wave 20 R10 β grants for views, folders and databases)
import routes_uploads # noqa: E402 (wave 21 C5 β tabular preview for Select-from-file)
import routes_tables # noqa: E402 (wave 18 C3-UT β user-created databases over the wire)
import routes_connectors # noqa: E402 (wave 23 C11 β the connectors directory; SESSION A's router)
import routes_forms # noqa: E402 (wave 23 C9 β the PUBLIC form door; SESSION D's router)
import routes_templates # noqa: E402 (wave 23 C12 β template apply doors; SESSION E's router)
import routes_odoo_tables # noqa: E402 (wave 27 item 17 β Odoo relational; SESSION E's router)
import routes_connected_tables # noqa: E402 (wave 31 T49/C4 β the source-neutral grid door; D's)
import routes_web_agent # noqa: E402 (wave 31 R10/C5 β the web-browsing agent; E's router, A's line)
import routes_query # noqa: E402 (wave 32 R1/C5 β the Query module; E's router, A's line)
from core import grid_events # noqa: E402
from deps import Session, module_gate # noqa: E402
_WEB_DIST = _HERE.parents[1] / "web" / "dist"
# --- THE CANONICAL FIELD CONTRACT: a startup assertion, and the referee gate's subject ----------
# Every field tags its semantic TYPE and its SOURCE. `source='odoo'` is READ-ONLY (Odoo is never
# written); `source='overlay'` is the editable stratum that lives OUTSIDE Odoo. Loaded from the
# ONE canonical file shared with the embedded host (`platform/aios_grid_fields.json`), so
# embed == standalone by construction.
#
# TWO REASONS THIS IS HERE and not folded into the routes:
# 1. It FAILS LOUDLY at import when the file is missing β which means the deploy or the RI_DIR
# layout is broken, and discovering that from a 500 on the first customer request instead of
# at startup costs a debugging session. (The pre-wave main.py had this guard; the EXIT-2a
# rewrite dropped it and this restores it.)
# 2. `aios-web/verify_fields_contract.py` β the cross-side REFEREE β reads `FIELDS` and
# `_PASSTHROUGH_KEYS` from this module to prove the canonical file, `aios_grid.py` and this
# API have not drifted. The rewrite removed them and the referee went red; retargeting the
# gate would have been the wrong repair ([[gate-can-report-green-on-nothing]]: retarget, do
# not delete β but only when the subject genuinely moved. Here it should not have moved).
#
# β THE ROUTES SERVE A SUPERSET OF THIS. `routes_customers._payload` derives its field list from
# `aios_grid.fields_from_workspace(ws)`, which is `FIELDS` PLUS the session user's own custom_ and
# measure_ columns β the same list the Streamlit host renders. That is the point: the standalone
# shell now sees the user's own columns instead of the bare base contract. `FIELDS` is the
# canonical FLOOR, asserted below to be exactly what `aios_grid` starts from.
import json as _json_contract # noqa: E402
_FIELDS_PATH = _RI / "aios_grid_fields.json"
if not _FIELDS_PATH.is_file():
raise FileNotFoundError(
f"AIOS web API: canonical field contract missing at {_FIELDS_PATH}. Set RI_DIR to the "
"platform root (it also carries the data layer this API imports)."
)
_contract = _json_contract.loads(_FIELDS_PATH.read_text(encoding="utf-8"))
FIELDS = _contract["fields"] if isinstance(_contract, dict) else _contract
# text/status/select/date pass through untouched; every OTHER odoo field is numeric -> rounded.
# Derived from field TYPE (not a hand-kept key list) so a new text/date field can never be
# wrongly rounded. `select` joined 2026-08-02 (dba) β a choice label rounded would be garbage.
_PASSTHROUGH_KEYS = {f["key"] for f in FIELDS
if f["source"] == "odoo" and f["type"] in ("text", "status", "select",
"date")}
app = FastAPI(title="AIOS web API")
# The customers payload measured 1.15 MB of JSON on the live Space, shipped UNCOMPRESSED β with
# the 754 KB bundle behind it, most of "the app is slow" was bytes on the wire. gzip takes the
# payload to ~10β15% of that. minimum_size spares the tiny acks the overhead.
app.add_middleware(GZipMiddleware, minimum_size=1024)
@app.exception_handler(StarletteHTTPException)
def _error_shape(request: Request, exc: StarletteHTTPException):
"""ONE error shape for every non-2xx (X2): `{"error": {"code", "message"}}`.
`deps.err` already raises detail in that shape; anything FastAPI raises on its own (a 404, a
422 from a malformed path param) is wrapped here so a client never has to branch on two
different error bodies.
"""
detail = exc.detail
if isinstance(detail, dict) and "error" in detail:
body = detail
else:
body = {"error": {"code": f"http_{exc.status_code}", "message": str(detail)}}
return JSONResponse(body, status_code=exc.status_code,
headers=getattr(exc, "headers", None))
@app.exception_handler(grid_events.StoreUnavailable)
def _store_unavailable(request: Request, exc: grid_events.StoreUnavailable):
"""THE SAFETY NET for "the store is down" β 503, from anywhere.
β WHY THIS IS APP-LEVEL AND NOT A `try` PER ROUTE. It was a try per route first, and a
`StoreUnavailable` raised while BUILDING THE PAYLOAD β before the route reached its own
try/except β surfaced as a 500. A store outage is a normal operational state and every route
here touches the store at least twice (the workspace read, then the write), so "remember to
wrap it" is a rule that gets forgotten once and then reports the wrong thing. One handler
means a store outage can only ever be a 503, whichever call raised it.
The seam raises this only when the caller passed no `fallback_ws` β i.e. exactly on this
adapter, which has no durable session dict to degrade into. A 200 over a write that
evaporated is the failure the whole rule exists to prevent.
"""
return JSONResponse(
{"error": {"code": "store_unavailable",
"message": "the tenant store is unavailable β no change was saved"}},
status_code=503)
@app.get("/api/health")
def health():
"""Unauthenticated LIVENESS only β it must answer before anyone can sign in, so it may not
reveal anything about the deployment beyond "the process is up". No version, no tenant list,
no config: a health endpoint is the one URL every scanner finds first.
β THE VERSION DOES NOT GO HERE, and it was asked to (2026-08-04, when LIVE became a pinned
release and "what is LIVE running" needed an answer). A build identifier tells an unauthenticated
caller exactly which commit's known issues apply. It rides `GET /api/v1/settings` instead, behind
a session β and the authoritative copy is the `VERSION` file in the Space repo, which
`deploy_web.space_version()` reads without needing the app to be up at all."""
return {"ok": True}
app.include_router(routes_auth.router)
app.include_router(routes_nav.router)
app.include_router(routes_customers.router)
app.include_router(routes_products.router)
app.include_router(routes_assets.router)
app.include_router(routes_tables.router)
app.include_router(routes_grid.router)
app.include_router(routes_records.router)
# EXIT wave 2: the Y1 page-data envelope (one route for every ported dashboard) and Y4's user
# administration. `routes_pages` imports `pages`, which lazily imports each `pages_*` builder β so
# a new page is a builder module plus one registry line, and nothing here changes.
app.include_router(routes_pages.router)
app.include_router(routes_admin.router)
app.include_router(routes_automation.router)
app.include_router(routes_keychain.router)
app.include_router(routes_statements.router)
# Wave 19 (owner item 13, R3): the LOOPABLE admin plane β the platform's own cross-tenant view.
# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
# platform-operator route ever shadowing each other. Every path it declares is gated by
# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
app.include_router(routes_platform_admin.router)
# Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are
# session-gated rather than admin-gated β an alert is a person's own subscription, and sharing is
# something every user does with their own views/folders/databases.
app.include_router(routes_alerts.router)
app.include_router(routes_shares.router)
app.include_router(routes_uploads.router)
# β WAVE 23 β THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this
# file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by:
# a router included AFTER it answers 404 forever while importing fine, type-checking fine and
# passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause.
#
# β ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit β three finished
# features that would have shipped dead. The workers each posted a mount ask and said they would
# signal "ready" first; C waited for a signal that never came while the files landed anyway. **The
# lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the
# control is that the enumeration must NAME every router in this file.**
app.include_router(routes_templates.router) # item 7 / C12 β template registry, session-gated
app.include_router(routes_connectors.router) # item 10 / C11 β the connectors directory
# β routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its
# two paths are DELIBERATELY unauthenticated β a form is filled in by someone with no account β so
# it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token
# by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot
# distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug.
app.include_router(routes_forms.router) # item 8 / C9 β the PUBLIC form door
# WAVE 27 item 17 β E's Odoo relational doors. Mounted in the SAME change that E's module landed,
# because the wave-23 scar is exactly this: three finished routers shipped with no include_router
# line β complete, gated, type-clean and 404 for every caller. `verify_api.section_mounts` walks
# `main.app.routes` and pins these two paths by NAME, so an unmounted router now goes RED.
app.include_router(routes_odoo_tables.router)
# ββ WAVE 31 Β· T49 / C4 β THE SOURCE-NEUTRAL DOOR TO THE SAME CAPABILITY.
# `/api/v1/connected-tables/{key}/rows` is an ALIAS: every request lands in
# `routes_odoo_tables.odoo_table_rows`, so "the Odoo path behaves byte-identically" holds by
# CONSTRUCTION rather than by two implementations that agree on the day they were written. R2 puts
# Meta Ads on the same mirror, and a Meta campaign served from a URL with `odoo` in it is a name
# that lies to every network tab, log line and bug report.
# β MOUNTED IN THE SAME CHANGE AS THE MODULE, per the line above and for the same wave-23 scar.
app.include_router(routes_connected_tables.router)
# ββ WAVE 29, item 20 / R11 / contract C6 β F's CHANGE TOKEN. `GET /api/v1/changes?scope=<scope>`
# answers "did this bucket change" for ~zero cost (an in-memory counter; ZERO `store.get()` deep
# copies), which is what lets a filtered view pick up a row created in another tab, by an automation
# or by a connector sync without re-downloading the world.
# β THIS LINE IS THE ARTIFACT THIS PROTOCOL LOSES MOST RELIABLY, AND IT WAS ALREADY LOST ONCE HERE:
# F's router and the CLIENT half both shipped complete, so the poller was calling `/api/v1/changes`
# six times a minute and taking a 404 while every one of F's own gates was green. `verify_api`'s D-48
# leg caught it (`unmatched: [('/api/v1/changes', 'apiBridge.ts')]`) β a client fetch path with no
# mounted route β which is precisely the control the wave-23 scar above was written to install.
# β Mounted is NOT callable: `section_changes_callable` in `verify_api.py` SIGNS IN and CALLS this
# route, because a route can be mounted and still raise before its own `try:` (D-107's plain-text 500).
app.include_router(routes_changes.router) # item 20 / C6 β F's router, A's line
# β WAVE 31 (R10 / C5) β E's router, taken by the INTEGRATOR under W31-T08's own done-when
# ("no router ships unmounted") because `main.py` is D's fence and D's queue did not reach it.
# It was written, gated and 404-dead: `verify_web_agent.py` asserts THIS LINE and was red at
# 60/61 for it. Four waves of the same defect β three routers in wave 23, four features in
# wave 29 β is why the assertion exists and why the line is not left for later.
app.include_router(routes_web_agent.router) # R10 / C5 β E's router, A's line
# ββ WAVE 32 (R1 / C5, cross-fence wiring 6) β THE QUERY MODULE. E's router, A's line, and the
# FIFTH consecutive wave in which this exact line is the artifact the protocol nearly loses.
# β MOUNTED HERE, IN THIS BLOCK, AND NOT AT THE END OF THE FILE β measured by SESSION E in its own
# gate before it front-inserted: `include_router` APPENDS, and `app.mount("/", _AppStatic(...),
# html=True)` swallows everything reached after it, so a router added below that mount answers
# **405 on POST and 404 on GET** while every one of its own tests passes. The comment at :213 states
# the rule; E's measurement is what turns it from advice into a number.
# β `verify_api` asserts `/api/v1/query` in `app.openapi()["paths"]` β NEVER `{r.path for r in
# app.routes}`, which finds nothing in this app because FastAPI wraps included routers (W31).
app.include_router(routes_query.router) # R1 / C5 β E's router, A's line
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
# They are the v1 handlers with the v1 session requirement β NOT the old unauthenticated Basic
# behavior. An alias that kept the old auth would be a bypass of everything above it.
_GATE = module_gate(routes_customers.MODULE)
@app.get("/api/customers", deprecated=True)
def _customers_alias(session: Session = Depends(_GATE)):
return routes_customers._payload(session)
@app.patch("/api/customers/{pid}", deprecated=True)
def _patch_alias(pid: int, body: dict = Body(default=None),
session: Session = Depends(_GATE)):
return routes_customers.patch_customer(pid, body, session)
def _assert_contract_floor():
"""The canonical FIELDS must be exactly what `aios_grid` starts an empty workspace from.
This is what stops `FIELDS` becoming a constant that exists only to satisfy a gate. If the
canonical JSON and `aios_grid.fields_from_workspace({})` ever disagree, the standalone API and
the embedded host are serving two different schemas and the grid's own contract has forked β
the exact drift `verify_fields_contract.py` was written to catch, now also caught at startup
on whatever machine is actually running.
"""
import aios_grid
base = [f for f in aios_grid.fields_from_workspace({}) if not f.get("custom")]
if [f["key"] for f in base] != [f["key"] for f in FIELDS]:
raise RuntimeError(
"AIOS web API: the canonical field contract and aios_grid.fields_from_workspace({}) "
"disagree on the base field set β embed and standalone would serve different schemas. "
"Run aios-web/verify_fields_contract.py.")
def _startup_notes():
"""Say the two things an operator must know, once, at import β never in a response body."""
if aios_session.EPHEMERAL_SECRET:
print("[aios-api] AIOS_SESSION_SECRET is not set β signing with a random per-process "
"key. Sessions will not survive a restart and will not work across workers. "
"Set it in production.")
if os.environ.get("AIOS_INSECURE_SSL") == "1":
print("[aios-api] AIOS_INSECURE_SSL=1 β TLS verification is DISABLED for outbound "
"requests. Local development only; never in a deployed environment.")
_assert_contract_floor()
_startup_notes()
# β THE STATIC MOUNT IS NOW UNAUTHENTICATED, and that is a change this wave made on purpose.
# Before EXIT-3a, `BasicAuth` middleware gated the WHOLE app including this mount. A branded login
# page cannot live behind a password prompt, so the shell's own assets must be public β which they
# are: `index.html`, the JS/CSS bundle and the favicon reveal nothing.
#
# β WHAT THAT SILENTLY DE-GATED, caught in review rather than in production. `web/dist/` also
# carries `sample_customers.json`, a DEV FIXTURE copied from `web/public/`. Its 8 customers are
# synthetic, but the `agent` column holds REAL EMPLOYEE NAMES, and it went from Basic-gated to
# publicly fetchable in this commit. Nothing needs it: both the API bridge and `useCustomerData`
# deleted their sample fallback on purpose ("NOTHING HERE FALLS BACK TO sample_customers.json"),
# and it survives only because it sits in `web/public/`. So it is refused here β 404, the same
# answer as any other path that is not part of the app.
#
# The right long-term fix is deleting it from `web/public/` (S2's lane β flagged in the mailbox);
# this guard is what makes the API safe regardless of what the bundle happens to contain.
_DEV_FIXTURES = {"sample_customers.json"}
class _AppStatic(StaticFiles):
async def get_response(self, path, scope):
if Path(path).name in _DEV_FIXTURES:
raise StarletteHTTPException(status_code=404, detail="Not Found")
resp = await super().get_response(path, scope)
# Vite content-hashes everything under assets/ (a change is a NEW url), so those are
# immutable β a repeat visit re-downloads zero bytes instead of the whole 750 KB bundle.
# index.html must stay revalidated or a deploy would strand returning browsers on the old
# bundle; ETag/304 makes that revalidation a header exchange, not a transfer.
# β Normalised first: on a Windows host StaticFiles hands this path with backslashes,
# and `startswith("assets/")` silently skipped every asset (measured on the local probe).
if path.replace("\\", "/").lstrip("/").startswith("assets/"):
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
resp.headers["Cache-Control"] = "no-cache"
return resp
# static bundle LAST so /api/* wins; html=True serves index.html at / plus the built assets
if _WEB_DIST.is_dir():
app.mount("/", _AppStatic(directory=str(_WEB_DIST), html=True), name="web")
# --- boot prewarm (AIOS_PREWARM=1 β set by the Dockerfile, never by tests) -----------------------
# Without this, the first visitor after every deploy/restart pays the full Odoo pool build in
# their request. The thread warms the CONSOLIDATED scope (None, None) + every registered page's
# default envelope; scoped users still pay their own scope's first build, once.
# Env-gated rather than a startup event so importing `api.main` in a gate (verify_api and friends
# run against fakes) can never fire a live Odoo pull.
def _seed_and_sync_store():
"""Bring the analytical store (harness.datastore) LIVE for this container β the app.py
bootstrap, mirrored (2026-07-31, owner item 1).
`harness.datastore` powers every measure column/condition, and ONLY app.py used to call
`ensure_seed()` β so on a fresh Space disk this container resolved measures against a store
that never existed and every measure cell served blank. Seeding alone was NOT enough either,
and that was measured live the same day: "datastore seeded in 1.7s" followed by an endless
`api:measure-column: ModelError the data cache is still warming up` β `ready()` demands
EVERY entity at phase 'live', and a seed that predates a newer entity leaves it un-synced
forever in a process with no sync loop. The SYNC SPRINT after the seed is what closes the
write_date gap and backfills anything the seed lacks (app.py:7779's exact pattern, bounded
passes). Fail-quiet throughout: no seed/token/Odoo β the columns stay blank, the rows still
serve.
"""
import time as _t
t0 = _t.time()
try:
from harness import datastore as _ds
if _ds.ensure_seed():
print(f"[aios-api] datastore seeded in {_t.time() - t0:.1f}s")
# DEDICATED Odoo connection for this thread (the W6 postmortem rule): the sync's
# search_reads must never interleave on the shared client's xmlrpc transport.
import core.odoo as _odoo
try:
_odoo._tlocal.client = _odoo.OdooClient()
except Exception:
pass
res = {}
for i in range(12):
res = _ds.sync_all(log=lambda *a, **k: None)
print(f"[aios-api] datastore sync pass {i + 1}: "
+ ", ".join(f"{k}={v.get('phase')}" for k, v in sorted(res.items())))
if all(v.get("phase") == "live" for v in res.values()):
break
# β Wave 21 (item 2, "make sure the metrics are correct"): a cursor sync can never see
# a HARD DELETE, and the downloaded seed carries whatever was deleted since it was cut β
# one reconcile pass at boot removes both classes of phantom row before the first
# measure is served. MEASURED 2026-08-05: five deleted sale_order_line rows = $284.25 of
# phantom YTD revenue, stable across re-syncs, zero the moment reconcile ran.
try:
_ds.reconcile_deletes(log=lambda *a, **k: None)
except Exception:
pass
print(f"[aios-api] datastore sync done in {_t.time() - t0:.1f}s "
f"(ready={_ds.ready()})")
# ββ 2026-08-09 (wave 28, D-107) β THE RELATIONAL REBUILD RUNS AT BOOT, HERE.
#
# β IT DID NOT BEFORE, AND NOTHING SAID SO. The rebuild lived only inside
# `_store_resync_loop`, whose very first statement is `sleep(1800)` β so the earliest a
# freshly booted container could spawn the four locked databases was T+30 MINUTES. The
# symptom was read as "the boot path is silent": `/odoo-tables/status` polled every 30 s
# across a ~20-minute window over two boots returned the pre-wave schema on all 20
# samples. It was not silent, it had not been asked yet. Both halves of D-107 were like
# this β a thing that never ran, mistaken for a thing that ran and failed.
#
# β WHY HERE AND NOT IN `_prewarm`: the tables are DERIVED FROM THE MIRROR, and this is
# the exact line where the mirror has finished advancing β seed, up to twelve sync passes,
# then the delete reconcile. Calling it from the other thread would race the seed and hit
# either `ro_con()`'s "still warming" RuntimeError or, worse, a HALF-SYNCED mirror, which
# is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
# refuses β correctly, but for a reason that reads like a data loss scare.
# β Same thread on purpose: it is already a daemon and nothing serves requests behind it.
_pull_meta("boot")
_rebuild_odoo_relational("boot")
# ββ W32-T07 β owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
# β AFTER the two above and not before: those advance the mirror and can take minutes, and
# this sweep is unrelated to it β putting it last means a slow Odoo sync cannot delay the
# one thing on this path that fixes a grid the owner has asked about twice.
_sweep_automation_schemas("boot")
except Exception as e: # noqa: BLE001
print(f"[aios-api] datastore seed/sync skipped: {e}")
def _pull_meta(why):
"""Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.
β WHY IT HAS TO HAPPEN HERE AND NOT ON A LAPTOP. The mirror is a FILE that lives beside the
process; the Space's copy is seeded from the HF dataset and knows nothing about a DuckDB on a
developer's box. Running `meta_store --sync` locally populates the local mirror and the LIVE
product stays empty β which is the whole difference between "the loader works" and "the
product has the data". Odoo is already arranged this way (`sync_all` runs in the container);
this is the same arrangement for the second connector.
β FAIL-QUIET AND SILENT WHEN THERE IS NOTHING TO DO. No token => no Meta => one line, no
error: a tenant that has not connected Meta is a normal state, and this runs on every boot.
β The window is deliberately SHORT here (`META_INSIGHTS_DAYS`, default 7 at boot) because boot
is not the place for a 90-day backfill β the resync pass widens it.
"""
try:
from harness import meta_store as _meta
if not _meta.token():
print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this "
f"deployment - the connector is idle, not broken")
return
# β PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at
# IMPORT, so an `os.environ.setdefault` here executed after the module was
# already loaded and changed NOTHING: every boot pulled 90 days instead of 7,
# which is the slow path that trips the per-ad-account rate limit and never
# finishes. A knob read at import cannot be turned by a caller at runtime.
rep = _meta.sync("royal-imports", log=lambda *_a: None, insights_days=7)
for p in rep.get("problems") or []:
print(f"[aios-api] meta sync PROBLEM ({why}): {p}")
print(f"[aios-api] meta sync done ({why}): "
+ ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items())))
except Exception as e: # noqa: BLE001
print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}")
def _rebuild_meta_relational(why, rt):
"""Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used.
β CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than
tidiness: `harness/datastore` is a ONE-FILE-AT-A-TIME process global, and that caller has just
established which tenant's file this process holds open. Spawning Meta here inherits that
binding instead of rebinding it under live readers β which is the documented way to serve one
tenant's rows to another with nothing raised.
β SILENT WHEN THERE IS NOTHING TO DO. A tenant that never connected Meta has no `meta_*` tables
in its mirror; `refresh` returns `{}` and says so once. That is a normal state, not a failure,
and it must not print an error every 30 minutes for every tenant that does not use Meta.
β Its own try/except for the reason the two passes above have theirs: a Meta refusal must not
cancel an Odoo rebuild that already succeeded.
"""
try:
import meta_relational as _meta
counts = _meta.refresh(rt, why)
if counts:
print(f"[aios-api] meta relational rebuild done ({why}): "
+ ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
try:
import automation_engine as _engine
_engine.refresh_relations(rt, log=lambda *_a: None)
except Exception as e: # noqa: BLE001
print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}")
except Exception as e: # noqa: BLE001
print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}")
def _sweep_automation_schemas(why):
"""ββ WAVE 32 Β· `W32-T07` β MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE THE TABLES.
Owner items 13 and 15 β the TikTok comments lock, and the comment CONTENT column he says he has
asked for *"many times"*. **Both were already correct in the source.** `TT_COMMENT_FIELDS`
carries `field_def("text", "Comment")` and `TT_LOCKED_TABLES` already contains the comments
table, both since wave 31, with his words quoted in the comment beside them. So this ticket is
not a schema change and there is nothing to design: it is DELIVERY.
β THE MECHANISM, WHICH IS THE WHOLE OF IT. `ut_ensure` MERGES fields into an existing table and
stamps `recordMode` β but only **when something calls it**, and the only callers are automation
runs. A tenant whose `ut_tt_comments` was spawned before the declaration changed keeps the old
shape until an automation happens to run against it. Nothing sweeps existing tenants. That is
[[a-migration-that-runs-on-the-next-write]], and it is this wave's stated thesis: a declaration
that never reaches a tenant is indistinguishable from one that was never written.
ββ AND IT MUST RUN **IN THE CONTAINER**, WHICH IS WHY THIS IS IN `main.py` AND NOT A SCRIPT.
D-195, measured three times: a developer's CLI write to the tenant store is reverted by the
running Space within a minute (download-modify-upload, last-write-wins) β and **the write
reports success every time**, then a FRESH read confirms it, and it is gone by the next poll.
A connector's tables must be spawned BY THE CONTAINER; a CLI spawn is a dry run that lies.
β EVERY TENANT, unlike `_rebuild_odoo_relational` below β and the asymmetry is deliberate
rather than an oversight. That function is scoped to royal because it derives from the DuckDB
mirror, and `harness/datastore` is a ONE-FILE-AT-A-TIME process global (D-29): rebinding it per
tenant in a daemon thread can serve one tenant's rows to another with nothing raised. This
sweep touches only `user_tables` through each tenant's own `rt`, which has no such global β so
the hazard that scopes that one does not exist here, and TikTok automations run in tenants
other than #0.
β CHEAP ON A CORRECT TENANT: `ut_ensure` short-circuits when nothing changed, so this is a read
per child table on a tenant that is already right, and the whole delivery on one that is not.
β ONE TENANT'S FAILURE MUST NOT STOP THE NEXT. Each is wrapped: a tenant whose store is
unreachable at boot is reported and skipped, never allowed to abort the sweep for everyone.
"""
try:
import automation_engine as _eng
from harness import runtime as _runtime
except Exception as e: # noqa: BLE001
print(f"[aios-api] schema sweep ({why}) SKIPPED β import failed: {e}")
return
try:
schemas = _eng.platform_schemas()
except Exception as e: # noqa: BLE001
print(f"[aios-api] schema sweep ({why}) SKIPPED β no declarations: {e}")
return
tenants = []
try:
tenants = _runtime.known_tenants()
except Exception as e: # noqa: BLE001
print(f"[aios-api] schema sweep ({why}) SKIPPED β tenant list unreadable: {e}")
return
for slug in tenants:
try:
rt = _runtime.get_runtime(slug)
# ββ FIXED 2026-08-13 β THIS SWEEP WAS SPAWNING EIGHT DATABASES IN EVERY TENANT.
# Owner: *"Database for Royal Imports, why we have fucking IG and TIktok databases."*
# `ut_ensure`'s first line is *"Create the table if it is missing"*, and this loop fed
# it every child of every platform schema for every tenant β so tenant #0, a floral and
# giftware importer with ZERO automations, woke up on 2026-08-13 at 12:05 UTC owning
# `ut_ig_posts`, `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots` and the
# four TikTok twins, all empty, all in his nav flyout. **The bug is one word wide:**
# this function's own title says *"MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE
# THE TABLES"* and its body called a CREATE-OR-MERGE function to do a MERGE-ONLY job.
# [[reuse-and-delete-are-hypotheses]] β `ut_ensure` was the right function for the
# merge and brought a second behaviour nobody wanted with it.
#
# β THE PREDICATE IS "DOES THIS TENANT ALREADY HAVE THE TABLE", read ONCE per tenant
# rather than per child β `rt.get` deep-copies the whole tenant document (28.6 MB on
# tenant #0), so asking eight times is eight copies to answer one question.
# β AND IT MUST NOT WEAKEN THE DELIVERY: a tenant that HAS `ut_tt_comments` still gets
# the `text` column and the `recordMode` stamp, which is the entire point of T07. The
# sweep now delivers to tables that exist and mints none, which is what it always
# claimed to do.
have = set(rt.get("user_tables") or {})
ensured, skipped = [], []
for s in schemas:
for key, child in (s.get("children") or {}).items():
if key not in have:
skipped.append(key)
continue
got = _eng.ut_ensure(rt, child["label"], child["fields"], "automation",
key=key, lock_fields=True,
record_mode=child["record_mode"])
if got:
ensured.append(got)
if skipped:
# β SAID OUT LOUD, never silently skipped β this repo's "no silent caps" rule. A
# sweep that quietly does nothing looks identical to a sweep that is not running,
# which is how the previous behaviour survived review in the first place.
print(f"[aios-api] schema sweep ({why}) {slug}: {len(skipped)} child table(s) not "
f"present in this tenant, so nothing was created for them "
f"({', '.join(sorted(skipped))}) β they are spawned by an automation that "
f"needs them, never by this sweep")
# β THE RETRACTION (D's `retract_foreign_presets`, D-152), AFTER the children loop.
# β THE SWEEP ABOVE MAKES COLUMNS **ARRIVE** AND CANNOT MAKE STALE ONES **LEAVE**, and
# T07's `done-when` asserts both ("no `ut_tt_*` grid carries an Instagram column"). On a
# tenant that ran TikTok before W30-T08 the 26 machine-authored IG columns are still
# there β the detector was fixed, the damage never was.
# β `kept` IS THE HONEST HALF: a foreign column that HOLDS DATA is REPORTED, never
# deleted. If it is non-empty, the screenshot shows a column and the report is the
# answer (W30/R6's second sentence).
st = {}
try:
st = _eng.retract_foreign_presets(rt, log=lambda *a, **k: None) or {}
except Exception as e: # noqa: BLE001
print(f"[aios-api] schema sweep ({why}) {slug}: retraction FAILED: {e}")
print(f"[aios-api] schema sweep ({why}) {slug}: ensured={len(ensured)} "
f"tables={st.get('tables', 0)} columns={st.get('columns', 0)} "
f"cells={st.get('cells', 0)} flags={st.get('flags', 0)} "
f"kept={st.get('kept') or []}")
except Exception as e: # noqa: BLE001
print(f"[aios-api] schema sweep ({why}) {slug}: FAILED: {e}")
# β A SUCCESS MARKER, for `_rebuild_odoo_relational`'s stated reason: D-107 was chased for a
# day on ABSENT log markers, which cannot tell "it ran and was fine" from "it was never
# reached". Three failure markers and no success marker makes silence ambiguous.
print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)")
def _rebuild_odoo_relational(why):
"""Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or
'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone
could have asked" are different diagnoses.
β THE SUCCESS LINE IS NOT DECORATION β it is the control this path lacked. D-107 was chased
for a day on the strength of *absent* log markers, which cannot distinguish "the rebuild ran
and was fine" from "the rebuild was never reached". Three failure markers and no success
marker means silence is ambiguous; now it is not.
β SCOPED TO ROYAL-IMPORTS, deliberately, and it is NOT the D-29 shortcut it resembles. The
caller has just advanced whichever DuckDB file this process holds open β tenant #0's β and
royal is the only tenant with an Odoo mirror to derive from (R1). Iterating tenants here walks
straight into D-29's documented hazard: `harness/datastore` is a ONE-FILE-AT-A-TIME
process-global, and rebinding it in a daemon thread under live readers can serve one tenant's
rows to another with nothing raised. `is_royal` stays the authority on which slugs qualify.
"""
try:
import odoo_relational as _rel
from harness import runtime as _runtime
if not _rel.is_royal("royal-imports"):
return
_rt = _runtime.get_runtime("royal-imports")
counts = _rel.refresh(_rt, "royal-imports")
# ββ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do.
#
# β THE FAILURE THIS CLOSES IS THE WORST-LOOKING KIND. `refresh` writes rows and field
# DEFINITIONS; every Link and Rollup cell comes from a separate pass. Those passes used to
# live only on the automation `tick`, which fires from an EXTERNAL EventBridge cron β so a
# fresh boot landed 71,954 rows with eleven fully-configured relational columns and every
# one of them BLANK until an unrelated scheduler happened to run. Nothing errors; the
# tables simply look finished and answer nothing.
#
# β `refresh_relations`, NOT `compute_relation_cells`. The latter computes a change count
# over a blob it was handed and PERSISTS NOTHING; the former walks the tenant and writes.
# Calling the inner one here would return a plausible number and change no cell.
#
# β ONE try/except PER PASS, for the reason `tick` states at its own copies: a source
# rollup REFUSES loudly on a truncated group set, and a shared block would let that honest
# refusal silently cancel a relational pass that had already succeeded.
try:
import automation_engine as _engine
_engine.refresh_relations(_rt, log=lambda *_a: None)
except Exception as e: # noqa: BLE001
print(f"[aios-api] odoo relation cells failed ({why}): {type(e).__name__}: {e}")
try:
import rollup_sql as _rollup
for _b, _key, _l, _f in _rel.TABLES:
_rollup.compute(_rt, _key)
except Exception as e: # noqa: BLE001
print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}")
print(f"[aios-api] odoo relational rebuild done ({why}): "
+ ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items())))
_rebuild_meta_relational(why, _rt)
except Exception as e: # noqa: BLE001
# β The TYPE is named here as it is in the two inner handlers. The original printed only
# `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a
# `BinderException` about a missing column is a different action from a timeout or an auth
# failure, and the text alone often does not say which it was.
print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}")
def _store_resync_loop():
"""Keep the analytical mirror current β the API-process stand-in for the re-sync the
Streamlit app piggybacks on page renders. Measure memos key on the pool stamp, so a
refreshed pool re-reads the freshly synced store."""
import time as _t
passes = 0
while True:
# ββ WAVE 32 Β· OWNER ITEM 11 / R11 β THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800.
#
# β THIS LINE IS WHY THE SETTING WAS NOT A SETTING. SESSION B built the whole of item 11 β
# the 30m/1h/4h/daily/manual presets, the server-side clamp, the config door and
# `odoo_relational.sync_seconds()` which turns the stored preset into seconds β and its own
# ticket said the one-line change belonged to A because `main.py` is A's file. That ASK was
# never sent, so the value was stored, displayed, clamped and IGNORED: a person could pick
# "every 4 hours" and the loop would keep resyncing every 30 minutes with nothing anywhere
# reporting the disagreement. Found by `verify_reachability` naming `sync_seconds` as a
# function whose ONLY caller was its own gate [[artifact-with-no-importer]].
#
# β `None` MEANS MANUAL AND MUST NOT MEAN ZERO. R11's `manual` preset returns None from
# `sync_seconds`; treating that as a falsy interval would spin this loop with no sleep at
# all. It parks at the default cadence instead and simply does no work β the tenant asked
# not to be synced automatically, not for the server to stop breathing.
# β RE-READ EVERY PASS, deliberately: a cadence changed in Settings takes effect on the
# next cycle rather than at the next container restart, which is what makes it a setting.
# β FAIL-SAFE TO 1800 β an unreadable config must not become a tight loop. The floor is
# enforced in `sync_seconds` as well as at the write door, for the same reason.
_every = 1800
try:
import odoo_relational as _rel_cad
from harness import runtime as _rt_cad
_secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports"))
_every = 1800 if _secs is None else max(int(_secs), 60)
except Exception: # noqa: BLE001
pass
_t.sleep(_every)
passes += 1
try:
from harness import datastore as _ds
import core.odoo as _odoo
try:
_odoo._tlocal.client = _odoo.OdooClient()
except Exception:
pass
_ds.sync_all(log=lambda *a, **k: None)
# Wave 21 β every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot
# see (the boot pass's comment has the measured case). Cheap id-sweep per entity;
# without it deleted Odoo lines inflate every sum on the mirror FOREVER.
if passes % 4 == 0:
_ds.reconcile_deletes(log=lambda *a, **k: None)
except Exception as e: # noqa: BLE001
print(f"[aios-api] store resync failed: {e}")
# β WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables are rebuilt
# AFTER the mirror they are derived from, in the same pass and in that order β deriving
# from a mirror this loop is about to advance would publish a worklist one cycle stale
# every single time.
#
# β OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must
# not swallow the sync's error message, and β worse the other way β a sync failure must
# not skip a rebuild that had nothing wrong with it. Two independent failures, two
# independent logs. (`_rebuild_odoo_relational` carries its own handlers.)
#
# β THE REBUILD IS NOT THE FRESHNESS GUARANTEE β the rows carry a visible `_refreshed`
# stamp for that. This loop is a daemon thread whose failure path is a `print` (D-29), so
# "the wiring exists" and "the data is current" are different claims and only the stamp
# can tell a user which one they are looking at.
#
# β ONE IMPLEMENTATION, TWO CALLERS (wave 28). This block used to be the only copy, which
# is what made the boot path silent for 30 minutes after every restart; it is now the
# SECOND caller of the same function `_seed_and_sync_store` calls at boot. A copy here
# would be a second thing to keep in step, and the two would answer differently on the
# next ruling β the exact shape the Views top-up was just fixed for on the other side of
# this wave.
# β THE META PULL RIDES THE RESYNC TOO, AND LEAVING IT OUT WAS A REAL GAP β caught by
# reading the deploy's own boot log rather than by any gate. `_pull_meta` was wired into
# `_seed_and_sync_store` ALONE, i.e. it ran exactly once per container, at boot, behind a
# full Odoo sync. So a boot where the Graph call was rate-limited, slow or simply after the
# thread died left the mirror empty with NOTHING to retry it: the relational rebuild below
# would then find no `meta_*` tables every 30 minutes forever and skip, silently and
# correctly. A connector that can only ever be established at boot is one bad boot away
# from being permanently absent.
# β Cheap when there is nothing to do: no token => one line and return.
_pull_meta("resync")
_rebuild_odoo_relational("resync")
def _prewarm():
import time as _t
t0 = _t.time()
# β The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can
# take many minutes of XML-RPC to close, and the first live probe of this arrangement
# showed the pool warm (42s) silently queued behind it β the whole app cold for every
# visitor while a background column channel caught up. Measure cells retry via the
# transient rule until the sync lands; nothing else waits on it.
import threading as _th
_th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
try:
from harness import runtime as _runtime
rt = _runtime.get_runtime("royal-imports")
routes_customers.warm_default(rt)
import pages as _pages
_pages.warm_default(rt)
# ββ WAVE 30 Β· T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent
# from this list, so its memo was always filled by a visitor rather than by boot: call 1 of
# `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB
# ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path β two
# waves of it β could not touch that, because the cold call is the one that fills the memo.
# β It elects a STRING and keeps no document; see `warm_default`'s own note on why caching
# the bucket would trade a latency for memory this tier does not have.
import routes_automation as _rauto
_rauto.warm_default(rt)
print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
except Exception as e: # noqa: BLE001 β boot must not die on a warm-up
print(f"[aios-api] prewarm skipped: {e}")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ββ W31-T46 / D-160 β THE MIRROR IS SEEDED WHETHER OR NOT `AIOS_PREWARM` IS SET.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#
# THE DEFECT, and it has cost a release once already (owner item 12, wave 20). `_prewarm()` was
# the ONLY caller of `_seed_and_sync_store()`, which is the ONLY caller of
# `datastore.ensure_seed()`. So on a fresh Space disk with `AIOS_PREWARM` anything but `1`:
# no `royal.duckdb` β `datastore.ready()` False forever β `ro_con()` refuses β every measure
# column blank AND β since wave 30 put the Odoo grids on the mirror β **two user-facing grids
# serve nothing**, with the app RUNNING, the deploy green and the tag correct. Last time the
# symptom was read as a pinned tag and a store problem for days.
#
# β AND THE FLAG IS EASIER TO LOSE THAN IT LOOKS: `deploy_web.py` pushes `AIOS_PREWARM=1`
# EXPLICITLY (to overwrite a stale `0`, because a Space secret survives a redeploy) β but that
# push sits under `if TARGET:`, so an ordinary bare `python deploy_web.py` skips it. A guard
# written for the dangerous case that the ordinary case walks straight past.
#
# β SO THE SEED IS SPLIT OFF AND MADE UNCONDITIONAL, which is T46's first branch rather than its
# fallback. It is the right half to move because it is the CHEAP, SAFE one: `ensure_seed()`
# touches no Odoo (it is one `hf_hub_download` of a snapshot), returns immediately when the file
# is already there, and returns immediately without `HF_TOKEN`. The EXPENSIVE, live half β
# `sync_all()`'s XML-RPC passes, the pool warm, the resync loop β stays exactly where it was,
# because the env gate's stated reason is still true: importing `api.main` in a gate must never
# fire a live Odoo pull.
#
# β THE `DB_PATH.exists()` PRE-CHECK IS WHAT KEEPS THIS FREE. It is a stat, and it is False only
# on a genuinely fresh disk β so on every developer box and in every gate run the thread is never
# started at all, and on a fresh Space it does exactly the thing whose absence blanks the grids.
#: What the boot seed did, so a surface can REPORT it instead of an operator inferring it from a
#: blank grid. R6's second sentence: a limit that cannot be removed is reported with its cause.
MIRROR_SEED = {"attempted": False, "seeded": False, "cause": "", "recommendation": ""}
def _seed_mirror_if_absent():
"""Hydrate the analytical mirror when this container has none β INDEPENDENT of `AIOS_PREWARM`.
Fail-quiet by design, and it records WHY rather than only whether: "there is no mirror and no
HF_TOKEN to fetch one" and "there is no mirror and the fetch failed" are different operator
actions, and a blank grid cannot tell them apart.
"""
from harness import datastore as _ds
MIRROR_SEED["attempted"] = True
try:
if _ds.ensure_seed():
MIRROR_SEED["seeded"] = True
print("[aios-api] analytical mirror seeded at boot (independent of AIOS_PREWARM)")
return True
if not _ds.DB_PATH.exists():
MIRROR_SEED["cause"] = (
"this container has no analytical mirror and the seed snapshot could not be "
"fetched (no HF_TOKEN, or the dataset was unreachable)")
MIRROR_SEED["recommendation"] = (
"set HF_TOKEN on the deployment; until then every connected grid and every "
"measure column served from the mirror is empty")
print(f"[aios-api] NO ANALYTICAL MIRROR: {MIRROR_SEED['cause']}")
except Exception as e: # noqa: BLE001 β boot must not die
MIRROR_SEED["cause"] = f"the boot seed raised {type(e).__name__}: {e}"
MIRROR_SEED["recommendation"] = "check HF_TOKEN and the seed dataset's availability"
print(f"[aios-api] mirror seed skipped: {e}")
return False
try:
from harness import datastore as _ds_boot
if not _ds_boot.DB_PATH.exists():
import threading as _threading_seed
_threading_seed.Thread(target=_seed_mirror_if_absent, daemon=True,
name="mirror-seed").start()
except Exception as e: # noqa: BLE001
print(f"[aios-api] mirror seed not scheduled: {e}")
if os.environ.get("AIOS_PREWARM") == "1":
import threading as _threading
_threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
_threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()
|