| """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) | |
| import routes_publish # noqa: E402 (wave 33 R5/C2 β the publish door; C's router, A's line) | |
| import routes_brand # noqa: E402 (wave 33 R4/C2/C6 β connector brand marks; G's router, A's line) | |
| import routes_slack # noqa: E402 (wave 33 R4/C2 β Manage agent + the Slack door; D's router, A's line) | |
| import routes_starred # noqa: E402 (wave 35 R4/C2/C3/C5 β the star; E's router, E's line) | |
| import routes_usage # noqa: E402 (wave 35 R9/C7 β the AI usage meter; E's router, E's line) | |
| import routes_feedback # noqa: E402 (wave 35 R8/C6 β feedback to the operator plane; E's router) | |
| import routes_agent_harness # noqa: E402 (wave 36 R8/C4 β the agent harness store; E's router) | |
| import routes_script_views # noqa: E402 (wave 36 R3/R10/C3 β script Views; E's router) | |
| from core import grid_events # noqa: E402 | |
| # D-315 / D-305 (2026-08-18) β the two store REFUSALS get their own app-level handlers below. | |
| # β Neither is a `StoreUnavailable` subclass, on purpose: routes that degrade a store outage into a | |
| # session fallback must NOT degrade a refusal, or the user is told a change was saved that was not. | |
| from core import data_binding # noqa: E402 | |
| from core import store # 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 _store_write_refused(request: Request, exc: data_binding.StoreWriteRefused): | |
| """D-315 β this deployment may not write that store. 503, with the REASON. | |
| β IT ECHOES THE EXCEPTION'S OWN MESSAGE, WHERE ITS NEIGHBOUR ABOVE USES A FIXED SENTENCE, and | |
| the difference is the point. "The store is unavailable" is the whole truth about an outage; a | |
| refusal has a cause the operator cannot otherwise discover, because per D-160 the Space | |
| environment cannot be read from outside. A refusal that said only "unavailable" would send | |
| somebody hunting for a network fault that does not exist ([[report-the-cause-before-you-fix-it]]). | |
| The message names the deployment, the store and the fix, and carries no customer data. | |
| β AND IT IS ITS OWN HANDLER RATHER THAN A SUBCLASS OF `StoreUnavailable`. Several routes carry | |
| `except StoreUnavailable:` blocks that DEGRADE to a session-scoped fallback workspace β correct | |
| for an outage, catastrophic here, because the user would be told the change was saved. A | |
| distinct type falls through every one of them to this handler. | |
| """ | |
| return JSONResponse( | |
| {"error": {"code": "store_write_refused", | |
| "message": f"this deployment may not write the tenant store. No change was " | |
| f"saved. {exc}"}}, | |
| status_code=503) | |
| def _store_conflict(request: Request, exc: store.StoreConflict): | |
| """D-305 β the document moved under this write more times than it could be rebased. 503. | |
| β 503 RATHER THAN 409, DELIBERATELY. A 409 invites the client to resolve a conflict, and there | |
| is nothing here for it to resolve: the store already re-read and re-applied this change up to | |
| `_MAX_REBASE` times before giving up. What the caller needs to know is that the write did not | |
| land and nothing was damaged, which is the same contract as the outage above and the same | |
| retry-later shape. β The one answer this must never be is 200: reaching this handler means the | |
| only way to have "succeeded" was to overwrite somebody else's work. | |
| """ | |
| return JSONResponse( | |
| {"error": {"code": "store_conflict", | |
| "message": f"somebody else changed this database while your change was being " | |
| f"saved, and it could not be merged. Nothing was saved or " | |
| f"overwritten. Try again. {exc}"}}, | |
| 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 | |
| # ββ WAVE 33 (C2) β THE SIXTH CONSECUTIVE WAVE IN WHICH THIS BLOCK IS THE THING THE PROTOCOL LOSES. | |
| # Contract C2 is written for exactly that: a lane creating a `routes_*.py` posts an ASK, and the | |
| # INTEGRATOR adds the line in the SAME wave. Both of these arrived that way (C's `ASK C-1`, G's | |
| # `ASK G-1`), and `verify_api::section_w23_mounts` asserts each path in `app.openapi()["paths"]` | |
| # with an NC that comments a mount out and goes RED. | |
| # β Same placement rule as the line above β ABOVE the `app.mount("/", _AppStatic(...), html=True)` | |
| # at the end of the file, never after it. | |
| # ββ THE PUBLISH DOOR IS MOUNTED AGAIN (2026-08-15). It was withheld for the wave-33 deploy | |
| # because QA reproduced three HIGH defects that are armed ONLY by mounting it. All three are fixed | |
| # in `routes_publish.py`, each at its cause rather than at its symptom: | |
| # 1. W33-T68 β `form` LEFT `PUBLISHABLE_MODES`. A form view's rows ARE the submissions people | |
| # sent it, so publishing one served other people's answers to anyone holding the link. A form | |
| # still has its own public door (`#/form/<token>`) which serves the BLANK form and never rows. | |
| # 2. W33-T69 β `_visible_keys` returns `[]` when a view STORED a `visible` list and none of its | |
| # keys survive, instead of falling back to the table default. The fallback still applies to a | |
| # view that never stored one, which is what it was written for. | |
| # 3. W33-T70 β three separate leaks closed: the rate limit no longer keys on the caller-supplied | |
| # `x-forwarded-for` (it keys on the socket peer and counts FAILURES only, so a shared proxy | |
| # peer cannot become one global bucket); every failing path now spends the same PBKDF2 the | |
| # success path spends, closing the 206x timing gap; and an unknown token on the sibling GET | |
| # answers the LOCKED shape rather than a 403, so that route stops sorting real tokens from | |
| # fake ones for free. | |
| # β `verify_api::section_w23_mounts` asserts this path in `app.openapi()["paths"]`, so its four | |
| # EXPECTED reds should now go GREEN. A red here after this line means the mount broke, not the gate. | |
| app.include_router(routes_publish.router) # R5 / C2 β C's router, A's line (the publish door) | |
| # β NOT BEHIND `module_gate("product_data")`, WHICH IS THE WHOLE REASON IT IS A SECOND ASSET DOOR. | |
| # `routes_assets.py` gates EVERY one of its routes on that module, so a connector logo served from | |
| # there would 403 for any account without the product-data grant β i.e. the Connectors directory | |
| # would lose its logos for exactly the accounts most likely to be setting a connector up (C6). | |
| app.include_router(routes_brand.router) # R4 / C2 / C6 β G's router, A's line (brand marks) | |
| # β TWO OF ITS SIX PATHS ARE UNAUTHENTICATED BY DESIGN (`/slack/events`, `/slack/interact`) and | |
| # that is the DOOR, not an omission β Slack posts to them with no session and could not carry one. | |
| # They are built on `routes_forms`' proven public shape: signing-secret verification, a sliding-window | |
| # rate limit, a body cap and ONE non-oracle 403, so a bad signature cannot distinguish "no such | |
| # workspace" from "not yours". β There is no auth middleware and no exempt-path allow-list in this | |
| # file to register them in: "public" here IS the absence of `Depends(require_session)`, which is why | |
| # `verify_api` asserts the absence rather than an entry in a list that does not exist. | |
| app.include_router(routes_slack.router) # R4 / C2 β D's router, A's line (Manage agent + Slack) | |
| # ββ WAVE 35 (R4/R5/R7, contracts C2/C3/C5) β THE STAR. Mounted in the SAME change that created | |
| # `routes_starred.py`, which is the seventh consecutive wave in which this block is the artefact the | |
| # protocol nearly loses β and the first in which the router's own lane also owns `main.py`, so there | |
| # is no ask/mount pair to drop. `W35-T46` asserts every one of this wave's paths in | |
| # `app.openapi()["paths"]` and CALLS one route from each. | |
| # β Same placement rule as every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)`, or | |
| # the router answers 404 on GET and 405 on POST while every one of its own tests passes. | |
| app.include_router(routes_starred.router) # R4 / C2 β the star, counts, and record stars | |
| # β R9 SAYS THIS ROUTE REPORTS AND NEVER ENFORCES, so mounting it cannot cut anybody off β there is | |
| # no ceiling anywhere behind it (`usage_ledger` has no refusal in it). The one AI limit the product | |
| # enforces is per COLUMN and is unchanged (`ai_enrich.ceiling_report`). | |
| app.include_router(routes_usage.router) # R9 / C7 β GET /usage, the one AI meter | |
| # β ITS TWO DOORS CARRY DIFFERENT WALLS ON PURPOSE (R8, and D-221 is the booked precedent): the | |
| # POST is any authenticated session's own act, the GET is `is_platform_admin` only. Mounting it does | |
| # not widen anything a tenant admin can reach β `verify_api` proves that by having one try. | |
| app.include_router(routes_feedback.router) # R8 / C6 β feedback to the operator plane | |
| # ββ WAVE 36 (R8 / C4) β THE AGENT HARNESS FILE STORE, mounted in the SAME change that created | |
| # `routes_agent_harness.py`. Eighth consecutive wave in which this block is the artefact the | |
| # protocol nearly loses; `verify_web_agent::section_w36_harness` asserts this path in | |
| # `app.openapi()["paths"]` and CALLS the route, because mounted is not callable (D-107). | |
| # β Placement, as for every line above: ABOVE `app.mount("/", _AppStatic(...), html=True)` at the | |
| # end of this file, or a GET answers 404 and a PUT answers 405 while every gate stays green. | |
| # β ITS PATHS SIT UNDER `/agents/{id}/...`, WHICH `routes_slack` ALSO SERVES β and that is safe | |
| # rather than lucky: a FastAPI path parameter never spans a `/`, so `/agents/{agent_id}` cannot | |
| # match `/agents/x/harness`. The two routers share a prefix and no route. | |
| app.include_router(routes_agent_harness.router) # R8 / C4 β versioned agent harness files | |
| # ββ WAVE 36 (R3 / R10 / C3) β THE SCRIPT VIEW, owner item 6. Mounted in the SAME change that | |
| # created `routes_script_views.py`; `verify_script_views.py` asserts both of its paths in | |
| # `app.openapi()["paths"]` AND calls them, and its NC comments this line out. | |
| # β ITS RUN DOOR SPAWNS A SUBPROCESS AND IS A PLAIN `def`, so FastAPI runs it in the threadpool. | |
| # Mounting it does not put a ten-second wait anywhere near the event loop; the router's own header | |
| # says why that is not a style choice. | |
| app.include_router(routes_script_views.router) # R3 / R10 / C3 β code-script database Views | |
| # --- 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. | |
| # ββ W35-T45 / R11 β TENANT #0'S ODOO CREDENTIAL MOVES ONTO ITS KEYCHAIN, IN THE CONTAINER. | |
| # β BEFORE the relational rebuild, deliberately: the rebuild resolves its connector through | |
| # `rt.odoo_source()`, so running the migration first means the very next read already goes | |
| # through the keychain branch and a broken migration is visible in THIS boot's log rather | |
| # than in tomorrow's. It is idempotent, so every later boot is one `list_entries` read. | |
| _migrate_env_odoo("boot") | |
| _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 _migrate_env_odoo(why): | |
| """ββ W35-T45 / R11 β move tenant #0's environment Odoo credential onto its keychain. | |
| ββ IN THE CONTAINER, WHICH IS THE WHOLE REASON THIS IS A BOOT LINE 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 CLI migration would | |
| be a dry run that lies, and what it would lie about here is a credential. | |
| β IDEMPOTENT AND SCOPED TO TENANT #0 by `routes_keychain.env_odoo_available`, so on every other | |
| tenant and on every later boot this is one `list_entries` read and a line. | |
| β FAIL-QUIET: this must never take a boot down. But it is never SILENT β a skip prints its reason, | |
| because "already migrated", "no keychain key on this deployment" and "the env is incomplete" are | |
| three different operator actions and a blank Keychain page cannot tell them apart. | |
| """ | |
| try: | |
| import routes_keychain as _kc_routes | |
| from harness import runtime as _runtime | |
| rep = _kc_routes.migrate_env_odoo(_runtime.get_runtime("royal-imports")) | |
| if rep.get("done"): | |
| print(f"[aios-api] odoo credential migrated onto the keychain ({why}): " | |
| f"entry={rep['entry']} carried_pause={rep['carried_pause']}" | |
| + (f" PROBLEM: {rep['why']}" if rep.get("why") else "")) | |
| else: | |
| print(f"[aios-api] odoo keychain migration skipped ({why}): {rep.get('why')}") | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[aios-api] odoo keychain migration FAILED ({why}): {type(e).__name__}: {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. | |
| # ββ WAVE 34 Β· W34-T46 / D-246 β THE PARAGRAPH ABOVE DESCRIBED A BRANCH THAT DID NOT | |
| # EXIST, AND THAT IS WHY NOBODY LOOKED. It said `manual` "parks at the default cadence | |
| # instead and simply DOES NO WORK". The first half was true; the second half was not | |
| # implemented anywhere: `_secs is None` set the sleep to 1800 and then fell into an | |
| # UNCONDITIONAL `sync_all()`. So a tenant who chose "manual" was synced every thirty | |
| # minutes exactly like everyone else, with the setting stored, displayed, clamped and | |
| # obeyed by nothing β the identical failure this block's own W32/R11 note is about, one | |
| # layer further in. A comment asserting a fix is why the second reader stops reading. | |
| # β FIXED, AND THE FIX IS NARROW ON PURPOSE. `manual` is an ODOO connector setting, so it | |
| # skips the Odoo mirror sync and its reconcile and NOTHING ELSE: the Meta pull and the | |
| # relational rebuild below still run, because a tenant asking not to have Odoo polled has | |
| # not asked for the other connector to stop. | |
| # β AND IT STILL SLEEPS AND STILL LOOPS. The config is re-read every pass, so switching | |
| # back off `manual` takes effect on the next cycle rather than at the next restart. | |
| _every = 1800 | |
| _manual = False | |
| try: | |
| import odoo_relational as _rel_cad | |
| from harness import runtime as _rt_cad | |
| # β D-245, KNOWN AND NOT FIXED HERE: the slug is hardcoded, so this reads TENANT #0's | |
| # cadence and applies it to a loop that syncs the shared mirror. Correct while one | |
| # tenant has Odoo; wrong the moment a second one does. Out of this ticket's scope and | |
| # named rather than silently inherited. | |
| _secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports")) | |
| _manual = _secs is None | |
| _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 | |
| # β A PLAIN BRANCH, NOT AN EARLY EXIT AND NOT AN EXCEPTION. A `raise` here would be | |
| # caught by this block's own `except` and printed as "store resync failed", turning a | |
| # setting a person chose into a recurring error in the log; a `continue` would skip | |
| # the Meta pull and the relational rebuild below, which `manual` says nothing about. | |
| if not _manual: | |
| _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. | |
| # β INSIDE the branch: a reconcile is a pass over the mirror this loop was just | |
| # told not to advance. | |
| 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() | |