fix(arch-043,arch-052): deterministic system tenant lookup; async-safe file metadata
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-23 16:39:09 +02:00
parent ed8ee5cda1
commit 17516d2783
5 changed files with 201 additions and 20 deletions
+4
View File
@@ -107,6 +107,10 @@ class Settings(BaseSettings):
rate_limit_webhook_max: int = 100 # incoming webhooks
rate_limit_webhook_window: int = 60 # 1 minute
# System tenant — used by seeding/plugins that need a well-known default
# tenant (must match scripts/seed_admin.py slug).
system_tenant_slug: str = "default"
# LLM Cost Overrun Protection (B.17)
llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget
llm_hard_cutoff: bool = True # block LLM calls when budget exceeded
+17
View File
@@ -355,6 +355,23 @@ async def close_engine() -> None:
_migration_session_factory = None
async def get_system_tenant(db: AsyncSession):
"""Return the well-known system tenant, or ``None`` if it does not exist.
Resolves by configured slug (``settings.system_tenant_slug``, default
``"default"`` as created by ``scripts/seed_admin.py``) instead of an
arbitrary first row, so multi-tenant databases stay deterministic.
"""
from sqlalchemy import select
from app.config import get_settings
from app.models.tenant import Tenant # lazy: models import this module's Base
slug = get_settings().system_tenant_slug
result = await db.execute(select(Tenant).where(Tenant.slug == slug).limit(1))
return result.scalar_one_or_none()
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Replace all global engines with a test engine. Returns a session factory.
+40 -12
View File
@@ -501,11 +501,38 @@ async def save_with_metadata(
}
async def get_file_metadata_async(path: str) -> dict[str, Any]:
"""Awaitable variant of :func:`get_file_metadata` (ARCH-052).
Safe to call from inside a running event loop — never creates a
nested one. For local storage this is plain filesystem access; for
S3 and other async backends the backend's ``exists()`` is awaited.
"""
backend = get_storage_backend()
if isinstance(backend, LocalStorage):
full_path = backend._full_path(path)
if not os.path.exists(full_path):
return {"size": None, "modified": None, "exists": False}
stat = os.stat(full_path)
return {
"size": stat.st_size,
"modified": stat.st_mtime,
"exists": True,
}
# S3 or other async backends — await the backend directly
if not await backend.exists(path):
return {"size": None, "modified": None, "exists": False}
return {"size": None, "modified": None, "exists": True}
def get_file_metadata(path: str) -> dict[str, Any]:
"""Read metadata of a stored file without loading its content.
Works with the *local* storage backend. For S3, use the S3 client
``stat_object`` API directly.
Works with the *local* storage backend without touching the event
loop. For S3 and other async-only backends this drives the check
through ``asyncio.run``; calling it from inside a running event loop
raises ``RuntimeError`` — use :func:`get_file_metadata_async` there
instead (ARCH-052).
Parameters
----------
@@ -530,14 +557,15 @@ def get_file_metadata(path: str) -> dict[str, Any]:
"modified": stat.st_mtime,
"exists": True,
}
# S3 or other backends — fall back to exists() check
import asyncio as _asyncio
loop = _asyncio.new_event_loop()
# Async-only backend outside a running loop is fine; inside one we
# must never build a nested event loop.
try:
exists = loop.run_until_complete(backend.exists(path))
if not exists:
return {"size": None, "modified": None, "exists": False}
return {"size": None, "modified": None, "exists": True}
finally:
loop.close()
asyncio.get_running_loop()
except RuntimeError:
pass
else:
raise RuntimeError(
"get_file_metadata() cannot be used with async storage backends "
"inside a running event loop — use get_file_metadata_async()"
)
return asyncio.run(get_file_metadata_async(path))
+9 -8
View File
@@ -246,11 +246,12 @@ class AutomationPlugin(BasePlugin):
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
from sqlalchemy import select as sa_select
# Get first tenant + admin user for seeding
# Get system tenant + admin user for seeding (ARCH-043:
# deterministic slug lookup instead of arbitrary first row)
from app.core.db import get_system_tenant
from app.models.user import User
from app.models.tenant import Tenant
tenant_result = await db.execute(sa_select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
tenant = await get_system_tenant(db)
if tenant:
user_result = await db.execute(
sa_select(User)
@@ -418,16 +419,16 @@ class AutomationPlugin(BasePlugin):
from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
from sqlalchemy import select
# Get default tenant_id from the first tenant in the DB
from app.models.tenant import Tenant
# Get system tenant for contributions (ARCH-043: deterministic slug
# lookup instead of arbitrary first row)
from app.core.db import get_system_tenant
from app.plugins.builtins.automation.models import AutomationCronJob
from app.plugins.builtins.automation.services import (
AgentService,
AutomationService,
CronJobService,
)
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
tenant = await get_system_tenant(db)
default_tenant_id = tenant.id if tenant else None
if default_tenant_id is None:
logger.warning("No tenant found — skipping plugin contributions registration")