"""AIOS web API — the ONE shared, stateless process the Streamlit exit is aimed at. It REUSES the `platform/` data layer verbatim (nothing re-implemented): the canonical, reconciled Odoo model stays server-side and the browser only ever sees derived JSON. Serves the JSON API under `/api/*` and the built React bundle (`aios-web/web/dist`) at `/`. WHAT CHANGED IN EXIT WAVE 1 (2026-07-30) — three things, all of them load-bearing: * **Real sessions replace HTTP Basic.** The whole app used to sit behind one shared `APP_PASSWORD` with the browser's native Basic prompt, which means every caller was the same anonymous principal and no route could scope anything. Now a signed stateless cookie carries a real `core/users` identity (X3), and every v1 route resolves BU + own-book scope from the user RECORD. `/api/health` stays unauthenticated (liveness only). * **The overlay fork is deleted.** `aios-web/api/data/overlay.json` was a second writable home for user-owned fields the Streamlit app keeps in the tenant store. ONE store now (C1c). * **The SSL shim is env-gated.** It used to run at import, unconditionally. ⚠ TENANT RESOLUTION IS PER REQUEST (X7). Nothing tenant-shaped is held at module level; the session's tenant claim resolves through `harness.runtime.get_runtime`, which is LRU-bounded. This is the rule the ~30–80 MB/tenant target depends on — EXIT-0 measured the alternative at a ~0.6 GB commit FLOOR per tenant, duplicated within 2% per tenant, nothing shared. """ import os import sys from pathlib import Path # --- the local Odoo SSL quirk, now BEHIND A GATE ------------------------------------------------- # The Windows trust store reports the (valid) Odoo cert as expired, so local runs need an # unverified default context; the HF Space and the container are unaffected. This used to run # unconditionally at import — i.e. the shipped container disabled TLS verification for every # outbound HTTPS call it ever made, to Odoo and to everything else, forever. That is a # man-in-the-middle away from being someone else's data. It is now opt-in, must be set # deliberately, and is never on by default. # ⛔ NEVER set AIOS_INSECURE_SSL in a deployed environment. It exists for one developer laptop. if os.environ.get("AIOS_INSECURE_SSL") == "1": import ssl ssl._create_default_https_context = ssl._create_unverified_context # noqa: S323 # --- reuse the tenant #0 data layer verbatim. RI_DIR overrides the location in the container # (where the layout differs from the local sibling-dir default). --- _HERE = Path(__file__).resolve() _RI = Path(os.environ.get("RI_DIR") or (_HERE.parents[2] / "platform")) for _p in (str(_RI), str(_HERE.parent)): if _p not in sys.path: sys.path.insert(0, _p) from dotenv import load_dotenv # noqa: E402 load_dotenv(_RI / ".env") # Odoo creds + APP_PASSWORD, git-ignored, never committed/printed from fastapi import Body, Depends, FastAPI, Request # noqa: E402 from fastapi.middleware.gzip import GZipMiddleware # noqa: E402 from fastapi.responses import JSONResponse # noqa: E402 from fastapi.staticfiles import StaticFiles # noqa: E402 from starlette.exceptions import HTTPException as StarletteHTTPException # noqa: E402 import aios_session # noqa: E402 import routes_admin # noqa: E402 import routes_alerts # noqa: E402 (wave 20 item 25 — the Alerts inbox) import routes_assets # noqa: E402 (wave 18 C2-ASSET — catalog product imagery) import routes_auth # noqa: E402 import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router, mounted by A) import routes_changes # noqa: E402 (wave 29 item 20 / C6 — F's change token, mounted by A) import routes_customers # noqa: E402 import routes_grid # noqa: E402 import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces) import routes_statements # noqa: E402 (EXIT-6 — the statement sender, off Streamlit) import routes_nav # noqa: E402 import routes_pages # noqa: E402 import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane) import routes_products # noqa: E402 (wave 15 C-TOPIC — gated, not yet in the nav) import routes_records # noqa: E402 import routes_shares # noqa: E402 (wave 20 R10 — grants for views, folders and databases) import routes_uploads # noqa: E402 (wave 21 C5 — tabular preview for Select-from-file) import routes_tables # noqa: E402 (wave 18 C3-UT — user-created databases over the wire) import routes_connectors # noqa: E402 (wave 23 C11 — the connectors directory; SESSION A's router) import routes_forms # noqa: E402 (wave 23 C9 — the PUBLIC form door; SESSION D's router) import routes_templates # noqa: E402 (wave 23 C12 — template apply doors; SESSION E's router) import routes_odoo_tables # noqa: E402 (wave 27 item 17 — Odoo relational; SESSION E's router) import routes_connected_tables # noqa: E402 (wave 31 T49/C4 — the source-neutral grid door; D's) import routes_web_agent # noqa: E402 (wave 31 R10/C5 — the web-browsing agent; E's router, A's line) import routes_query # noqa: E402 (wave 32 R1/C5 — the Query module; E's router, A's line) from core import grid_events # noqa: E402 from deps import Session, module_gate # noqa: E402 _WEB_DIST = _HERE.parents[1] / "web" / "dist" # --- THE CANONICAL FIELD CONTRACT: a startup assertion, and the referee gate's subject ---------- # Every field tags its semantic TYPE and its SOURCE. `source='odoo'` is READ-ONLY (Odoo is never # written); `source='overlay'` is the editable stratum that lives OUTSIDE Odoo. Loaded from the # ONE canonical file shared with the embedded host (`platform/aios_grid_fields.json`), so # embed == standalone by construction. # # TWO REASONS THIS IS HERE and not folded into the routes: # 1. It FAILS LOUDLY at import when the file is missing — which means the deploy or the RI_DIR # layout is broken, and discovering that from a 500 on the first customer request instead of # at startup costs a debugging session. (The pre-wave main.py had this guard; the EXIT-2a # rewrite dropped it and this restores it.) # 2. `aios-web/verify_fields_contract.py` — the cross-side REFEREE — reads `FIELDS` and # `_PASSTHROUGH_KEYS` from this module to prove the canonical file, `aios_grid.py` and this # API have not drifted. The rewrite removed them and the referee went red; retargeting the # gate would have been the wrong repair ([[gate-can-report-green-on-nothing]]: retarget, do # not delete — but only when the subject genuinely moved. Here it should not have moved). # # ⚠ THE ROUTES SERVE A SUPERSET OF THIS. `routes_customers._payload` derives its field list from # `aios_grid.fields_from_workspace(ws)`, which is `FIELDS` PLUS the session user's own custom_ and # measure_ columns — the same list the Streamlit host renders. That is the point: the standalone # shell now sees the user's own columns instead of the bare base contract. `FIELDS` is the # canonical FLOOR, asserted below to be exactly what `aios_grid` starts from. import json as _json_contract # noqa: E402 _FIELDS_PATH = _RI / "aios_grid_fields.json" if not _FIELDS_PATH.is_file(): raise FileNotFoundError( f"AIOS web API: canonical field contract missing at {_FIELDS_PATH}. Set RI_DIR to the " "platform root (it also carries the data layer this API imports)." ) _contract = _json_contract.loads(_FIELDS_PATH.read_text(encoding="utf-8")) FIELDS = _contract["fields"] if isinstance(_contract, dict) else _contract # text/status/select/date pass through untouched; every OTHER odoo field is numeric -> rounded. # Derived from field TYPE (not a hand-kept key list) so a new text/date field can never be # wrongly rounded. `select` joined 2026-08-02 (dba) — a choice label rounded would be garbage. _PASSTHROUGH_KEYS = {f["key"] for f in FIELDS if f["source"] == "odoo" and f["type"] in ("text", "status", "select", "date")} app = FastAPI(title="AIOS web API") # The customers payload measured 1.15 MB of JSON on the live Space, shipped UNCOMPRESSED — with # the 754 KB bundle behind it, most of "the app is slow" was bytes on the wire. gzip takes the # payload to ~10–15% of that. minimum_size spares the tiny acks the overhead. app.add_middleware(GZipMiddleware, minimum_size=1024) @app.exception_handler(StarletteHTTPException) def _error_shape(request: Request, exc: StarletteHTTPException): """ONE error shape for every non-2xx (X2): `{"error": {"code", "message"}}`. `deps.err` already raises detail in that shape; anything FastAPI raises on its own (a 404, a 422 from a malformed path param) is wrapped here so a client never has to branch on two different error bodies. """ detail = exc.detail if isinstance(detail, dict) and "error" in detail: body = detail else: body = {"error": {"code": f"http_{exc.status_code}", "message": str(detail)}} return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) @app.exception_handler(grid_events.StoreUnavailable) def _store_unavailable(request: Request, exc: grid_events.StoreUnavailable): """THE SAFETY NET for "the store is down" → 503, from anywhere. ⚠ WHY THIS IS APP-LEVEL AND NOT A `try` PER ROUTE. It was a try per route first, and a `StoreUnavailable` raised while BUILDING THE PAYLOAD — before the route reached its own try/except — surfaced as a 500. A store outage is a normal operational state and every route here touches the store at least twice (the workspace read, then the write), so "remember to wrap it" is a rule that gets forgotten once and then reports the wrong thing. One handler means a store outage can only ever be a 503, whichever call raised it. The seam raises this only when the caller passed no `fallback_ws` — i.e. exactly on this adapter, which has no durable session dict to degrade into. A 200 over a write that evaporated is the failure the whole rule exists to prevent. """ return JSONResponse( {"error": {"code": "store_unavailable", "message": "the tenant store is unavailable — no change was saved"}}, status_code=503) @app.get("/api/health") def health(): """Unauthenticated LIVENESS only — it must answer before anyone can sign in, so it may not reveal anything about the deployment beyond "the process is up". No version, no tenant list, no config: a health endpoint is the one URL every scanner finds first. ⛔ THE VERSION DOES NOT GO HERE, and it was asked to (2026-08-04, when LIVE became a pinned release and "what is LIVE running" needed an answer). A build identifier tells an unauthenticated caller exactly which commit's known issues apply. It rides `GET /api/v1/settings` instead, behind a session — and the authoritative copy is the `VERSION` file in the Space repo, which `deploy_web.space_version()` reads without needing the app to be up at all.""" return {"ok": True} app.include_router(routes_auth.router) app.include_router(routes_nav.router) app.include_router(routes_customers.router) app.include_router(routes_products.router) app.include_router(routes_assets.router) app.include_router(routes_tables.router) app.include_router(routes_grid.router) app.include_router(routes_records.router) # EXIT wave 2: the Y1 page-data envelope (one route for every ported dashboard) and Y4's user # administration. `routes_pages` imports `pages`, which lazily imports each `pages_*` builder — so # a new page is a builder module plus one registry line, and nothing here changes. app.include_router(routes_pages.router) app.include_router(routes_admin.router) app.include_router(routes_automation.router) app.include_router(routes_keychain.router) app.include_router(routes_statements.router) # Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view. # Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path # ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a # platform-operator route ever shadowing each other. Every path it declares is gated by # `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it. app.include_router(routes_platform_admin.router) # Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are # session-gated rather than admin-gated — an alert is a person's own subscription, and sharing is # something every user does with their own views/folders/databases. app.include_router(routes_alerts.router) app.include_router(routes_shares.router) app.include_router(routes_uploads.router) # ⭐ WAVE 23 — THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this # file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by: # a router included AFTER it answers 404 forever while importing fine, type-checking fine and # passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause. # # ⛔ ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit — three finished # features that would have shipped dead. The workers each posted a mount ask and said they would # signal "ready" first; C waited for a signal that never came while the files landed anyway. **The # lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the # control is that the enumeration must NAME every router in this file.** app.include_router(routes_templates.router) # item 7 / C12 — template registry, session-gated app.include_router(routes_connectors.router) # item 10 / C11 — the connectors directory # ⚠ routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its # two paths are DELIBERATELY unauthenticated — a form is filled in by someone with no account — so # it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token # by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot # distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug. app.include_router(routes_forms.router) # item 8 / C9 — the PUBLIC form door # WAVE 27 item 17 — E's Odoo relational doors. Mounted in the SAME change that E's module landed, # because the wave-23 scar is exactly this: three finished routers shipped with no include_router # line — complete, gated, type-clean and 404 for every caller. `verify_api.section_mounts` walks # `main.app.routes` and pins these two paths by NAME, so an unmounted router now goes RED. app.include_router(routes_odoo_tables.router) # ⭐⭐ WAVE 31 · T49 / C4 — THE SOURCE-NEUTRAL DOOR TO THE SAME CAPABILITY. # `/api/v1/connected-tables/{key}/rows` is an ALIAS: every request lands in # `routes_odoo_tables.odoo_table_rows`, so "the Odoo path behaves byte-identically" holds by # CONSTRUCTION rather than by two implementations that agree on the day they were written. R2 puts # Meta Ads on the same mirror, and a Meta campaign served from a URL with `odoo` in it is a name # that lies to every network tab, log line and bug report. # ⚠ MOUNTED IN THE SAME CHANGE AS THE MODULE, per the line above and for the same wave-23 scar. app.include_router(routes_connected_tables.router) # ⭐⭐ WAVE 29, item 20 / R11 / contract C6 — F's CHANGE TOKEN. `GET /api/v1/changes?scope=` # answers "did this bucket change" for ~zero cost (an in-memory counter; ZERO `store.get()` deep # copies), which is what lets a filtered view pick up a row created in another tab, by an automation # or by a connector sync without re-downloading the world. # ⛔ THIS LINE IS THE ARTIFACT THIS PROTOCOL LOSES MOST RELIABLY, AND IT WAS ALREADY LOST ONCE HERE: # F's router and the CLIENT half both shipped complete, so the poller was calling `/api/v1/changes` # six times a minute and taking a 404 while every one of F's own gates was green. `verify_api`'s D-48 # leg caught it (`unmatched: [('/api/v1/changes', 'apiBridge.ts')]`) — a client fetch path with no # mounted route — which is precisely the control the wave-23 scar above was written to install. # ⚠ Mounted is NOT callable: `section_changes_callable` in `verify_api.py` SIGNS IN and CALLS this # route, because a route can be mounted and still raise before its own `try:` (D-107's plain-text 500). app.include_router(routes_changes.router) # item 20 / C6 — F's router, A's line # ⛔ WAVE 31 (R10 / C5) — E's router, taken by the INTEGRATOR under W31-T08's own done-when # ("no router ships unmounted") because `main.py` is D's fence and D's queue did not reach it. # It was written, gated and 404-dead: `verify_web_agent.py` asserts THIS LINE and was red at # 60/61 for it. Four waves of the same defect — three routers in wave 23, four features in # wave 29 — is why the assertion exists and why the line is not left for later. app.include_router(routes_web_agent.router) # R10 / C5 — E's router, A's line # ⭐⭐ WAVE 32 (R1 / C5, cross-fence wiring 6) — THE QUERY MODULE. E's router, A's line, and the # FIFTH consecutive wave in which this exact line is the artifact the protocol nearly loses. # ⛔ MOUNTED HERE, IN THIS BLOCK, AND NOT AT THE END OF THE FILE — measured by SESSION E in its own # gate before it front-inserted: `include_router` APPENDS, and `app.mount("/", _AppStatic(...), # html=True)` swallows everything reached after it, so a router added below that mount answers # **405 on POST and 404 on GET** while every one of its own tests passes. The comment at :213 states # the rule; E's measurement is what turns it from advice into a number. # ⚠ `verify_api` asserts `/api/v1/query` in `app.openapi()["paths"]` — NEVER `{r.path for r in # app.routes}`, which finds nothing in this app because FastAPI wraps included routers (W31). app.include_router(routes_query.router) # R1 / C5 — E's router, A's line # --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working) # They are the v1 handlers with the v1 session requirement — NOT the old unauthenticated Basic # behavior. An alias that kept the old auth would be a bypass of everything above it. _GATE = module_gate(routes_customers.MODULE) @app.get("/api/customers", deprecated=True) def _customers_alias(session: Session = Depends(_GATE)): return routes_customers._payload(session) @app.patch("/api/customers/{pid}", deprecated=True) def _patch_alias(pid: int, body: dict = Body(default=None), session: Session = Depends(_GATE)): return routes_customers.patch_customer(pid, body, session) def _assert_contract_floor(): """The canonical FIELDS must be exactly what `aios_grid` starts an empty workspace from. This is what stops `FIELDS` becoming a constant that exists only to satisfy a gate. If the canonical JSON and `aios_grid.fields_from_workspace({})` ever disagree, the standalone API and the embedded host are serving two different schemas and the grid's own contract has forked — the exact drift `verify_fields_contract.py` was written to catch, now also caught at startup on whatever machine is actually running. """ import aios_grid base = [f for f in aios_grid.fields_from_workspace({}) if not f.get("custom")] if [f["key"] for f in base] != [f["key"] for f in FIELDS]: raise RuntimeError( "AIOS web API: the canonical field contract and aios_grid.fields_from_workspace({}) " "disagree on the base field set — embed and standalone would serve different schemas. " "Run aios-web/verify_fields_contract.py.") def _startup_notes(): """Say the two things an operator must know, once, at import — never in a response body.""" if aios_session.EPHEMERAL_SECRET: print("[aios-api] AIOS_SESSION_SECRET is not set — signing with a random per-process " "key. Sessions will not survive a restart and will not work across workers. " "Set it in production.") if os.environ.get("AIOS_INSECURE_SSL") == "1": print("[aios-api] AIOS_INSECURE_SSL=1 — TLS verification is DISABLED for outbound " "requests. Local development only; never in a deployed environment.") _assert_contract_floor() _startup_notes() # ⛔ THE STATIC MOUNT IS NOW UNAUTHENTICATED, and that is a change this wave made on purpose. # Before EXIT-3a, `BasicAuth` middleware gated the WHOLE app including this mount. A branded login # page cannot live behind a password prompt, so the shell's own assets must be public — which they # are: `index.html`, the JS/CSS bundle and the favicon reveal nothing. # # ⚠ WHAT THAT SILENTLY DE-GATED, caught in review rather than in production. `web/dist/` also # carries `sample_customers.json`, a DEV FIXTURE copied from `web/public/`. Its 8 customers are # synthetic, but the `agent` column holds REAL EMPLOYEE NAMES, and it went from Basic-gated to # publicly fetchable in this commit. Nothing needs it: both the API bridge and `useCustomerData` # deleted their sample fallback on purpose ("NOTHING HERE FALLS BACK TO sample_customers.json"), # and it survives only because it sits in `web/public/`. So it is refused here — 404, the same # answer as any other path that is not part of the app. # # The right long-term fix is deleting it from `web/public/` (S2's lane — flagged in the mailbox); # this guard is what makes the API safe regardless of what the bundle happens to contain. _DEV_FIXTURES = {"sample_customers.json"} class _AppStatic(StaticFiles): async def get_response(self, path, scope): if Path(path).name in _DEV_FIXTURES: raise StarletteHTTPException(status_code=404, detail="Not Found") resp = await super().get_response(path, scope) # Vite content-hashes everything under assets/ (a change is a NEW url), so those are # immutable — a repeat visit re-downloads zero bytes instead of the whole 750 KB bundle. # index.html must stay revalidated or a deploy would strand returning browsers on the old # bundle; ETag/304 makes that revalidation a header exchange, not a transfer. # ⚠ Normalised first: on a Windows host StaticFiles hands this path with backslashes, # and `startswith("assets/")` silently skipped every asset (measured on the local probe). if path.replace("\\", "/").lstrip("/").startswith("assets/"): resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" else: resp.headers["Cache-Control"] = "no-cache" return resp # static bundle LAST so /api/* wins; html=True serves index.html at / plus the built assets if _WEB_DIST.is_dir(): app.mount("/", _AppStatic(directory=str(_WEB_DIST), html=True), name="web") # --- boot prewarm (AIOS_PREWARM=1 — set by the Dockerfile, never by tests) ----------------------- # Without this, the first visitor after every deploy/restart pays the full Odoo pool build in # their request. The thread warms the CONSOLIDATED scope (None, None) + every registered page's # default envelope; scoped users still pay their own scope's first build, once. # Env-gated rather than a startup event so importing `api.main` in a gate (verify_api and friends # run against fakes) can never fire a live Odoo pull. def _seed_and_sync_store(): """Bring the analytical store (harness.datastore) LIVE for this container — the app.py bootstrap, mirrored (2026-07-31, owner item 1). `harness.datastore` powers every measure column/condition, and ONLY app.py used to call `ensure_seed()` — so on a fresh Space disk this container resolved measures against a store that never existed and every measure cell served blank. Seeding alone was NOT enough either, and that was measured live the same day: "datastore seeded in 1.7s" followed by an endless `api:measure-column: ModelError the data cache is still warming up` — `ready()` demands EVERY entity at phase 'live', and a seed that predates a newer entity leaves it un-synced forever in a process with no sync loop. The SYNC SPRINT after the seed is what closes the write_date gap and backfills anything the seed lacks (app.py:7779's exact pattern, bounded passes). Fail-quiet throughout: no seed/token/Odoo → the columns stay blank, the rows still serve. """ import time as _t t0 = _t.time() try: from harness import datastore as _ds if _ds.ensure_seed(): print(f"[aios-api] datastore seeded in {_t.time() - t0:.1f}s") # DEDICATED Odoo connection for this thread (the W6 postmortem rule): the sync's # search_reads must never interleave on the shared client's xmlrpc transport. import core.odoo as _odoo try: _odoo._tlocal.client = _odoo.OdooClient() except Exception: pass res = {} for i in range(12): res = _ds.sync_all(log=lambda *a, **k: None) print(f"[aios-api] datastore sync pass {i + 1}: " + ", ".join(f"{k}={v.get('phase')}" for k, v in sorted(res.items()))) if all(v.get("phase") == "live" for v in res.values()): break # ⭐ Wave 21 (item 2, "make sure the metrics are correct"): a cursor sync can never see # a HARD DELETE, and the downloaded seed carries whatever was deleted since it was cut — # one reconcile pass at boot removes both classes of phantom row before the first # measure is served. MEASURED 2026-08-05: five deleted sale_order_line rows = $284.25 of # phantom YTD revenue, stable across re-syncs, zero the moment reconcile ran. try: _ds.reconcile_deletes(log=lambda *a, **k: None) except Exception: pass print(f"[aios-api] datastore sync done in {_t.time() - t0:.1f}s " f"(ready={_ds.ready()})") # ⭐⭐ 2026-08-09 (wave 28, D-107) — THE RELATIONAL REBUILD RUNS AT BOOT, HERE. # # ⛔ IT DID NOT BEFORE, AND NOTHING SAID SO. The rebuild lived only inside # `_store_resync_loop`, whose very first statement is `sleep(1800)` — so the earliest a # freshly booted container could spawn the four locked databases was T+30 MINUTES. The # symptom was read as "the boot path is silent": `/odoo-tables/status` polled every 30 s # across a ~20-minute window over two boots returned the pre-wave schema on all 20 # samples. It was not silent, it had not been asked yet. Both halves of D-107 were like # this — a thing that never ran, mistaken for a thing that ran and failed. # # ⚠ WHY HERE AND NOT IN `_prewarm`: the tables are DERIVED FROM THE MIRROR, and this is # the exact line where the mirror has finished advancing — seed, up to twelve sync passes, # then the delete reconcile. Calling it from the other thread would race the seed and hit # either `ro_con()`'s "still warming" RuntimeError or, worse, a HALF-SYNCED mirror, which # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild # refuses — correctly, but for a reason that reads like a data loss scare. # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it. _pull_meta("boot") _rebuild_odoo_relational("boot") # ⭐⭐ W32-T07 — owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`. # ⚠ AFTER the two above and not before: those advance the mirror and can take minutes, and # this sweep is unrelated to it — putting it last means a slow Odoo sync cannot delay the # one thing on this path that fixes a grid the owner has asked about twice. _sweep_automation_schemas("boot") except Exception as e: # noqa: BLE001 print(f"[aios-api] datastore seed/sync skipped: {e}") def _pull_meta(why): """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it. ⛔ WHY IT HAS TO HAPPEN HERE AND NOT ON A LAPTOP. The mirror is a FILE that lives beside the process; the Space's copy is seeded from the HF dataset and knows nothing about a DuckDB on a developer's box. Running `meta_store --sync` locally populates the local mirror and the LIVE product stays empty — which is the whole difference between "the loader works" and "the product has the data". Odoo is already arranged this way (`sync_all` runs in the container); this is the same arrangement for the second connector. ⚠ FAIL-QUIET AND SILENT WHEN THERE IS NOTHING TO DO. No token => no Meta => one line, no error: a tenant that has not connected Meta is a normal state, and this runs on every boot. ⚠ The window is deliberately SHORT here (`META_INSIGHTS_DAYS`, default 7 at boot) because boot is not the place for a 90-day backfill — the resync pass widens it. """ try: from harness import meta_store as _meta if not _meta.token(): print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this " f"deployment - the connector is idle, not broken") return # ⛔ PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at # IMPORT, so an `os.environ.setdefault` here executed after the module was # already loaded and changed NOTHING: every boot pulled 90 days instead of 7, # which is the slow path that trips the per-ad-account rate limit and never # finishes. A knob read at import cannot be turned by a caller at runtime. rep = _meta.sync("royal-imports", log=lambda *_a: None, insights_days=7) for p in rep.get("problems") or []: print(f"[aios-api] meta sync PROBLEM ({why}): {p}") print(f"[aios-api] meta sync done ({why}): " + ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items()))) except Exception as e: # noqa: BLE001 print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}") def _rebuild_meta_relational(why, rt): """Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used. ⭐ CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than tidiness: `harness/datastore` is a ONE-FILE-AT-A-TIME process global, and that caller has just established which tenant's file this process holds open. Spawning Meta here inherits that binding instead of rebinding it under live readers — which is the documented way to serve one tenant's rows to another with nothing raised. ⚠ SILENT WHEN THERE IS NOTHING TO DO. A tenant that never connected Meta has no `meta_*` tables in its mirror; `refresh` returns `{}` and says so once. That is a normal state, not a failure, and it must not print an error every 30 minutes for every tenant that does not use Meta. ⛔ Its own try/except for the reason the two passes above have theirs: a Meta refusal must not cancel an Odoo rebuild that already succeeded. """ try: import meta_relational as _meta counts = _meta.refresh(rt, why) if counts: print(f"[aios-api] meta relational rebuild done ({why}): " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) try: import automation_engine as _engine _engine.refresh_relations(rt, log=lambda *_a: None) except Exception as e: # noqa: BLE001 print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}") except Exception as e: # noqa: BLE001 print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}") def _sweep_automation_schemas(why): """⛔⛔ WAVE 32 · `W32-T07` — MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE THE TABLES. Owner items 13 and 15 — the TikTok comments lock, and the comment CONTENT column he says he has asked for *"many times"*. **Both were already correct in the source.** `TT_COMMENT_FIELDS` carries `field_def("text", "Comment")` and `TT_LOCKED_TABLES` already contains the comments table, both since wave 31, with his words quoted in the comment beside them. So this ticket is not a schema change and there is nothing to design: it is DELIVERY. ⛔ THE MECHANISM, WHICH IS THE WHOLE OF IT. `ut_ensure` MERGES fields into an existing table and stamps `recordMode` — but only **when something calls it**, and the only callers are automation runs. A tenant whose `ut_tt_comments` was spawned before the declaration changed keeps the old shape until an automation happens to run against it. Nothing sweeps existing tenants. That is [[a-migration-that-runs-on-the-next-write]], and it is this wave's stated thesis: a declaration that never reaches a tenant is indistinguishable from one that was never written. ⛔⛔ AND IT MUST RUN **IN THE CONTAINER**, WHICH IS WHY THIS IS IN `main.py` AND NOT A SCRIPT. D-195, measured three times: a developer's CLI write to the tenant store is reverted by the running Space within a minute (download-modify-upload, last-write-wins) — and **the write reports success every time**, then a FRESH read confirms it, and it is gone by the next poll. A connector's tables must be spawned BY THE CONTAINER; a CLI spawn is a dry run that lies. ⚠ EVERY TENANT, unlike `_rebuild_odoo_relational` below — and the asymmetry is deliberate rather than an oversight. That function is scoped to royal because it derives from the DuckDB mirror, and `harness/datastore` is a ONE-FILE-AT-A-TIME process global (D-29): rebinding it per tenant in a daemon thread can serve one tenant's rows to another with nothing raised. This sweep touches only `user_tables` through each tenant's own `rt`, which has no such global — so the hazard that scopes that one does not exist here, and TikTok automations run in tenants other than #0. ⚠ CHEAP ON A CORRECT TENANT: `ut_ensure` short-circuits when nothing changed, so this is a read per child table on a tenant that is already right, and the whole delivery on one that is not. ⚠ ONE TENANT'S FAILURE MUST NOT STOP THE NEXT. Each is wrapped: a tenant whose store is unreachable at boot is reported and skipped, never allowed to abort the sweep for everyone. """ try: import automation_engine as _eng from harness import runtime as _runtime except Exception as e: # noqa: BLE001 print(f"[aios-api] schema sweep ({why}) SKIPPED — import failed: {e}") return try: schemas = _eng.platform_schemas() except Exception as e: # noqa: BLE001 print(f"[aios-api] schema sweep ({why}) SKIPPED — no declarations: {e}") return tenants = [] try: tenants = _runtime.known_tenants() except Exception as e: # noqa: BLE001 print(f"[aios-api] schema sweep ({why}) SKIPPED — tenant list unreadable: {e}") return for slug in tenants: try: rt = _runtime.get_runtime(slug) # ⛔⛔ FIXED 2026-08-13 — THIS SWEEP WAS SPAWNING EIGHT DATABASES IN EVERY TENANT. # Owner: *"Database for Royal Imports, why we have fucking IG and TIktok databases."* # `ut_ensure`'s first line is *"Create the table if it is missing"*, and this loop fed # it every child of every platform schema for every tenant — so tenant #0, a floral and # giftware importer with ZERO automations, woke up on 2026-08-13 at 12:05 UTC owning # `ut_ig_posts`, `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots` and the # four TikTok twins, all empty, all in his nav flyout. **The bug is one word wide:** # this function's own title says *"MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE # THE TABLES"* and its body called a CREATE-OR-MERGE function to do a MERGE-ONLY job. # [[reuse-and-delete-are-hypotheses]] — `ut_ensure` was the right function for the # merge and brought a second behaviour nobody wanted with it. # # ⭐ THE PREDICATE IS "DOES THIS TENANT ALREADY HAVE THE TABLE", read ONCE per tenant # rather than per child — `rt.get` deep-copies the whole tenant document (28.6 MB on # tenant #0), so asking eight times is eight copies to answer one question. # ⚠ AND IT MUST NOT WEAKEN THE DELIVERY: a tenant that HAS `ut_tt_comments` still gets # the `text` column and the `recordMode` stamp, which is the entire point of T07. The # sweep now delivers to tables that exist and mints none, which is what it always # claimed to do. have = set(rt.get("user_tables") or {}) ensured, skipped = [], [] for s in schemas: for key, child in (s.get("children") or {}).items(): if key not in have: skipped.append(key) continue got = _eng.ut_ensure(rt, child["label"], child["fields"], "automation", key=key, lock_fields=True, record_mode=child["record_mode"]) if got: ensured.append(got) if skipped: # ⭐ SAID OUT LOUD, never silently skipped — this repo's "no silent caps" rule. A # sweep that quietly does nothing looks identical to a sweep that is not running, # which is how the previous behaviour survived review in the first place. print(f"[aios-api] schema sweep ({why}) {slug}: {len(skipped)} child table(s) not " f"present in this tenant, so nothing was created for them " f"({', '.join(sorted(skipped))}) — they are spawned by an automation that " f"needs them, never by this sweep") # ⭐ THE RETRACTION (D's `retract_foreign_presets`, D-152), AFTER the children loop. # ⛔ THE SWEEP ABOVE MAKES COLUMNS **ARRIVE** AND CANNOT MAKE STALE ONES **LEAVE**, and # T07's `done-when` asserts both ("no `ut_tt_*` grid carries an Instagram column"). On a # tenant that ran TikTok before W30-T08 the 26 machine-authored IG columns are still # there — the detector was fixed, the damage never was. # ⚠ `kept` IS THE HONEST HALF: a foreign column that HOLDS DATA is REPORTED, never # deleted. If it is non-empty, the screenshot shows a column and the report is the # answer (W30/R6's second sentence). st = {} try: st = _eng.retract_foreign_presets(rt, log=lambda *a, **k: None) or {} except Exception as e: # noqa: BLE001 print(f"[aios-api] schema sweep ({why}) {slug}: retraction FAILED: {e}") print(f"[aios-api] schema sweep ({why}) {slug}: ensured={len(ensured)} " f"tables={st.get('tables', 0)} columns={st.get('columns', 0)} " f"cells={st.get('cells', 0)} flags={st.get('flags', 0)} " f"kept={st.get('kept') or []}") except Exception as e: # noqa: BLE001 print(f"[aios-api] schema sweep ({why}) {slug}: FAILED: {e}") # ⭐ A SUCCESS MARKER, for `_rebuild_odoo_relational`'s stated reason: D-107 was chased for a # day on ABSENT log markers, which cannot tell "it ran and was fine" from "it was never # reached". Three failure markers and no success marker makes silence ambiguous. print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)") def _rebuild_odoo_relational(why): """Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or 'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone could have asked" are different diagnoses. ⭐ THE SUCCESS LINE IS NOT DECORATION — it is the control this path lacked. D-107 was chased for a day on the strength of *absent* log markers, which cannot distinguish "the rebuild ran and was fine" from "the rebuild was never reached". Three failure markers and no success marker means silence is ambiguous; now it is not. ⚠ SCOPED TO ROYAL-IMPORTS, deliberately, and it is NOT the D-29 shortcut it resembles. The caller has just advanced whichever DuckDB file this process holds open — tenant #0's — and royal is the only tenant with an Odoo mirror to derive from (R1). Iterating tenants here walks straight into D-29's documented hazard: `harness/datastore` is a ONE-FILE-AT-A-TIME process-global, and rebinding it in a daemon thread under live readers can serve one tenant's rows to another with nothing raised. `is_royal` stays the authority on which slugs qualify. """ try: import odoo_relational as _rel from harness import runtime as _runtime if not _rel.is_royal("royal-imports"): return _rt = _runtime.get_runtime("royal-imports") counts = _rel.refresh(_rt, "royal-imports") # ⭐⭐ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do. # # ⛔ THE FAILURE THIS CLOSES IS THE WORST-LOOKING KIND. `refresh` writes rows and field # DEFINITIONS; every Link and Rollup cell comes from a separate pass. Those passes used to # live only on the automation `tick`, which fires from an EXTERNAL EventBridge cron — so a # fresh boot landed 71,954 rows with eleven fully-configured relational columns and every # one of them BLANK until an unrelated scheduler happened to run. Nothing errors; the # tables simply look finished and answer nothing. # # ⚠ `refresh_relations`, NOT `compute_relation_cells`. The latter computes a change count # over a blob it was handed and PERSISTS NOTHING; the former walks the tenant and writes. # Calling the inner one here would return a plausible number and change no cell. # # ⚠ ONE try/except PER PASS, for the reason `tick` states at its own copies: a source # rollup REFUSES loudly on a truncated group set, and a shared block would let that honest # refusal silently cancel a relational pass that had already succeeded. try: import automation_engine as _engine _engine.refresh_relations(_rt, log=lambda *_a: None) except Exception as e: # noqa: BLE001 print(f"[aios-api] odoo relation cells failed ({why}): {type(e).__name__}: {e}") try: import rollup_sql as _rollup for _b, _key, _l, _f in _rel.TABLES: _rollup.compute(_rt, _key) except Exception as e: # noqa: BLE001 print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}") print(f"[aios-api] odoo relational rebuild done ({why}): " + ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items()))) _rebuild_meta_relational(why, _rt) except Exception as e: # noqa: BLE001 # ⚠ The TYPE is named here as it is in the two inner handlers. The original printed only # `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a # `BinderException` about a missing column is a different action from a timeout or an auth # failure, and the text alone often does not say which it was. print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}") def _store_resync_loop(): """Keep the analytical mirror current — the API-process stand-in for the re-sync the Streamlit app piggybacks on page renders. Measure memos key on the pool stamp, so a refreshed pool re-reads the freshly synced store.""" import time as _t passes = 0 while True: # ⭐⭐ WAVE 32 · OWNER ITEM 11 / R11 — THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800. # # ⛔ THIS LINE IS WHY THE SETTING WAS NOT A SETTING. SESSION B built the whole of item 11 — # the 30m/1h/4h/daily/manual presets, the server-side clamp, the config door and # `odoo_relational.sync_seconds()` which turns the stored preset into seconds — and its own # ticket said the one-line change belonged to A because `main.py` is A's file. That ASK was # never sent, so the value was stored, displayed, clamped and IGNORED: a person could pick # "every 4 hours" and the loop would keep resyncing every 30 minutes with nothing anywhere # reporting the disagreement. Found by `verify_reachability` naming `sync_seconds` as a # function whose ONLY caller was its own gate [[artifact-with-no-importer]]. # # ⚠ `None` MEANS MANUAL AND MUST NOT MEAN ZERO. R11's `manual` preset returns None from # `sync_seconds`; treating that as a falsy interval would spin this loop with no sleep at # all. It parks at the default cadence instead and simply does no work — the tenant asked # not to be synced automatically, not for the server to stop breathing. # ⚠ RE-READ EVERY PASS, deliberately: a cadence changed in Settings takes effect on the # next cycle rather than at the next container restart, which is what makes it a setting. # ⚠ FAIL-SAFE TO 1800 — an unreadable config must not become a tight loop. The floor is # enforced in `sync_seconds` as well as at the write door, for the same reason. _every = 1800 try: import odoo_relational as _rel_cad from harness import runtime as _rt_cad _secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports")) _every = 1800 if _secs is None else max(int(_secs), 60) except Exception: # noqa: BLE001 pass _t.sleep(_every) passes += 1 try: from harness import datastore as _ds import core.odoo as _odoo try: _odoo._tlocal.client = _odoo.OdooClient() except Exception: pass _ds.sync_all(log=lambda *a, **k: None) # Wave 21 — every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot # see (the boot pass's comment has the measured case). Cheap id-sweep per entity; # without it deleted Odoo lines inflate every sum on the mirror FOREVER. if passes % 4 == 0: _ds.reconcile_deletes(log=lambda *a, **k: None) except Exception as e: # noqa: BLE001 print(f"[aios-api] store resync failed: {e}") # ⭐ WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables are rebuilt # AFTER the mirror they are derived from, in the same pass and in that order — deriving # from a mirror this loop is about to advance would publish a worklist one cycle stale # every single time. # # ⛔ OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must # not swallow the sync's error message, and — worse the other way — a sync failure must # not skip a rebuild that had nothing wrong with it. Two independent failures, two # independent logs. (`_rebuild_odoo_relational` carries its own handlers.) # # ⚠ THE REBUILD IS NOT THE FRESHNESS GUARANTEE — the rows carry a visible `_refreshed` # stamp for that. This loop is a daemon thread whose failure path is a `print` (D-29), so # "the wiring exists" and "the data is current" are different claims and only the stamp # can tell a user which one they are looking at. # # ⭐ ONE IMPLEMENTATION, TWO CALLERS (wave 28). This block used to be the only copy, which # is what made the boot path silent for 30 minutes after every restart; it is now the # SECOND caller of the same function `_seed_and_sync_store` calls at boot. A copy here # would be a second thing to keep in step, and the two would answer differently on the # next ruling — the exact shape the Views top-up was just fixed for on the other side of # this wave. # ⛔ THE META PULL RIDES THE RESYNC TOO, AND LEAVING IT OUT WAS A REAL GAP — caught by # reading the deploy's own boot log rather than by any gate. `_pull_meta` was wired into # `_seed_and_sync_store` ALONE, i.e. it ran exactly once per container, at boot, behind a # full Odoo sync. So a boot where the Graph call was rate-limited, slow or simply after the # thread died left the mirror empty with NOTHING to retry it: the relational rebuild below # would then find no `meta_*` tables every 30 minutes forever and skip, silently and # correctly. A connector that can only ever be established at boot is one bad boot away # from being permanently absent. # ⚠ Cheap when there is nothing to do: no token => one line and return. _pull_meta("resync") _rebuild_odoo_relational("resync") def _prewarm(): import time as _t t0 = _t.time() # ⚠ The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can # take many minutes of XML-RPC to close, and the first live probe of this arrangement # showed the pool warm (42s) silently queued behind it — the whole app cold for every # visitor while a background column channel caught up. Measure cells retry via the # transient rule until the sync lands; nothing else waits on it. import threading as _th _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start() try: from harness import runtime as _runtime rt = _runtime.get_runtime("royal-imports") routes_customers.warm_default(rt) import pages as _pages _pages.warm_default(rt) # ⭐⭐ WAVE 30 · T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent # from this list, so its memo was always filled by a visitor rather than by boot: call 1 of # `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB # ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path — two # waves of it — could not touch that, because the cold call is the one that fills the memo. # ⚠ It elects a STRING and keeps no document; see `warm_default`'s own note on why caching # the bucket would trade a latency for memory this tier does not have. import routes_automation as _rauto _rauto.warm_default(rt) print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s") except Exception as e: # noqa: BLE001 — boot must not die on a warm-up print(f"[aios-api] prewarm skipped: {e}") # ═════════════════════════════════════════════════════════════════════════════════════════════ # ⭐⭐ W31-T46 / D-160 — THE MIRROR IS SEEDED WHETHER OR NOT `AIOS_PREWARM` IS SET. # ═════════════════════════════════════════════════════════════════════════════════════════════ # # THE DEFECT, and it has cost a release once already (owner item 12, wave 20). `_prewarm()` was # the ONLY caller of `_seed_and_sync_store()`, which is the ONLY caller of # `datastore.ensure_seed()`. So on a fresh Space disk with `AIOS_PREWARM` anything but `1`: # no `royal.duckdb` ⇒ `datastore.ready()` False forever ⇒ `ro_con()` refuses ⇒ every measure # column blank AND — since wave 30 put the Odoo grids on the mirror — **two user-facing grids # serve nothing**, with the app RUNNING, the deploy green and the tag correct. Last time the # symptom was read as a pinned tag and a store problem for days. # # ⛔ AND THE FLAG IS EASIER TO LOSE THAN IT LOOKS: `deploy_web.py` pushes `AIOS_PREWARM=1` # EXPLICITLY (to overwrite a stale `0`, because a Space secret survives a redeploy) — but that # push sits under `if TARGET:`, so an ordinary bare `python deploy_web.py` skips it. A guard # written for the dangerous case that the ordinary case walks straight past. # # ⭐ SO THE SEED IS SPLIT OFF AND MADE UNCONDITIONAL, which is T46's first branch rather than its # fallback. It is the right half to move because it is the CHEAP, SAFE one: `ensure_seed()` # touches no Odoo (it is one `hf_hub_download` of a snapshot), returns immediately when the file # is already there, and returns immediately without `HF_TOKEN`. The EXPENSIVE, live half — # `sync_all()`'s XML-RPC passes, the pool warm, the resync loop — stays exactly where it was, # because the env gate's stated reason is still true: importing `api.main` in a gate must never # fire a live Odoo pull. # # ⚠ THE `DB_PATH.exists()` PRE-CHECK IS WHAT KEEPS THIS FREE. It is a stat, and it is False only # on a genuinely fresh disk — so on every developer box and in every gate run the thread is never # started at all, and on a fresh Space it does exactly the thing whose absence blanks the grids. #: What the boot seed did, so a surface can REPORT it instead of an operator inferring it from a #: blank grid. R6's second sentence: a limit that cannot be removed is reported with its cause. MIRROR_SEED = {"attempted": False, "seeded": False, "cause": "", "recommendation": ""} def _seed_mirror_if_absent(): """Hydrate the analytical mirror when this container has none — INDEPENDENT of `AIOS_PREWARM`. Fail-quiet by design, and it records WHY rather than only whether: "there is no mirror and no HF_TOKEN to fetch one" and "there is no mirror and the fetch failed" are different operator actions, and a blank grid cannot tell them apart. """ from harness import datastore as _ds MIRROR_SEED["attempted"] = True try: if _ds.ensure_seed(): MIRROR_SEED["seeded"] = True print("[aios-api] analytical mirror seeded at boot (independent of AIOS_PREWARM)") return True if not _ds.DB_PATH.exists(): MIRROR_SEED["cause"] = ( "this container has no analytical mirror and the seed snapshot could not be " "fetched (no HF_TOKEN, or the dataset was unreachable)") MIRROR_SEED["recommendation"] = ( "set HF_TOKEN on the deployment; until then every connected grid and every " "measure column served from the mirror is empty") print(f"[aios-api] NO ANALYTICAL MIRROR: {MIRROR_SEED['cause']}") except Exception as e: # noqa: BLE001 — boot must not die MIRROR_SEED["cause"] = f"the boot seed raised {type(e).__name__}: {e}" MIRROR_SEED["recommendation"] = "check HF_TOKEN and the seed dataset's availability" print(f"[aios-api] mirror seed skipped: {e}") return False try: from harness import datastore as _ds_boot if not _ds_boot.DB_PATH.exists(): import threading as _threading_seed _threading_seed.Thread(target=_seed_mirror_if_absent, daemon=True, name="mirror-seed").start() except Exception as e: # noqa: BLE001 print(f"[aios-api] mirror seed not scheduled: {e}") if os.environ.get("AIOS_PREWARM") == "1": import threading as _threading _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start() _threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()