| """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) | |
| 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)) | |
| 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) | |
| 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) | |
| def _customers_alias(session: Session = Depends(_GATE)): | |
| return routes_customers._payload(session) | |
| 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) | |
| ensured = [] | |
| for s in schemas: | |
| for key, child in (s.get("children") or {}).items(): | |
| 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) | |
| # β 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() | |