chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""Base storage class for file system abstraction."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import BinaryIO, List, Callable
|
||||
|
||||
|
||||
class BaseStorage(ABC):
|
||||
"""Abstract base class for storage implementations."""
|
||||
|
||||
@abstractmethod
|
||||
def save_file(self, file_data: BinaryIO, path: str, **kwargs) -> dict:
|
||||
"""
|
||||
Save a file to storage.
|
||||
|
||||
Args:
|
||||
file_data: File-like object containing the data
|
||||
path: Path where the file should be stored
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing metadata about the saved file, including:
|
||||
- 'path': The path where the file was saved
|
||||
- 'storage_type': The type of storage (e.g., 'local', 's3')
|
||||
- Other storage-specific metadata (e.g., 'uri', 'bucket_name', etc.)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""
|
||||
Retrieve a file from storage.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
BinaryIO: File-like object containing the file data
|
||||
"""
|
||||
pass
|
||||
|
||||
def generate_presigned_url(self, path: str, expires_in: int = 300) -> str:
|
||||
"""Return a short-lived presigned download URL; not all backends support it.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
expires_in: TTL of the signed URL in seconds
|
||||
|
||||
Returns:
|
||||
str: A presigned URL granting time-limited read access
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the backend cannot mint presigned URLs.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support presigned URLs"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
This method handles the details of retrieving the file and providing
|
||||
it to the processor function in an appropriate way based on the storage type.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
bool: True if deletion was successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
bool: True if the file exists
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""
|
||||
List all files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory path to list
|
||||
|
||||
Returns:
|
||||
List[str]: List of file paths
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_directory(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a path is a directory.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
bool: True if the path is a directory
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_directory(self, directory: str) -> bool:
|
||||
"""
|
||||
Remove a directory and all its contents.
|
||||
|
||||
For local storage, this removes the directory and all files/subdirectories within it.
|
||||
For S3 storage, this removes all objects with the directory path as a prefix.
|
||||
|
||||
Args:
|
||||
directory: Directory path to remove
|
||||
|
||||
Returns:
|
||||
bool: True if removal was successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
"""PostgreSQL storage layer for user-level data.
|
||||
|
||||
This package holds the SQLAlchemy Core engine, metadata, repositories, and
|
||||
migration infrastructure for the user-data Postgres database. It is separate
|
||||
from ``application/vectorstore/pgvector.py`` — the two may point at the same
|
||||
cluster or at different clusters depending on operator configuration.
|
||||
|
||||
Repository modules are added in later phases
|
||||
as individual collections are ported.
|
||||
"""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Common helpers shared by all repositories.
|
||||
|
||||
Repositories are thin wrappers around SQLAlchemy Core query construction.
|
||||
They take a ``Connection`` on call and return plain ``dict`` rows during the
|
||||
Mongo→Postgres cutover so that call sites don't have to change shape. Once
|
||||
cutover is complete, a follow-up phase may migrate repo return types to
|
||||
Pydantic DTOs (tracked in the migration plan as a post-migration item).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from application.storage.db.serialization import coerce_pg_native
|
||||
|
||||
|
||||
_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def looks_like_uuid(value: Any) -> bool:
|
||||
"""Return True if ``value`` is a canonical UUID (string or ``UUID`` instance).
|
||||
|
||||
Used by ``get_any`` accessors to pick the UUID lookup path vs. the
|
||||
``legacy_mongo_id`` fallback during the Mongo→PG cutover window.
|
||||
Accepting ``uuid.UUID`` directly matters for callers that receive an
|
||||
id straight from a PG column (SQLAlchemy maps ``UUID`` columns to the
|
||||
Python ``UUID`` type) — without this, the call falls through to the
|
||||
legacy-text lookup and crashes on ``operator does not exist: text = uuid``.
|
||||
"""
|
||||
if isinstance(value, UUID):
|
||||
return True
|
||||
return isinstance(value, str) and bool(_UUID_RE.match(value))
|
||||
|
||||
|
||||
def row_to_dict(row: Any) -> dict:
|
||||
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
|
||||
|
||||
Normalises PG-native types at the SELECT boundary: UUID, datetime,
|
||||
date, Decimal, and bytes are coerced to JSON-safe forms via
|
||||
:func:`coerce_pg_native`. Downstream serialisation (SSE events,
|
||||
JSONB writes, API responses) becomes safe by default — repository
|
||||
consumers no longer need to know that PG returns a different type
|
||||
set than Mongo did.
|
||||
|
||||
Also emits ``_id`` alongside ``id`` for the duration of the Mongo→PG
|
||||
cutover so legacy serializers expecting Mongo's shape keep working.
|
||||
|
||||
Args:
|
||||
row: A SQLAlchemy ``Row`` object, or ``None``.
|
||||
|
||||
Returns:
|
||||
A plain dict, or an empty dict if ``row`` is ``None``.
|
||||
"""
|
||||
if row is None:
|
||||
return {}
|
||||
|
||||
# Row has a ``._mapping`` attribute exposing a MappingProxy view.
|
||||
mapping: Mapping[str, Any] = row._mapping # type: ignore[attr-defined]
|
||||
out = coerce_pg_native(dict(mapping))
|
||||
|
||||
if "id" in out and out["id"] is not None:
|
||||
out["_id"] = out["id"]
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Self-bootstrapping database setup for the DocsGPT user-data Postgres DB.
|
||||
|
||||
On app startup the Flask factory (and Celery worker init) can call
|
||||
:func:`ensure_database_ready` to:
|
||||
|
||||
1. Create the target database if it's missing (dev-friendly; requires the
|
||||
configured role to have ``CREATEDB`` privilege).
|
||||
2. Apply every pending Alembic migration up to ``head``.
|
||||
|
||||
Both steps are gated by settings that default ON for dev convenience and
|
||||
can be turned off in prod (``AUTO_CREATE_DB`` / ``AUTO_MIGRATE``) where
|
||||
schema is managed out-of-band by a deploy pipeline.
|
||||
|
||||
All heavy imports (alembic, psycopg, sqlalchemy.exc sub-symbols) are
|
||||
deferred to inside the function so merely importing this module has no
|
||||
side effects and is cheap for test collection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def ensure_database_ready(
|
||||
uri: Optional[str],
|
||||
*,
|
||||
create_db: bool,
|
||||
migrate: bool,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
"""Make sure the target Postgres DB exists and is migrated to ``head``.
|
||||
|
||||
This is idempotent and safe to call once per process. Each step is
|
||||
independently gated so prod deployments that manage schema externally
|
||||
can disable the migrate step while still allowing the process to boot
|
||||
against an already-provisioned database.
|
||||
|
||||
Args:
|
||||
uri: SQLAlchemy URI for the user-data Postgres database. If
|
||||
``None`` or empty, the function logs and returns — the app
|
||||
supports running without a configured URI for certain dev
|
||||
flows that don't touch user data.
|
||||
create_db: If ``True``, auto-create the database when it's
|
||||
missing. Requires the configured role to have ``CREATEDB``.
|
||||
migrate: If ``True``, run ``alembic upgrade head`` after the
|
||||
database is reachable.
|
||||
logger: Optional logger to use. Defaults to this module's logger.
|
||||
|
||||
Raises:
|
||||
Exception: Any failure in an explicitly-enabled step is re-raised
|
||||
so the app fails fast rather than booting into a broken state.
|
||||
Missing-role / auth errors surface cleanly without a
|
||||
mis-directed auto-create attempt.
|
||||
"""
|
||||
log = logger or logging.getLogger(__name__)
|
||||
|
||||
if not uri:
|
||||
log.info(
|
||||
"ensure_database_ready: POSTGRES_URI is not set; "
|
||||
"skipping database bootstrap."
|
||||
)
|
||||
return
|
||||
|
||||
if create_db:
|
||||
_ensure_database_exists(uri, log)
|
||||
|
||||
if migrate:
|
||||
_run_migrations(log)
|
||||
|
||||
|
||||
def _ensure_database_exists(uri: str, log: logging.Logger) -> None:
|
||||
"""Create the target database if a connection reveals it's missing.
|
||||
|
||||
We probe with a lightweight ``connect().close()``. If Postgres
|
||||
reports ``InvalidCatalogName`` (SQLSTATE ``3D000``), we reconnect to
|
||||
the server's ``postgres`` maintenance DB and issue ``CREATE DATABASE``
|
||||
in AUTOCOMMIT mode (required — CREATE DATABASE can't run in a
|
||||
transaction). Any other connection failure (bad host, auth failure,
|
||||
missing role) is re-raised untouched so the operator sees the true
|
||||
cause instead of a mis-directed auto-create attempt.
|
||||
"""
|
||||
# Lazy imports keep module import side-effect free.
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
url = make_url(uri)
|
||||
target_db = url.database
|
||||
if not target_db:
|
||||
raise RuntimeError(
|
||||
f"POSTGRES_URI is missing a database name: {uri!r}. "
|
||||
"Expected something like "
|
||||
"'postgresql+psycopg://user:pass@host:5432/docsgpt'."
|
||||
)
|
||||
|
||||
probe_engine = create_engine(uri, pool_pre_ping=False)
|
||||
try:
|
||||
try:
|
||||
conn = probe_engine.connect()
|
||||
except OperationalError as exc:
|
||||
if _is_missing_database(exc):
|
||||
log.info(
|
||||
"ensure_database_ready: database %r is missing; "
|
||||
"creating it...",
|
||||
target_db,
|
||||
)
|
||||
_create_database(url, target_db, log)
|
||||
log.info("ensure_database_ready: database %r ready.", target_db)
|
||||
return
|
||||
# Not a missing-DB error — surface it as-is. This is the path
|
||||
# for bad host/auth/role-missing, and auto-creating would be
|
||||
# actively wrong there.
|
||||
log.error(
|
||||
"ensure_database_ready: cannot connect to Postgres for "
|
||||
"database %r: %s",
|
||||
target_db,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
else:
|
||||
conn.close()
|
||||
log.info("ensure_database_ready: database %r ready.", target_db)
|
||||
finally:
|
||||
probe_engine.dispose()
|
||||
|
||||
|
||||
def _create_database(url, target_db: str, log: logging.Logger) -> None:
|
||||
"""Issue ``CREATE DATABASE`` against the server's ``postgres`` DB.
|
||||
|
||||
Uses AUTOCOMMIT (required by Postgres — ``CREATE DATABASE`` cannot run
|
||||
inside a transaction). The database identifier is quoted via
|
||||
``psycopg.sql.Identifier`` so unusual names (hyphens, reserved words)
|
||||
are handled correctly.
|
||||
|
||||
Args:
|
||||
url: Parsed SQLAlchemy URL for the target DB; we reuse
|
||||
host/port/credentials and swap the database to ``postgres``.
|
||||
target_db: The target database name to create.
|
||||
log: Logger for INFO/ERROR breadcrumbs.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
# psycopg is imported lazily — its error classes are the canonical
|
||||
# cause markers Postgres hands us back.
|
||||
import psycopg
|
||||
from psycopg import sql as pg_sql
|
||||
|
||||
maintenance_url = url.set(database="postgres")
|
||||
maintenance_engine = create_engine(
|
||||
maintenance_url,
|
||||
isolation_level="AUTOCOMMIT",
|
||||
pool_pre_ping=False,
|
||||
)
|
||||
try:
|
||||
with maintenance_engine.connect() as conn:
|
||||
# Use psycopg's Identifier to quote the DB name safely. The
|
||||
# SQL object renders as a literal ``CREATE DATABASE "<name>"``
|
||||
# which SQLAlchemy passes through to psycopg verbatim.
|
||||
stmt = pg_sql.SQL("CREATE DATABASE {}").format(
|
||||
pg_sql.Identifier(target_db)
|
||||
)
|
||||
raw = conn.connection.dbapi_connection # psycopg connection
|
||||
with raw.cursor() as cur:
|
||||
try:
|
||||
cur.execute(stmt)
|
||||
except psycopg.errors.DuplicateDatabase:
|
||||
# Another worker won the race — benign.
|
||||
log.info(
|
||||
"ensure_database_ready: database %r already "
|
||||
"created by a concurrent worker; continuing.",
|
||||
target_db,
|
||||
)
|
||||
except psycopg.errors.InsufficientPrivilege as exc:
|
||||
log.error(
|
||||
"ensure_database_ready: role lacks CREATEDB "
|
||||
"privilege to create %r. Either GRANT CREATEDB "
|
||||
"to the role, create the database manually, or "
|
||||
"set AUTO_CREATE_DB=False and provision it "
|
||||
"out-of-band. See docs/Deploying/Postgres-"
|
||||
"Migration for guidance. Underlying error: %s",
|
||||
target_db,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
except (OperationalError, ProgrammingError) as exc:
|
||||
log.error(
|
||||
"ensure_database_ready: failed to create database %r: %s. "
|
||||
"See docs/Deploying/Postgres-Migration for manual setup.",
|
||||
target_db,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
maintenance_engine.dispose()
|
||||
|
||||
|
||||
def _is_missing_database(exc: Exception) -> bool:
|
||||
"""Return True if ``exc`` indicates the target database doesn't exist.
|
||||
|
||||
We check three signals in the cause chain:
|
||||
|
||||
1. ``psycopg.errors.InvalidCatalogName`` — the canonical class for
|
||||
SQLSTATE ``3D000`` when raised during a query.
|
||||
2. ``pgcode`` / ``diag.sqlstate`` equal to ``3D000`` — defensive, for
|
||||
driver versions that surface the code on a generic class.
|
||||
3. The canonical server message phrasing ``database "..." does not
|
||||
exist`` — **required** for connection-time failures, because
|
||||
psycopg 3's ``OperationalError`` raised by ``connect()`` does NOT
|
||||
populate ``sqlstate`` (the connection never completed the protocol
|
||||
handshake, so the attributes stay ``None``). The server's error
|
||||
message itself is stable across Postgres versions, so this is a
|
||||
reliable fallback for the only case that matters: DB missing at
|
||||
boot.
|
||||
"""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
invalid_catalog = psycopg.errors.InvalidCatalogName
|
||||
except Exception: # noqa: BLE001 — defensive; never break on import
|
||||
invalid_catalog = None
|
||||
|
||||
seen: set[int] = set()
|
||||
cursor: Optional[BaseException] = exc
|
||||
while cursor is not None and id(cursor) not in seen:
|
||||
seen.add(id(cursor))
|
||||
if invalid_catalog is not None and isinstance(cursor, invalid_catalog):
|
||||
return True
|
||||
pgcode = getattr(cursor, "pgcode", None) or getattr(
|
||||
getattr(cursor, "diag", None), "sqlstate", None
|
||||
)
|
||||
if pgcode == "3D000":
|
||||
return True
|
||||
msg = str(cursor)
|
||||
if 'database "' in msg and "does not exist" in msg:
|
||||
return True
|
||||
cursor = cursor.__cause__ or cursor.__context__
|
||||
return False
|
||||
|
||||
|
||||
def _run_migrations(log: logging.Logger) -> None:
|
||||
"""Run ``alembic upgrade head`` against ``POSTGRES_URI``.
|
||||
|
||||
Alembic serializes concurrent workers via its ``alembic_version``
|
||||
table, so no extra application-level locking is needed. Failures are
|
||||
logged and re-raised so the app fails fast.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# Lazy imports — alembic pulls in a fair amount of code.
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
# Mirror the discovery path used by scripts/db/init_postgres.py so
|
||||
# both entry points resolve the same alembic.ini regardless of cwd.
|
||||
alembic_ini = Path(__file__).resolve().parents[2] / "alembic.ini"
|
||||
if not alembic_ini.exists():
|
||||
raise RuntimeError(f"alembic.ini not found at {alembic_ini}")
|
||||
|
||||
cfg = Config(str(alembic_ini))
|
||||
cfg.set_main_option("script_location", str(alembic_ini.parent / "alembic"))
|
||||
|
||||
# Cheap pre-check: if we're already at head, say so explicitly.
|
||||
try:
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
head_rev = script.get_current_head()
|
||||
url = cfg.get_main_option("sqlalchemy.url")
|
||||
# env.py populates sqlalchemy.url from settings.POSTGRES_URI when
|
||||
# it's imported, but our Config instance hasn't loaded env.py
|
||||
# yet. Fall back to reading settings directly for the precheck.
|
||||
if not url:
|
||||
from application.core.settings import settings as _settings
|
||||
|
||||
url = _settings.POSTGRES_URI
|
||||
current_rev: Optional[str] = None
|
||||
if url:
|
||||
precheck_engine = create_engine(url, pool_pre_ping=False)
|
||||
try:
|
||||
with precheck_engine.connect() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
current_rev = ctx.get_current_revision()
|
||||
finally:
|
||||
precheck_engine.dispose()
|
||||
if current_rev is not None and current_rev == head_rev:
|
||||
log.info(
|
||||
"ensure_database_ready: migrations already at head (%s); "
|
||||
"nothing to do.",
|
||||
head_rev,
|
||||
)
|
||||
return
|
||||
log.info(
|
||||
"ensure_database_ready: applying Alembic migrations "
|
||||
"(current=%s, target=%s)...",
|
||||
current_rev,
|
||||
head_rev,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — precheck is best-effort
|
||||
# If the precheck itself fails we still want to try the upgrade;
|
||||
# alembic will give a more actionable error if something's off.
|
||||
log.info(
|
||||
"ensure_database_ready: revision precheck failed (%s); "
|
||||
"proceeding with upgrade anyway.",
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception as exc: # noqa: BLE001 — surface everything
|
||||
log.error(
|
||||
"ensure_database_ready: alembic upgrade failed: %s. "
|
||||
"Check migration logs and DB connectivity; the app will not "
|
||||
"boot until this is resolved (or AUTO_MIGRATE is disabled).",
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
log.info("ensure_database_ready: migrations applied.")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""SQLAlchemy Core engine factory for the user-data Postgres database.
|
||||
|
||||
The engine is lazily constructed on first use and cached as a module-level
|
||||
singleton. Repositories and the Alembic env module both obtain connections
|
||||
through this factory, so pool tuning lives in one place.
|
||||
|
||||
``POSTGRES_URI`` can be written in any of the common Postgres URI forms::
|
||||
|
||||
postgres://user:pass@host:5432/docsgpt
|
||||
postgresql://user:pass@host:5432/docsgpt
|
||||
|
||||
Both are accepted and normalized internally to the psycopg3 dialect
|
||||
(``postgresql+psycopg://``) by ``application.core.settings``. Operators
|
||||
don't need to know about SQLAlchemy dialect prefixes.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
|
||||
from application.core.settings import settings
|
||||
|
||||
_engine: Optional[Engine] = None
|
||||
|
||||
|
||||
def _resolve_uri() -> str:
|
||||
"""Return the Postgres URI for user-data tables.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ``settings.POSTGRES_URI`` is unset. Callers that
|
||||
reach this path without a configured URI have a setup bug — the
|
||||
error message points them at the right setting.
|
||||
"""
|
||||
if not settings.POSTGRES_URI:
|
||||
raise RuntimeError(
|
||||
"POSTGRES_URI is not configured. Set it in your .env to a "
|
||||
"psycopg3 URI such as "
|
||||
"'postgresql+psycopg://user:pass@host:5432/docsgpt'."
|
||||
)
|
||||
return settings.POSTGRES_URI
|
||||
|
||||
|
||||
#: Per-statement wall-clock cap applied to every connection handed out by
|
||||
#: the engine. 30s is generous for interactive hot paths (reads under a few
|
||||
#: hundred ms are normal) but still catches a runaway query before it
|
||||
#: stacks up on PgBouncer or holds locks indefinitely.
|
||||
STATEMENT_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""Return the process-wide SQLAlchemy Engine, creating it if needed.
|
||||
|
||||
The engine applies a server-side ``statement_timeout`` to every
|
||||
connection it hands out via a ``connect`` event, so both
|
||||
:func:`db_session` and :func:`db_readonly` inherit the same
|
||||
guardrail.
|
||||
|
||||
Returns:
|
||||
A SQLAlchemy ``Engine`` configured with a pooled connection to
|
||||
Postgres via psycopg3.
|
||||
"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
_resolve_uri(),
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True, # survive PgBouncer / idle-disconnect recycles
|
||||
pool_recycle=1800,
|
||||
future=True,
|
||||
)
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _apply_session_guardrails(dbapi_conn, _record):
|
||||
# Apply as a SQL ``SET`` (not a libpq ``options=-c ...``
|
||||
# startup parameter) so the engine works behind
|
||||
# PgBouncer-style poolers — notably Neon's ``-pooler``
|
||||
# endpoint, which rejects startup options. Explicit
|
||||
# ``commit()`` so the session-level SET survives SA's
|
||||
# transaction resets on pool return.
|
||||
with dbapi_conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {STATEMENT_TIMEOUT_MS}")
|
||||
dbapi_conn.commit()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def dispose_engine() -> None:
|
||||
"""Dispose the pooled connections and reset the singleton.
|
||||
|
||||
Called from the Celery ``worker_process_init`` signal so each forked
|
||||
worker gets a fresh pool instead of sharing file descriptors with the
|
||||
parent process (which corrupts the pool on fork).
|
||||
"""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
@@ -0,0 +1,999 @@
|
||||
"""SQLAlchemy Core metadata for the user-data Postgres database.
|
||||
|
||||
Tables are added here one at a time as repositories are built during the
|
||||
MongoDB→Postgres migration. The baseline schema in the Alembic migration
|
||||
(``application/alembic/versions/0001_initial.py``) is the source of truth
|
||||
for DDL; the ``Table`` definitions below must match it column-for-column.
|
||||
If the two drift, migrations win — update this file to match.
|
||||
|
||||
Cross-table invariant not expressed in the Core ``Table`` definitions
|
||||
below: every ``user_id`` column is FK-enforced against
|
||||
``users(user_id)`` with ``ON DELETE RESTRICT``, and a
|
||||
``BEFORE INSERT OR UPDATE OF user_id`` trigger on each child table
|
||||
auto-creates the ``users`` row if it does not yet exist. See migration
|
||||
``0015_user_id_fk``. The FKs are intentionally omitted from the Core
|
||||
declarations to keep this file readable; the DB is the authority.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CHAR,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
MetaData,
|
||||
PrimaryKeyConstraint,
|
||||
UniqueConstraint,
|
||||
Table,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, CITEXT, JSONB, UUID
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
|
||||
# --- Users, prompts, tools, logs --------------------------------------------
|
||||
|
||||
users_table = Table(
|
||||
"users",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False, unique=True),
|
||||
# Populated from the OIDC email claim at login (migration 0023); used to add
|
||||
# a team member by email instead of raw sub. Nullable / backfilled on login.
|
||||
Column("email", Text),
|
||||
Column(
|
||||
"agent_preferences",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default='{"pinned": [], "shared_with_me": []}',
|
||||
),
|
||||
Column("tool_preferences", JSONB, nullable=False, server_default="{}"),
|
||||
Column("active", Boolean, nullable=False, server_default="true"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
auth_events_table = Table(
|
||||
"auth_events",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("event", Text, nullable=False),
|
||||
Column("ip", Text),
|
||||
Column("user_agent", Text),
|
||||
Column("metadata", JSONB, nullable=False, server_default="{}"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Elevated RBAC role grants. The ``user`` role is implicit (no row); only
|
||||
# admin grants are stored. ``(user_id, role, source)`` is the key so manual and
|
||||
# oidc_group grants coexist and revoke independently. ``user_id`` is the auth
|
||||
# ``sub`` (no FK/trigger, mirroring ``auth_events``). See migration 0020.
|
||||
user_roles_table = Table(
|
||||
"user_roles",
|
||||
metadata,
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("source", Text, nullable=False, server_default="manual"),
|
||||
Column("granted_by", Text),
|
||||
Column("granted_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
PrimaryKeyConstraint("user_id", "role", "source"),
|
||||
CheckConstraint("role IN ('admin')", name="user_roles_role_check"),
|
||||
CheckConstraint("source IN ('manual', 'oidc_group')", name="user_roles_source_check"),
|
||||
)
|
||||
|
||||
# --- Teams: multi-team membership, team-scoped roles, resource sharing -------
|
||||
# See migration 0021. Three additive tables; no existing table is altered.
|
||||
|
||||
# A team. ``owner_id`` is the creator's auth ``sub`` and the deletion anchor —
|
||||
# intentionally no FK/trigger (like ``user_roles``) so user deletion isn't
|
||||
# blocked by an ON DELETE RESTRICT.
|
||||
teams_table = Table(
|
||||
"teams",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("slug", CITEXT, nullable=False, unique=True),
|
||||
Column("description", Text),
|
||||
Column("owner_id", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
Index("teams_owner_idx", teams_table.c.owner_id)
|
||||
|
||||
# Membership + team-scoped role grant, field-for-field on ``user_roles``.
|
||||
# ``(team_id, user_id, role, source)`` so manual and IdP-derived grants coexist
|
||||
# and revoke independently. ``user_id`` is the auth ``sub`` (no FK/trigger).
|
||||
team_members_table = Table(
|
||||
"team_members",
|
||||
metadata,
|
||||
Column("team_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("source", Text, nullable=False, server_default="manual"),
|
||||
Column("granted_by", Text),
|
||||
Column("granted_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
PrimaryKeyConstraint("team_id", "user_id", "role", "source"),
|
||||
CheckConstraint("role IN ('team_admin', 'team_member')", name="team_members_role_check"),
|
||||
CheckConstraint(
|
||||
"source IN ('manual', 'oidc_group', 'scim')", name="team_members_source_check"
|
||||
),
|
||||
)
|
||||
|
||||
Index("team_members_user_idx", team_members_table.c.user_id)
|
||||
|
||||
# One polymorphic share table for all four shareable resource types. Sharing is
|
||||
# additive visibility, never ownership transfer. ``owner_id`` is denormalised
|
||||
# owner-at-share-time so visibility queries skip the resource-table join.
|
||||
# ``resource_id`` has no cross-table FK (polymorphic); dangling rows are scrubbed
|
||||
# by AFTER DELETE triggers on each resource table (see migration 0021).
|
||||
team_resource_grants_table = Table(
|
||||
"team_resource_grants",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("team_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("resource_type", Text, nullable=False),
|
||||
Column("resource_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("owner_id", Text, nullable=False),
|
||||
Column("access_level", Text, nullable=False, server_default="viewer"),
|
||||
# NULL = shared with the whole team; a sub = shared with that one member
|
||||
# (who must also be a team member). See migration 0022.
|
||||
Column("target_user_id", Text),
|
||||
Column("granted_by", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
CheckConstraint(
|
||||
"resource_type IN ('agent', 'source', 'prompt', 'tool')",
|
||||
name="team_resource_grants_type_check",
|
||||
),
|
||||
CheckConstraint(
|
||||
"access_level IN ('viewer', 'editor')", name="team_resource_grants_access_check"
|
||||
),
|
||||
)
|
||||
|
||||
# Functional unique dedup: a whole-team grant (target NULL → '') and each
|
||||
# per-member grant are distinct. Mirrors migration 0022.
|
||||
Index(
|
||||
"team_resource_grants_dedup_uidx",
|
||||
team_resource_grants_table.c.team_id,
|
||||
team_resource_grants_table.c.resource_type,
|
||||
team_resource_grants_table.c.resource_id,
|
||||
func.coalesce(team_resource_grants_table.c.target_user_id, ""),
|
||||
unique=True,
|
||||
)
|
||||
Index(
|
||||
"team_resource_grants_team_type_idx",
|
||||
team_resource_grants_table.c.team_id,
|
||||
team_resource_grants_table.c.resource_type,
|
||||
)
|
||||
Index(
|
||||
"team_resource_grants_resource_idx",
|
||||
team_resource_grants_table.c.resource_type,
|
||||
team_resource_grants_table.c.resource_id,
|
||||
)
|
||||
|
||||
|
||||
prompts_table = Table(
|
||||
"prompts",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
user_tools_table = Table(
|
||||
"user_tools",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("custom_name", Text),
|
||||
Column("display_name", Text),
|
||||
Column("description", Text),
|
||||
Column("config", JSONB, nullable=False, server_default="{}"),
|
||||
Column("config_requirements", JSONB, nullable=False, server_default="{}"),
|
||||
Column("actions", JSONB, nullable=False, server_default="[]"),
|
||||
Column("status", Boolean, nullable=False, server_default="true"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
token_usage_table = Table(
|
||||
"token_usage",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("user_id", Text),
|
||||
Column("api_key", Text),
|
||||
Column("agent_id", UUID(as_uuid=True)),
|
||||
Column("prompt_tokens", Integer, nullable=False, server_default="0"),
|
||||
Column("generated_tokens", Integer, nullable=False, server_default="0"),
|
||||
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# Added in ``0004_durability_foundation``. Distinguishes
|
||||
# ``agent_stream`` (primary completion) from side-channel inserts
|
||||
# (``title`` / ``compression`` / ``rag_condense`` / ``fallback``)
|
||||
# so cost attribution dashboards can group by call source.
|
||||
Column("source", Text, nullable=False, server_default="agent_stream"),
|
||||
# Added in ``0005_token_usage_request_id``. Stream-scoped UUID stamped
|
||||
# on the agent's primary LLM so multi-call agent runs (which produce
|
||||
# N rows) count as a single request via DISTINCT in the repository
|
||||
# query. NULL on side-channel sources by design.
|
||||
Column("request_id", Text),
|
||||
# Added in ``0015_token_usage_model_id``. Canonical model id (catalog
|
||||
# name for built-ins, UUID for BYOM); NULL on un-backfilled rows.
|
||||
Column("model_id", Text),
|
||||
)
|
||||
|
||||
user_logs_table = Table(
|
||||
"user_logs",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("user_id", Text),
|
||||
Column("endpoint", Text),
|
||||
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("data", JSONB),
|
||||
)
|
||||
|
||||
stack_logs_table = Table(
|
||||
"stack_logs",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("activity_id", Text, nullable=False),
|
||||
Column("endpoint", Text),
|
||||
Column("level", Text),
|
||||
Column("user_id", Text),
|
||||
Column("api_key", Text),
|
||||
Column("query", Text),
|
||||
Column("stacks", JSONB, nullable=False, server_default="[]"),
|
||||
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Singleton key/value table for instance-wide state (e.g. anonymous
|
||||
# instance UUID, one-shot notice flags). Added in migration
|
||||
# ``0002_app_metadata``.
|
||||
app_metadata_table = Table(
|
||||
"app_metadata",
|
||||
metadata,
|
||||
Column("key", Text, primary_key=True),
|
||||
Column("value", Text, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
# --- Agents, sources, attachments, artifacts --------------------------------
|
||||
|
||||
agent_folders_table = Table(
|
||||
"agent_folders",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("description", Text),
|
||||
Column("parent_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
sources_table = Table(
|
||||
"sources",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("language", Text),
|
||||
Column("date", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("model", Text),
|
||||
Column("type", Text),
|
||||
Column("metadata", JSONB, nullable=False, server_default="{}"),
|
||||
# Per-source behavior contract (SourceConfig). Separate from ``metadata``
|
||||
# (display/provenance). Empty ``{}`` parses to classic defaults.
|
||||
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("retriever", Text),
|
||||
Column("sync_frequency", Text),
|
||||
Column("tokens", Text),
|
||||
Column("file_path", Text),
|
||||
Column("remote_data", JSONB),
|
||||
Column("directory_structure", JSONB),
|
||||
Column("file_name_map", JSONB),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
agents_table = Table(
|
||||
"agents",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("description", Text),
|
||||
Column("agent_type", Text),
|
||||
Column("status", Text, nullable=False),
|
||||
Column("key", CITEXT, unique=True),
|
||||
# Stable per-user human identifier used to match an agent across
|
||||
# YAML export/import (idempotent re-import / GitOps). Uniqueness is
|
||||
# enforced by a partial unique index in migration 0019, not here, so
|
||||
# multiple agents may carry NULL.
|
||||
Column("slug", CITEXT),
|
||||
Column("image", Text),
|
||||
Column("source_id", UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL")),
|
||||
Column("extra_source_ids", ARRAY(UUID(as_uuid=True)), nullable=False, server_default="{}"),
|
||||
Column("chunks", Integer),
|
||||
Column("retriever", Text),
|
||||
Column("prompt_id", UUID(as_uuid=True), ForeignKey("prompts.id", ondelete="SET NULL")),
|
||||
Column("tools", JSONB, nullable=False, server_default="[]"),
|
||||
Column("json_schema", JSONB),
|
||||
Column("models", JSONB),
|
||||
Column("default_model_id", Text),
|
||||
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
|
||||
Column("limited_token_mode", Boolean, nullable=False, server_default="false"),
|
||||
Column("token_limit", Integer),
|
||||
Column("limited_request_mode", Boolean, nullable=False, server_default="false"),
|
||||
Column("request_limit", Integer),
|
||||
Column("allow_system_prompt_override", Boolean, nullable=False, server_default="false"),
|
||||
Column("shared", Boolean, nullable=False, server_default="false"),
|
||||
Column("shared_token", CITEXT, unique=True),
|
||||
Column("shared_metadata", JSONB),
|
||||
Column("incoming_webhook_token", CITEXT, unique=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("last_used_at", DateTime(timezone=True)),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
# Per-user uniqueness of the export/import slug. Mirrors the partial unique
|
||||
# index created in migration 0019 so a schema built from this metadata
|
||||
# (e.g. create_all) matches an Alembic-built one.
|
||||
Index(
|
||||
"ix_agents_user_slug",
|
||||
agents_table.c.user_id,
|
||||
agents_table.c.slug,
|
||||
unique=True,
|
||||
postgresql_where=agents_table.c.slug.isnot(None),
|
||||
)
|
||||
|
||||
user_custom_models_table = Table(
|
||||
"user_custom_models",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("upstream_model_id", Text, nullable=False),
|
||||
Column("display_name", Text, nullable=False),
|
||||
Column("description", Text, nullable=False, server_default=""),
|
||||
Column("base_url", Text, nullable=False),
|
||||
# AES-CBC ciphertext (base64) keyed via per-user PBKDF2 in
|
||||
# application.security.encryption.encrypt_credentials.
|
||||
Column("api_key_encrypted", Text, nullable=False),
|
||||
Column("capabilities", JSONB, nullable=False, server_default="{}"),
|
||||
Column("enabled", Boolean, nullable=False, server_default="true"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
attachments_table = Table(
|
||||
"attachments",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("filename", Text, nullable=False),
|
||||
Column("upload_path", Text, nullable=False),
|
||||
Column("mime_type", Text),
|
||||
Column("size", BigInteger),
|
||||
Column("content", Text),
|
||||
Column("token_count", Integer),
|
||||
Column("openai_file_id", Text),
|
||||
Column("google_file_uri", Text),
|
||||
Column("metadata", JSONB),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
# Identity row, one per logical artifact. The stable ``id`` is the handle passed
|
||||
# around (chat/workflow state/message bodies carry only this reference, never
|
||||
# bytes). Authz is parent-derived: whoever can reach ``conversation_id`` (chat)
|
||||
# or ``workflow_run_id`` (run) can reach its artifacts, so a CHECK requires at
|
||||
# least one parent. ``user_id`` is ownership/quota only; ``team_id`` is a
|
||||
# nullable forward-compat hook for Teams (no FK, matching ``teams`` itself).
|
||||
artifacts_table = Table(
|
||||
"artifacts",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("conversation_id", UUID(as_uuid=True)),
|
||||
Column("workflow_run_id", UUID(as_uuid=True)),
|
||||
Column("team_id", UUID(as_uuid=True)),
|
||||
Column("message_id", UUID(as_uuid=True)),
|
||||
Column("kind", Text, nullable=False),
|
||||
Column("title", Text),
|
||||
Column("metadata", JSONB),
|
||||
Column("current_version", Integer, nullable=False, server_default="1"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
CheckConstraint(
|
||||
"conversation_id IS NOT NULL OR workflow_run_id IS NOT NULL",
|
||||
name="artifacts_parent_present_check",
|
||||
),
|
||||
)
|
||||
|
||||
Index(
|
||||
"artifacts_conversation_idx",
|
||||
artifacts_table.c.conversation_id,
|
||||
postgresql_where=artifacts_table.c.conversation_id.isnot(None),
|
||||
)
|
||||
Index(
|
||||
"artifacts_workflow_run_idx",
|
||||
artifacts_table.c.workflow_run_id,
|
||||
postgresql_where=artifacts_table.c.workflow_run_id.isnot(None),
|
||||
)
|
||||
Index("artifacts_user_idx", artifacts_table.c.user_id)
|
||||
|
||||
# Append-only version history; never mutated. Each edit appends a row and bumps
|
||||
# ``artifacts.current_version``. ``UNIQUE(artifact_id, version)`` keeps versions
|
||||
# monotonic. ``storage_path`` is the ``BaseStorage`` key (NULL when spec-only);
|
||||
# bytes live in storage, only metadata + the key live here (pass-by-reference).
|
||||
artifact_versions_table = Table(
|
||||
"artifact_versions",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column(
|
||||
"artifact_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("artifacts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("version", Integer, nullable=False),
|
||||
Column("mime_type", Text),
|
||||
Column("filename", Text),
|
||||
Column("storage_path", Text),
|
||||
Column("size", BigInteger),
|
||||
Column("sha256", Text),
|
||||
Column("spec", JSONB),
|
||||
Column("preview_text", Text),
|
||||
Column("produced_by", JSONB),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("artifact_id", "version", name="artifact_versions_artifact_version_uidx"),
|
||||
)
|
||||
|
||||
Index("artifact_versions_artifact_idx", artifact_versions_table.c.artifact_id)
|
||||
|
||||
memories_table = Table(
|
||||
"memories",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
# No FK since 0009 — delete-cascade preserved by trigger.
|
||||
Column("tool_id", UUID(as_uuid=True)),
|
||||
Column("path", Text, nullable=False),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("user_id", "tool_id", "path", name="memories_user_tool_path_uidx"),
|
||||
)
|
||||
|
||||
# Authoritative storage for an LLM-editable wiki source (config.kind="wiki").
|
||||
# Source-scoped (team-shareable) unlike per-user ``memories``; the vector store
|
||||
# is a derived index re-embedded per page (``embed_status`` tracks freshness).
|
||||
wiki_pages_table = Table(
|
||||
"wiki_pages",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("source_id", UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("path", Text, nullable=False),
|
||||
Column("title", Text),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("token_count", Integer),
|
||||
Column("version", Integer, nullable=False, server_default="1"),
|
||||
Column("content_hash", Text),
|
||||
Column("embed_status", Text, nullable=False, server_default="pending"),
|
||||
Column("updated_by", Text),
|
||||
Column("updated_via", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("source_id", "path", name="wiki_pages_source_path_uidx"),
|
||||
)
|
||||
|
||||
Index(
|
||||
"wiki_pages_source_path_prefix_idx",
|
||||
wiki_pages_table.c.source_id,
|
||||
wiki_pages_table.c.path,
|
||||
postgresql_ops={"path": "text_pattern_ops"},
|
||||
)
|
||||
|
||||
todos_table = Table(
|
||||
"todos",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("tool_id", UUID(as_uuid=True), ForeignKey("user_tools.id", ondelete="CASCADE")),
|
||||
Column("todo_id", Integer),
|
||||
Column("title", Text, nullable=False),
|
||||
Column("completed", Boolean, nullable=False, server_default="false"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
notes_table = Table(
|
||||
"notes",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("tool_id", UUID(as_uuid=True), ForeignKey("user_tools.id", ondelete="CASCADE")),
|
||||
Column("title", Text, nullable=False),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("user_id", "tool_id", name="notes_user_tool_uidx"),
|
||||
)
|
||||
|
||||
connector_sessions_table = Table(
|
||||
"connector_sessions",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("provider", Text, nullable=False),
|
||||
Column("server_url", Text),
|
||||
Column("session_token", Text, unique=True),
|
||||
Column("user_email", Text),
|
||||
Column("status", Text),
|
||||
Column("token_info", JSONB),
|
||||
Column("session_data", JSONB, nullable=False, server_default="{}"),
|
||||
Column("expires_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
|
||||
# --- Conversations, messages, workflows -------------------------------------
|
||||
|
||||
conversations_table = Table(
|
||||
"conversations",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("agent_id", UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL")),
|
||||
Column("name", Text),
|
||||
Column("api_key", Text),
|
||||
Column("is_shared_usage", Boolean, nullable=False, server_default="false"),
|
||||
Column("shared_token", Text),
|
||||
Column("shared_with", ARRAY(Text), nullable=False, server_default="{}"),
|
||||
# "listed" shows in the owner's sidebar; "hidden" persists silently
|
||||
# (agent/API/OpenAI-compat traffic). See migration 0016.
|
||||
Column("visibility", Text, nullable=False, server_default="listed"),
|
||||
Column("compression_metadata", JSONB),
|
||||
Column("date", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
conversation_messages_table = Table(
|
||||
"conversation_messages",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("conversation_id", UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False),
|
||||
# Denormalised from conversations.user_id. Auto-filled on insert by a
|
||||
# BEFORE INSERT trigger when the caller omits it. See migration 0020.
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("position", Integer, nullable=False),
|
||||
Column("prompt", Text),
|
||||
Column("response", Text),
|
||||
Column("thought", Text),
|
||||
Column("sources", JSONB, nullable=False, server_default="[]"),
|
||||
Column("tool_calls", JSONB, nullable=False, server_default="[]"),
|
||||
# Postgres cannot FK-enforce array elements, so the referential
|
||||
# invariant is kept by an AFTER DELETE trigger on ``attachments``
|
||||
# that array_removes the id from every row that references it.
|
||||
# See migration 0017_cleanup_dangling_refs.
|
||||
Column("attachments", ARRAY(UUID(as_uuid=True)), nullable=False, server_default="{}"),
|
||||
Column("model_id", Text),
|
||||
# Renamed from ``metadata`` in migration 0016 to avoid SQLAlchemy's
|
||||
# reserved attribute collision on declarative models. The repository
|
||||
# translates this ↔ API dict key ``metadata`` so external callers
|
||||
# still see ``metadata``.
|
||||
Column("message_metadata", JSONB, nullable=False, server_default="{}"),
|
||||
Column("feedback", JSONB),
|
||||
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# Added in 0004_durability_foundation. ``status`` is the WAL state
|
||||
# machine (pending|streaming|complete|failed); ``request_id`` ties a
|
||||
# row to a specific HTTP request for log correlation.
|
||||
Column("status", Text, nullable=False, server_default="complete"),
|
||||
Column("request_id", Text),
|
||||
UniqueConstraint("conversation_id", "position", name="conversation_messages_conv_pos_uidx"),
|
||||
)
|
||||
|
||||
# Per-yield journal of chat-stream events, used by the snapshot+tail
|
||||
# reconnect: the route's GET reconnect endpoint reads
|
||||
# ``WHERE message_id = ? AND sequence_no > ?`` from this table before
|
||||
# tailing the live ``channel:{message_id}`` pub/sub. See
|
||||
# ``application/streaming/event_replay.py`` and migration 0007.
|
||||
message_events_table = Table(
|
||||
"message_events",
|
||||
metadata,
|
||||
# PK is the composite ``(message_id, sequence_no)`` — it doubles as
|
||||
# the snapshot read index (covering range scan on
|
||||
# ``WHERE message_id = ? AND sequence_no > ?``).
|
||||
Column(
|
||||
"message_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conversation_messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
# Strictly monotonic per ``message_id``. Allocated by the route as it
|
||||
# yields, so the writer is single-threaded for the lifetime of one
|
||||
# stream — no contention, no SERIAL needed.
|
||||
Column("sequence_no", Integer, primary_key=True, nullable=False),
|
||||
Column("event_type", Text, nullable=False),
|
||||
Column("payload", JSONB, nullable=False, server_default="{}"),
|
||||
Column(
|
||||
"created_at", DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
shared_conversations_table = Table(
|
||||
"shared_conversations",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("uuid", UUID(as_uuid=True), nullable=False, unique=True),
|
||||
Column("conversation_id", UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("prompt_id", UUID(as_uuid=True), ForeignKey("prompts.id", ondelete="SET NULL")),
|
||||
Column("chunks", Integer),
|
||||
Column("is_promptable", Boolean, nullable=False, server_default="false"),
|
||||
Column("first_n_queries", Integer, nullable=False, server_default="0"),
|
||||
Column("api_key", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
pending_tool_state_table = Table(
|
||||
"pending_tool_state",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("conversation_id", UUID(as_uuid=True), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("messages", JSONB, nullable=False),
|
||||
Column("pending_tool_calls", JSONB, nullable=False),
|
||||
Column("tools_dict", JSONB, nullable=False),
|
||||
Column("tool_schemas", JSONB, nullable=False),
|
||||
Column("agent_config", JSONB, nullable=False),
|
||||
Column("client_tools", JSONB),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("expires_at", DateTime(timezone=True), nullable=False),
|
||||
# Added in ``0004_durability_foundation``. ``status`` is the
|
||||
# ``pending|resuming`` claim flag for the resumed-run path;
|
||||
# ``resumed_at`` stamps when ``mark_resuming`` flipped the row so
|
||||
# the cleanup janitor can revert stale claims after the grace
|
||||
# window.
|
||||
Column("status", Text, nullable=False, server_default="pending"),
|
||||
Column("resumed_at", DateTime(timezone=True)),
|
||||
UniqueConstraint("conversation_id", "user_id", name="pending_tool_state_conv_user_uidx"),
|
||||
)
|
||||
|
||||
|
||||
# --- Durability foundation (idempotency / journals, migration 0004) ---------
|
||||
# CHECK constraints (status enums) and partial indexes are intentionally
|
||||
# omitted from these declarations — the DB is the authority. Repositories
|
||||
# use raw ``text(...)`` SQL against these tables, not the Core objects.
|
||||
|
||||
task_dedup_table = Table(
|
||||
"task_dedup",
|
||||
metadata,
|
||||
Column("idempotency_key", Text, primary_key=True),
|
||||
Column("task_name", Text, nullable=False),
|
||||
Column("task_id", Text, nullable=False),
|
||||
Column("result_json", JSONB),
|
||||
# CHECK (status IN ('pending', 'completed', 'failed')) lives in 0004.
|
||||
Column("status", Text, nullable=False),
|
||||
# Bumped each time the per-Celery-task wrapper re-enters; the
|
||||
# poison-loop guard (``MAX_TASK_ATTEMPTS=5``) refuses to run fn once
|
||||
# this exceeds the threshold.
|
||||
Column("attempt_count", Integer, nullable=False, server_default="0"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# Added in ``0006_idempotency_lease``. Per-invocation random id
|
||||
# written by the wrapper at lease claim; refreshed every 30 s by a
|
||||
# heartbeat thread. Other workers seeing a fresh lease (NOT NULL
|
||||
# AND ``lease_expires_at > now()``) refuse to run the task body.
|
||||
Column("lease_owner_id", Text),
|
||||
Column("lease_expires_at", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
webhook_dedup_table = Table(
|
||||
"webhook_dedup",
|
||||
metadata,
|
||||
Column("idempotency_key", Text, primary_key=True),
|
||||
Column("agent_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("task_id", Text, nullable=False),
|
||||
Column("response_json", JSONB),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Three-phase tool-call journal: ``proposed → executed → confirmed``
|
||||
# (terminal: ``failed``; ``compensated`` is grandfathered in the CHECK
|
||||
# from migration 0004 but no code writes it). The reconciler sweeps
|
||||
# stuck rows via the partial ``tool_call_attempts_pending_ts_idx``.
|
||||
tool_call_attempts_table = Table(
|
||||
"tool_call_attempts",
|
||||
metadata,
|
||||
Column("call_id", Text, primary_key=True),
|
||||
# ON DELETE SET NULL preserves the journal even after the parent
|
||||
# message is deleted — useful for cost-attribution / compliance.
|
||||
Column(
|
||||
"message_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conversation_messages.id", ondelete="SET NULL"),
|
||||
),
|
||||
Column("tool_id", UUID(as_uuid=True)),
|
||||
# Direct attribution (0018): headless runs (scheduled / webhook) and
|
||||
# parse-failure rows never get a message_id, so user/agent are stamped
|
||||
# at propose time instead of derived through the parent message.
|
||||
Column("user_id", Text),
|
||||
Column("agent_id", UUID(as_uuid=True)),
|
||||
Column("tool_name", Text, nullable=False),
|
||||
Column("action_name", Text, nullable=False),
|
||||
Column("arguments", JSONB, nullable=False),
|
||||
Column("result", JSONB),
|
||||
Column("error", Text),
|
||||
# CHECK (status IN ('proposed', 'executed', 'confirmed',
|
||||
# 'compensated', 'failed')) lives in 0004.
|
||||
Column("status", Text, nullable=False),
|
||||
Column("attempted_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Per-source ingest checkpoint. Heartbeat thread bumps ``last_updated``
|
||||
# every 30s while a worker embeds; the reconciler escalates when it
|
||||
# stops ticking.
|
||||
ingest_chunk_progress_table = Table(
|
||||
"ingest_chunk_progress",
|
||||
metadata,
|
||||
Column("source_id", UUID(as_uuid=True), primary_key=True),
|
||||
Column("total_chunks", Integer, nullable=False),
|
||||
Column("embedded_chunks", Integer, nullable=False, server_default="0"),
|
||||
Column("last_index", Integer, nullable=False, server_default="-1"),
|
||||
Column("last_updated", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# Added in ``0005_ingest_attempt_id``. Stamped from
|
||||
# ``self.request.id`` (Celery's stable task id) so a retry of the
|
||||
# same task resumes from the checkpoint, but a separate invocation
|
||||
# (manual reingest, scheduled sync) resets to a clean re-index.
|
||||
Column("attempt_id", Text),
|
||||
# Added in ``0008_ingest_progress_status``. The reconciler flips
|
||||
# this to 'stalled'; ``init_progress`` resets it to 'active'.
|
||||
Column("status", Text, nullable=False, server_default="active"),
|
||||
)
|
||||
|
||||
|
||||
workflows_table = Table(
|
||||
"workflows",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("description", Text),
|
||||
Column("current_graph_version", Integer, nullable=False, server_default="1"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
workflow_nodes_table = Table(
|
||||
"workflow_nodes",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("graph_version", Integer, nullable=False),
|
||||
Column("node_id", Text, nullable=False),
|
||||
Column("node_type", Text, nullable=False),
|
||||
Column("title", Text),
|
||||
Column("description", Text),
|
||||
Column("position", JSONB, nullable=False, server_default='{"x": 0, "y": 0}'),
|
||||
Column("config", JSONB, nullable=False, server_default="{}"),
|
||||
Column("legacy_mongo_id", Text),
|
||||
# Composite UNIQUE so workflow_edges can use a composite FK that
|
||||
# enforces endpoint nodes belong to the same (workflow, version) as
|
||||
# the edge itself. See migration 0008.
|
||||
UniqueConstraint(
|
||||
"id", "workflow_id", "graph_version",
|
||||
name="workflow_nodes_id_wf_ver_key",
|
||||
),
|
||||
)
|
||||
|
||||
workflow_edges_table = Table(
|
||||
"workflow_edges",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("graph_version", Integer, nullable=False),
|
||||
Column("edge_id", Text, nullable=False),
|
||||
Column("from_node_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("to_node_id", UUID(as_uuid=True), nullable=False),
|
||||
Column("source_handle", Text),
|
||||
Column("target_handle", Text),
|
||||
Column("config", JSONB, nullable=False, server_default="{}"),
|
||||
# Composite FKs: endpoints must belong to the same (workflow, version)
|
||||
# as the edge. Prevents cross-workflow / cross-version edges that the
|
||||
# single-column FKs couldn't catch. See migration 0008.
|
||||
ForeignKeyConstraint(
|
||||
["from_node_id", "workflow_id", "graph_version"],
|
||||
["workflow_nodes.id", "workflow_nodes.workflow_id", "workflow_nodes.graph_version"],
|
||||
ondelete="CASCADE",
|
||||
name="workflow_edges_from_node_fk",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["to_node_id", "workflow_id", "graph_version"],
|
||||
["workflow_nodes.id", "workflow_nodes.workflow_id", "workflow_nodes.graph_version"],
|
||||
ondelete="CASCADE",
|
||||
name="workflow_edges_to_node_fk",
|
||||
),
|
||||
)
|
||||
|
||||
workflow_runs_table = Table(
|
||||
"workflow_runs",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("status", Text, nullable=False),
|
||||
Column("inputs", JSONB),
|
||||
Column("result", JSONB),
|
||||
Column("steps", JSONB, nullable=False, server_default="[]"),
|
||||
Column("started_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("ended_at", DateTime(timezone=True)),
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
|
||||
# --- Scheduler (migration 0010) ---------------------------------------------
|
||||
|
||||
schedules_table = Table(
|
||||
"schedules",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column("user_id", Text, nullable=False),
|
||||
# Nullable as of 0011: agentless chats create one-time schedules whose
|
||||
# run is built ephemerally at fire time from system defaults.
|
||||
Column(
|
||||
"agent_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
),
|
||||
Column("trigger_type", Text, nullable=False),
|
||||
Column("name", Text),
|
||||
Column("instruction", Text, nullable=False),
|
||||
Column("status", Text, nullable=False, server_default="active"),
|
||||
Column("cron", Text),
|
||||
Column("run_at", DateTime(timezone=True)),
|
||||
Column("timezone", Text, nullable=False, server_default="UTC"),
|
||||
Column("next_run_at", DateTime(timezone=True)),
|
||||
Column("last_run_at", DateTime(timezone=True)),
|
||||
Column("end_at", DateTime(timezone=True)),
|
||||
Column("tool_allowlist", JSONB, nullable=False, server_default="[]"),
|
||||
Column("model_id", Text),
|
||||
Column("token_budget", Integer),
|
||||
Column(
|
||||
"origin_conversation_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
),
|
||||
Column("created_via", Text, nullable=False, server_default="ui"),
|
||||
Column("consecutive_failure_count", Integer, nullable=False, server_default="0"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
schedule_runs_table = Table(
|
||||
"schedule_runs",
|
||||
metadata,
|
||||
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
|
||||
Column(
|
||||
"schedule_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("schedules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("user_id", Text, nullable=False),
|
||||
# Nullable as of 0011 (mirrors ``schedules.agent_id``); FK CASCADE
|
||||
# established in 0010 to match the direct ``agents`` reference.
|
||||
Column(
|
||||
"agent_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
),
|
||||
Column("status", Text, nullable=False, server_default="pending"),
|
||||
Column("scheduled_for", DateTime(timezone=True), nullable=False),
|
||||
Column("trigger_source", Text, nullable=False, server_default="cron"),
|
||||
Column("started_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
Column("output", Text),
|
||||
Column("output_truncated", Boolean, nullable=False, server_default="false"),
|
||||
Column("error", Text),
|
||||
Column("error_type", Text),
|
||||
Column("prompt_tokens", Integer, nullable=False, server_default="0"),
|
||||
Column("generated_tokens", Integer, nullable=False, server_default="0"),
|
||||
Column("conversation_id", UUID(as_uuid=True)),
|
||||
Column("message_id", UUID(as_uuid=True)),
|
||||
Column("celery_task_id", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("schedule_id", "scheduled_for", name="schedule_runs_dedup_uidx"),
|
||||
)
|
||||
|
||||
|
||||
# --- Remote devices (migration 0012) ----------------------------------------
|
||||
|
||||
devices_table = Table(
|
||||
"devices",
|
||||
metadata,
|
||||
Column("id", Text, primary_key=True),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("hostname", Text),
|
||||
Column("os", Text),
|
||||
Column("arch", Text),
|
||||
Column("cli_version", Text),
|
||||
Column("machine_pubkey_fingerprint", Text, nullable=False),
|
||||
Column("token_hash", Text, nullable=False),
|
||||
# CHECK (approval_mode IN ('ask', 'full')) lives in 0013.
|
||||
Column("approval_mode", Text, nullable=False, server_default="ask"),
|
||||
Column("description", Text),
|
||||
Column("status", Text, nullable=False, server_default="active"),
|
||||
Column("paired_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("last_seen_at", DateTime(timezone=True)),
|
||||
Column("revoked_at", DateTime(timezone=True)),
|
||||
Column("revoke_reason", Text),
|
||||
UniqueConstraint("user_id", "name", name="devices_user_name_uidx"),
|
||||
)
|
||||
|
||||
device_audit_log_table = Table(
|
||||
"device_audit_log",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("device_id", Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("agent_id", Text),
|
||||
Column("conversation_id", Text),
|
||||
Column("invocation_id", Text, nullable=False),
|
||||
Column("action", Text, nullable=False),
|
||||
Column("command", Text, nullable=False),
|
||||
Column("working_dir", Text),
|
||||
Column("approval_mode", Text, nullable=False),
|
||||
Column("decision", Text, nullable=False),
|
||||
Column("decision_reason", Text),
|
||||
Column("issued_at", DateTime(timezone=True), nullable=False),
|
||||
Column("started_at", DateTime(timezone=True)),
|
||||
Column("finished_at", DateTime(timezone=True)),
|
||||
Column("exit_code", Integer),
|
||||
Column("duration_ms", Integer),
|
||||
Column("stdout_sha256", CHAR(64)),
|
||||
Column("stderr_sha256", CHAR(64)),
|
||||
Column("stdout_bytes", Integer),
|
||||
Column("stderr_bytes", Integer),
|
||||
Column("error", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
device_auto_approve_patterns_table = Table(
|
||||
"device_auto_approve_patterns",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("device_id", Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("pattern", Text, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("device_id", "user_id", "pattern", name="device_auto_approve_uidx"),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Secret redaction for reflected ``stack_logs`` data.
|
||||
|
||||
``stacks`` is built by reflecting every public attribute of runtime
|
||||
objects (the ``llm`` component carries the deployment provider
|
||||
``api_key`` and the caller's ``user_api_key``). The unified-logs endpoint
|
||||
returns ``stacks`` to the client, so credentials must be scrubbed — at
|
||||
write time for new rows, and at read time for rows written before
|
||||
redaction existed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
# Substrings marking a key as a credential. Compound token-count fields
|
||||
# (``prompt_tokens`` / ``generated_tokens`` / ``token_budget`` / ...) are
|
||||
# intentionally not matched: secret token forms are spelled out
|
||||
# (``access_token`` etc.) and a bare ``token`` is handled separately.
|
||||
_SECRET_SUBSTRINGS = (
|
||||
"api_key",
|
||||
"apikey",
|
||||
"api_token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"auth_token",
|
||||
"id_token",
|
||||
"session_token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"passphrase",
|
||||
"private_key",
|
||||
"credential",
|
||||
"authorization",
|
||||
"bearer",
|
||||
)
|
||||
|
||||
|
||||
def is_secret_key(key: str) -> bool:
|
||||
"""True when ``key`` names a credential that must not be persisted/returned."""
|
||||
k = key.lower()
|
||||
if k == "token":
|
||||
return True
|
||||
return any(s in k for s in _SECRET_SUBSTRINGS)
|
||||
|
||||
|
||||
def redact_secrets(obj):
|
||||
"""Recursively replace secret-keyed values with ``[REDACTED]``.
|
||||
|
||||
Walks dicts and lists; leaf scalars pass through unchanged. ``None``
|
||||
returns ``None`` so callers can redact an optional payload directly.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: (
|
||||
REDACTED
|
||||
if isinstance(k, str) and is_secret_key(k)
|
||||
else redact_secrets(v)
|
||||
)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [redact_secrets(v) for v in obj]
|
||||
return obj
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Repositories for the user-data Postgres database.
|
||||
|
||||
Each module in this package exposes exactly one repository class. Repository
|
||||
methods take a ``Connection`` (either as a constructor argument or as a
|
||||
method argument) and return plain ``dict`` rows via
|
||||
``application.storage.db.base_repository.row_to_dict`` during the
|
||||
MongoDB→Postgres cutover, so call sites don't have to change shape.
|
||||
|
||||
Repositories are added one collection at a time, matching the phased
|
||||
rollout in ``migration-postgres.md``.
|
||||
"""
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Read-only cross-table aggregations for the admin dashboard.
|
||||
|
||||
Unlike the per-table repositories, this one answers operator-level questions
|
||||
that span tables (counts, engagement, spend). All methods are read-only and
|
||||
time-bounded where they touch large tables, so they are safe to call from an
|
||||
admin-gated dashboard but should not be put on any hot path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class AdminStatsRepository:
|
||||
"""Global aggregates for the admin overview and per-user drill-down."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def _scalar(self, sql: str, params: dict | None = None) -> int:
|
||||
return int(self._conn.execute(text(sql), params or {}).scalar() or 0)
|
||||
|
||||
def overview(self) -> dict:
|
||||
"""Top-line counts + recent-window engagement for the dashboard home."""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_7d = now - timedelta(days=7)
|
||||
cutoff_30d = now - timedelta(days=30)
|
||||
|
||||
users_total = self._scalar("SELECT count(*) FROM users")
|
||||
users_active = self._scalar("SELECT count(*) FROM users WHERE active")
|
||||
return {
|
||||
"users": {
|
||||
"total": users_total,
|
||||
"active": users_active,
|
||||
"inactive": users_total - users_active,
|
||||
},
|
||||
"admins": self._scalar(
|
||||
"SELECT count(DISTINCT user_id) FROM user_roles WHERE role = 'admin'"
|
||||
),
|
||||
"agents": self._scalar("SELECT count(*) FROM agents"),
|
||||
"sources": self._scalar("SELECT count(*) FROM sources"),
|
||||
"conversations": self._scalar("SELECT count(*) FROM conversations"),
|
||||
"new_users_7d": self._scalar(
|
||||
"SELECT count(*) FROM users WHERE created_at >= :c", {"c": cutoff_7d}
|
||||
),
|
||||
"active_users_30d": self._scalar(
|
||||
"SELECT count(DISTINCT user_id) FROM token_usage "
|
||||
"WHERE timestamp >= :c AND user_id IS NOT NULL",
|
||||
{"c": cutoff_30d},
|
||||
),
|
||||
"failed_logins_7d": self._scalar(
|
||||
"SELECT count(*) FROM auth_events "
|
||||
"WHERE event = 'oidc_login_denied' AND created_at >= :c",
|
||||
{"c": cutoff_7d},
|
||||
),
|
||||
"tokens_30d": self._scalar(
|
||||
"SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) "
|
||||
"FROM token_usage WHERE timestamp >= :c",
|
||||
{"c": cutoff_30d},
|
||||
),
|
||||
}
|
||||
|
||||
def top_token_users(self, *, since: datetime, limit: int = 10) -> list[dict]:
|
||||
"""Highest token consumers since ``since`` (admin usage view)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id, SUM(prompt_tokens + generated_tokens) AS tokens
|
||||
FROM token_usage
|
||||
WHERE timestamp >= :since AND user_id IS NOT NULL
|
||||
GROUP BY user_id
|
||||
ORDER BY tokens DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"since": since, "limit": int(limit)},
|
||||
)
|
||||
return [{"user_id": r[0], "tokens": int(r[1])} for r in result.fetchall()]
|
||||
|
||||
def list_users(
|
||||
self, user_id_filter: Optional[str], offset: int, limit: int
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""Paginated users with their last auth-event time, most-recently-seen first.
|
||||
|
||||
``last_seen`` is max(auth_events.created_at) for the user (NULL = never).
|
||||
Ordering surfaces active accounts first, dormant ones last — the natural
|
||||
triage order for an operator.
|
||||
"""
|
||||
where = ""
|
||||
count_params: dict = {}
|
||||
if user_id_filter is not None:
|
||||
where = "WHERE u.user_id ILIKE '%' || :uid || '%'"
|
||||
count_params["uid"] = user_id_filter
|
||||
total = self._conn.execute(
|
||||
text(f"SELECT count(*) FROM users u {where}"), count_params
|
||||
).scalar_one()
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT u.user_id, u.active, u.created_at,
|
||||
max(ae.created_at) AS last_seen
|
||||
FROM users u
|
||||
LEFT JOIN auth_events ae ON ae.user_id = u.user_id
|
||||
{where}
|
||||
GROUP BY u.user_id, u.active, u.created_at
|
||||
ORDER BY max(ae.created_at) DESC NULLS LAST, u.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{**count_params, "limit": int(limit), "offset": int(offset)},
|
||||
)
|
||||
return int(total), [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
def user_counts(self, user_id: str) -> dict:
|
||||
"""Per-user resource counts for the drill-down view."""
|
||||
cutoff_30d = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
return {
|
||||
"agents": self._scalar(
|
||||
"SELECT count(*) FROM agents WHERE user_id = :u", {"u": user_id}
|
||||
),
|
||||
"sources": self._scalar(
|
||||
"SELECT count(*) FROM sources WHERE user_id = :u", {"u": user_id}
|
||||
),
|
||||
"conversations": self._scalar(
|
||||
"SELECT count(*) FROM conversations WHERE user_id = :u", {"u": user_id}
|
||||
),
|
||||
"tokens_30d": self._scalar(
|
||||
"SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) "
|
||||
"FROM token_usage WHERE user_id = :u AND timestamp >= :c",
|
||||
{"u": user_id, "c": cutoff_30d},
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Repository for the ``agent_folders`` table.
|
||||
|
||||
Folders are self-referential via ``parent_id`` to model nested folder
|
||||
hierarchies — a folder can sit inside another folder, and on delete the
|
||||
DB sets each child's ``parent_id`` to NULL (no cascade) so children
|
||||
survive their parent's removal but flatten to the top level. The legacy
|
||||
Mongo route used ``$unset: {parent_id: ""}`` against children before
|
||||
deleting the parent; that pre-step is no longer needed because the FK
|
||||
``ON DELETE SET NULL`` action does it automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, func, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.models import agent_folders_table
|
||||
|
||||
|
||||
_ALLOWED_UPDATE_COLUMNS = {"name", "description", "parent_id"}
|
||||
|
||||
|
||||
class AgentFoldersRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO agent_folders (
|
||||
user_id, name, description, parent_id, legacy_mongo_id
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :name, :description,
|
||||
CAST(:parent_id AS uuid), :legacy_mongo_id
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parent_id": str(parent_id) if parent_id else None,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, folder_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agent_folders WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": folder_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM agent_folders WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agent_folders WHERE user_id = :user_id ORDER BY created_at"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_children(self, parent_id: str, user_id: str) -> list[dict]:
|
||||
"""List immediate children of ``parent_id`` for nested-folder UIs."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM agent_folders "
|
||||
"WHERE parent_id = CAST(:parent_id AS uuid) AND user_id = :user_id "
|
||||
"ORDER BY created_at"
|
||||
),
|
||||
{"parent_id": parent_id, "user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, folder_id: str, user_id: str, fields: dict[str, Any]) -> bool:
|
||||
"""Partial update.
|
||||
|
||||
The route validates that ``parent_id != folder_id`` (no self-parenting)
|
||||
before calling here; this layer does not re-check.
|
||||
"""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATE_COLUMNS}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col == "parent_id":
|
||||
values[col] = str(val) if val else None
|
||||
else:
|
||||
values[col] = val
|
||||
values["updated_at"] = func.now()
|
||||
|
||||
t = agent_folders_table
|
||||
stmt = (
|
||||
t.update()
|
||||
.where(t.c.id == folder_id)
|
||||
.where(t.c.user_id == user_id)
|
||||
.values(**values)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, folder_id: str, user_id: str) -> bool:
|
||||
"""Delete a folder.
|
||||
|
||||
The schema's ``ON DELETE SET NULL`` on the self-FK takes care of
|
||||
un-parenting any child folders, and the agents table's
|
||||
``folder_id`` FK does the same for agents in the folder.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM agent_folders WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": folder_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Repository for the ``agents`` table.
|
||||
|
||||
Covers every write operation the legacy Mongo code performs on ``agents_collection``:
|
||||
|
||||
- create, update, delete
|
||||
- find by key (API key lookup)
|
||||
- find by webhook token
|
||||
- list for user, list templates
|
||||
- folder assignment
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, cast, func, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
from application.storage.db.models import agents_table
|
||||
|
||||
|
||||
class AgentsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _normalize_unique_text(col: str, val):
|
||||
"""Coerce blank strings for nullable unique text columns to NULL."""
|
||||
if col in ("key", "slug") and val == "":
|
||||
return None
|
||||
return val
|
||||
|
||||
def create(self, user_id: str, name: str, status: str, **kwargs) -> dict:
|
||||
values: dict = {"user_id": user_id, "name": name, "status": status}
|
||||
|
||||
_ALLOWED = {
|
||||
"description", "agent_type", "key", "slug", "retriever",
|
||||
"default_model_id", "incoming_webhook_token",
|
||||
"source_id", "prompt_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"chunks", "token_limit", "request_limit",
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"allow_system_prompt_override",
|
||||
"shared", "shared_token", "shared_metadata",
|
||||
"tools", "json_schema", "models", "legacy_mongo_id",
|
||||
"created_at", "updated_at", "last_used_at",
|
||||
}
|
||||
|
||||
for col, val in kwargs.items():
|
||||
if col not in _ALLOWED or val is None:
|
||||
continue
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata"):
|
||||
# JSONB columns: pass the Python object directly. SQLAlchemy
|
||||
# Core's JSONB type processor json.dumps it once during
|
||||
# bind; pre-serialising would double-encode and the value
|
||||
# would round-trip as a JSON string instead of the dict.
|
||||
values[col] = val
|
||||
elif col in ("chunks", "token_limit", "request_limit"):
|
||||
values[col] = int(val)
|
||||
elif col in (
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"shared", "allow_system_prompt_override",
|
||||
):
|
||||
values[col] = bool(val)
|
||||
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
|
||||
values[col] = str(val)
|
||||
elif col == "extra_source_ids":
|
||||
# ARRAY(UUID) — pass list of strings; psycopg adapts it.
|
||||
values[col] = [str(x) for x in val] if val else []
|
||||
else:
|
||||
values[col] = self._normalize_unique_text(col, val)
|
||||
|
||||
stmt = pg_insert(agents_table).values(**values).returning(agents_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, agent_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": agent_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, agent_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve an agent by either PG UUID or legacy Mongo ObjectId string.
|
||||
|
||||
Cutover helper: URLs / bookmarks / old client state may still hold
|
||||
Mongo ObjectId-strings. Try the UUID path first (the post-cutover
|
||||
shape) and fall back to ``legacy_mongo_id`` — both are scoped by
|
||||
``user_id`` so cross-user access is impossible.
|
||||
"""
|
||||
if looks_like_uuid(agent_id):
|
||||
row = self.get(agent_id, user_id)
|
||||
if row is not None:
|
||||
return row
|
||||
return self.get_by_legacy_id(agent_id, user_id)
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str, user_id: str | None = None) -> Optional[dict]:
|
||||
"""Fetch an agent by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM agents WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_by_key(self, key: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE key = :key"),
|
||||
{"key": key},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_by_shared_token(self, token: str) -> Optional[dict]:
|
||||
"""Resolve a publicly-shared agent by its rotating share token.
|
||||
|
||||
Only returns rows with ``shared = true`` so revoking a share
|
||||
(setting ``shared = false``) immediately stops token access even
|
||||
if the token value itself is still in the row.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM agents "
|
||||
"WHERE shared_token = :token AND shared = true"
|
||||
),
|
||||
{"token": token},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_by_webhook_token(self, token: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE incoming_webhook_token = :token"),
|
||||
{"token": token},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_by_slug(self, user_id: str, slug: str) -> Optional[dict]:
|
||||
"""Resolve one of a user's agents by its stable export/import slug."""
|
||||
if not slug:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE user_id = :user_id AND slug = :slug"),
|
||||
{"user_id": user_id, "slug": slug},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE user_id = :user_id ORDER BY created_at DESC"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def get_by_id(self, agent_id: str) -> Optional[dict]:
|
||||
"""Fetch an agent by id with NO ownership scoping.
|
||||
|
||||
Used ONLY after a team-grant authorization check has already confirmed
|
||||
the caller may see this agent (see team sharing). Never call this on a
|
||||
raw user-supplied id without that check — it bypasses the dual-key guard.
|
||||
"""
|
||||
if not looks_like_uuid(agent_id):
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": agent_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_by_ids(self, agent_ids) -> list[dict]:
|
||||
"""Fetch agents whose id is in ``agent_ids`` (team-shared listing path)."""
|
||||
ids = [str(a) for a in agent_ids if looks_like_uuid(str(a))]
|
||||
if not ids:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM agents WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"),
|
||||
{"ids": ids},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update_by_id(
|
||||
self, agent_id: str, fields: dict, expected_updated_at=None
|
||||
) -> Optional[bool]:
|
||||
"""Update an agent by id WITHOUT user scoping (team ``editor`` write path).
|
||||
|
||||
The caller MUST have verified the principal holds an ``editor`` grant
|
||||
before calling. When ``expected_updated_at`` is given, the update is
|
||||
applied only if the row's ``updated_at`` still matches (optimistic lock);
|
||||
returns None on a version mismatch so the route can answer 409.
|
||||
"""
|
||||
# Reuse the owner-path column allowlist + coercion by delegating, but
|
||||
# against the id-only predicate. We rebuild the same filtered values.
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
"limited_request_mode", "request_limit",
|
||||
"allow_system_prompt_override",
|
||||
"shared", "shared_token", "shared_metadata",
|
||||
"incoming_webhook_token", "last_used_at",
|
||||
}
|
||||
filtered = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not filtered:
|
||||
return False
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata"):
|
||||
values[col] = val
|
||||
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
|
||||
values[col] = str(val) if val else None
|
||||
elif col == "extra_source_ids":
|
||||
values[col] = [str(x) for x in val] if val else []
|
||||
elif col in (
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"shared", "allow_system_prompt_override",
|
||||
):
|
||||
values[col] = bool(val)
|
||||
else:
|
||||
values[col] = self._normalize_unique_text(col, val)
|
||||
values["updated_at"] = func.now()
|
||||
|
||||
t = agents_table
|
||||
stmt = t.update().where(t.c.id == agent_id)
|
||||
if expected_updated_at is not None:
|
||||
# Cast the (string/datetime) bind to timestamptz so PG compares the
|
||||
# optimistic-lock token against the column type, not text.
|
||||
stmt = stmt.where(t.c.updated_at == cast(expected_updated_at, t.c.updated_at.type))
|
||||
stmt = stmt.values(**values)
|
||||
result = self._conn.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
return True
|
||||
# Distinguish a stale optimistic-lock write (row exists) from a missing row.
|
||||
if expected_updated_at is not None and self.get_by_id(str(agent_id)) is not None:
|
||||
return None
|
||||
return False
|
||||
|
||||
def list_templates(self) -> list[dict]:
|
||||
# Template/system agents are seeded under ``__system__`` (migration
|
||||
# 0001 + the seeder); the legacy ``'system'`` value is kept in the
|
||||
# match so older rows still surface.
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM agents WHERE user_id IN ('system', '__system__') "
|
||||
"ORDER BY name"
|
||||
),
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, agent_id: str, user_id: str, fields: dict) -> bool:
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
"limited_request_mode", "request_limit",
|
||||
"allow_system_prompt_override",
|
||||
"shared", "shared_token", "shared_metadata",
|
||||
"incoming_webhook_token", "last_used_at",
|
||||
}
|
||||
filtered = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata"):
|
||||
# See note in create(): JSONB columns receive Python
|
||||
# objects, the type processor handles serialisation.
|
||||
values[col] = val
|
||||
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
|
||||
values[col] = str(val) if val else None
|
||||
elif col == "extra_source_ids":
|
||||
values[col] = [str(x) for x in val] if val else []
|
||||
elif col in (
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"shared", "allow_system_prompt_override",
|
||||
):
|
||||
values[col] = bool(val)
|
||||
else:
|
||||
values[col] = self._normalize_unique_text(col, val)
|
||||
values["updated_at"] = func.now()
|
||||
|
||||
t = agents_table
|
||||
stmt = (
|
||||
t.update()
|
||||
.where(t.c.id == agent_id)
|
||||
.where(t.c.user_id == user_id)
|
||||
.values(**values)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
def update_by_legacy_id(self, legacy_mongo_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Update an agent addressed by the Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
agent = self.get_by_legacy_id(legacy_mongo_id, user_id)
|
||||
if agent is None:
|
||||
return False
|
||||
return self.update(agent["id"], user_id, fields)
|
||||
|
||||
def delete(self, agent_id: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM agents WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": agent_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_by_legacy_id(self, legacy_mongo_id: str, user_id: str) -> bool:
|
||||
"""Delete an agent addressed by the Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM agents "
|
||||
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
|
||||
),
|
||||
{"legacy_id": legacy_mongo_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def set_folder(self, agent_id: str, user_id: str, folder_id: Optional[str]) -> None:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE agents SET folder_id = CAST(:folder_id AS uuid), updated_at = now()
|
||||
WHERE id = CAST(:id AS uuid) AND user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"id": agent_id, "user_id": user_id, "folder_id": folder_id},
|
||||
)
|
||||
|
||||
def clear_folder_for_all(self, folder_id: str, user_id: str) -> None:
|
||||
"""Remove folder assignment from all agents in a folder (used on folder delete)."""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"UPDATE agents SET folder_id = NULL, updated_at = now() "
|
||||
"WHERE folder_id = CAST(:folder_id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"folder_id": folder_id, "user_id": user_id},
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Repository for the ``app_metadata`` singleton key/value table.
|
||||
|
||||
Owns the instance-wide state the version-check client needs:
|
||||
``instance_id`` (anonymous UUID sent with each check) and
|
||||
``version_check_notice_shown`` (one-shot flag for the first-run
|
||||
telemetry notice). Kept deliberately generic so future one-off config
|
||||
values can piggyback without a new migration each time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
|
||||
class AppMetadataRepository:
|
||||
"""Postgres-backed ``app_metadata`` store. Tiny by design."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
row = self._conn.execute(
|
||||
text("SELECT value FROM app_metadata WHERE key = :key"),
|
||||
{"key": key},
|
||||
).fetchone()
|
||||
return row[0] if row is not None else None
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"INSERT INTO app_metadata (key, value) VALUES (:key, :value) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
|
||||
),
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
|
||||
def get_or_create_instance_id(self) -> str:
|
||||
"""Return the anonymous instance UUID, generating one if absent.
|
||||
|
||||
Uses ``INSERT ... ON CONFLICT DO NOTHING`` + re-read so two
|
||||
workers racing on the very first startup converge on a single
|
||||
UUID instead of each persisting their own.
|
||||
"""
|
||||
existing = self.get("instance_id")
|
||||
if existing:
|
||||
return existing
|
||||
candidate = str(uuid.uuid4())
|
||||
self._conn.execute(
|
||||
text(
|
||||
"INSERT INTO app_metadata (key, value) VALUES ('instance_id', :value) "
|
||||
"ON CONFLICT (key) DO NOTHING"
|
||||
),
|
||||
{"value": candidate},
|
||||
)
|
||||
# Re-read: if another worker won the race, their UUID is now authoritative.
|
||||
winner = self.get("instance_id")
|
||||
return winner or candidate
|
||||
@@ -0,0 +1,627 @@
|
||||
"""Repository for the ``artifacts`` / ``artifact_versions`` tables.
|
||||
|
||||
Append-only artifact store. An ``artifacts`` row is the stable identity; each
|
||||
edit appends an immutable ``artifact_versions`` row and atomically bumps
|
||||
``artifacts.current_version`` (mirroring ``workflows.increment_graph_version``).
|
||||
|
||||
Authz is parent-derived: reads are not gated on ``user_id`` — callers resolve
|
||||
access via the artifact's ``conversation_id`` / ``workflow_run_id`` parent.
|
||||
``user_id`` is carried for ownership / quota accounting only. Pass-by-reference:
|
||||
only metadata + the ``BaseStorage`` key are stored here, never binary bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _artifact_to_dict(row: Any) -> dict:
|
||||
"""row_to_dict for an ``artifacts`` row (``metadata`` key preserved)."""
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def _version_to_dict(row: Any) -> dict:
|
||||
"""row_to_dict for an ``artifact_versions`` row (``spec``/``produced_by`` keys preserved)."""
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
class ArtifactsRepository:
|
||||
"""CRUD for artifact identities and their append-only versions."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
"""Bind the repository to an open SQLAlchemy connection."""
|
||||
self._conn = conn
|
||||
|
||||
def create_artifact(
|
||||
self,
|
||||
user_id: str,
|
||||
kind: str,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
metadata: Any = None,
|
||||
mime_type: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
storage_path: Optional[str] = None,
|
||||
size: Optional[int] = None,
|
||||
sha256: Optional[str] = None,
|
||||
spec: Any = None,
|
||||
preview_text: Optional[str] = None,
|
||||
produced_by: Any = None,
|
||||
) -> dict:
|
||||
"""Create the identity row and its version 1 atomically; return the artifact dict.
|
||||
|
||||
When a parent is given, a STABLE per-parent ``ref_seq`` (max existing + 1) is
|
||||
computed BEFORE the insert and stored into ``metadata`` so the short ``A{n}`` ref
|
||||
the model receives survives later deletions. See ``next_ref_seq`` for the (benign)
|
||||
concurrent-create dup caveat.
|
||||
"""
|
||||
metadata_payload = metadata
|
||||
if conversation_id is not None or workflow_run_id is not None:
|
||||
ref_seq = self.next_ref_seq(
|
||||
conversation_id=conversation_id, workflow_run_id=workflow_run_id
|
||||
)
|
||||
merged = dict(metadata) if isinstance(metadata, dict) else {}
|
||||
merged["ref_seq"] = ref_seq
|
||||
metadata_payload = merged
|
||||
artifact = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO artifacts (
|
||||
user_id, conversation_id, workflow_run_id, team_id,
|
||||
message_id, kind, title, metadata, current_version
|
||||
)
|
||||
VALUES (
|
||||
:user_id,
|
||||
CAST(:conversation_id AS uuid),
|
||||
CAST(:workflow_run_id AS uuid),
|
||||
CAST(:team_id AS uuid),
|
||||
CAST(:message_id AS uuid),
|
||||
:kind, :title, CAST(:metadata AS jsonb), 1
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"conversation_id": conversation_id,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"team_id": team_id,
|
||||
"message_id": message_id,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"metadata": json.dumps(metadata_payload) if metadata_payload is not None else None,
|
||||
},
|
||||
).fetchone()
|
||||
artifact_dict = _artifact_to_dict(artifact)
|
||||
self._insert_version(
|
||||
artifact_id=artifact_dict["id"],
|
||||
version=1,
|
||||
mime_type=mime_type,
|
||||
filename=filename,
|
||||
storage_path=storage_path,
|
||||
size=size,
|
||||
sha256=sha256,
|
||||
spec=spec,
|
||||
preview_text=preview_text,
|
||||
produced_by=produced_by,
|
||||
)
|
||||
return artifact_dict
|
||||
|
||||
def get_artifact(self, artifact_id: str) -> Optional[dict]:
|
||||
"""Fetch one artifact by id, unscoped (prefer ``get_artifact_in_parent`` at request boundaries)."""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM artifacts WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": artifact_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return _artifact_to_dict(row) if row is not None else None
|
||||
|
||||
def get_artifact_in_parent(
|
||||
self,
|
||||
artifact_id: str,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Fetch an artifact only if it belongs to the given parent; the safe gate for request handlers."""
|
||||
if conversation_id is None and workflow_run_id is None:
|
||||
raise ValueError("get_artifact_in_parent requires conversation_id or workflow_run_id")
|
||||
clauses = ["id = CAST(:id AS uuid)"]
|
||||
params: dict[str, Any] = {"id": artifact_id}
|
||||
if conversation_id is not None:
|
||||
clauses.append("conversation_id = CAST(:conversation_id AS uuid)")
|
||||
params["conversation_id"] = conversation_id
|
||||
if workflow_run_id is not None:
|
||||
clauses.append("workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
||||
params["workflow_run_id"] = workflow_run_id
|
||||
result = self._conn.execute(
|
||||
text(f"SELECT * FROM artifacts WHERE {' AND '.join(clauses)}"),
|
||||
params,
|
||||
)
|
||||
row = result.fetchone()
|
||||
return _artifact_to_dict(row) if row is not None else None
|
||||
|
||||
def find_bridged_attachment(
|
||||
self,
|
||||
attachment_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""Return the conversation artifact already bridged from ``attachment_id``, or None (idempotency gate)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT a.* FROM artifacts a "
|
||||
"JOIN artifact_versions v "
|
||||
" ON v.artifact_id = a.id AND v.version = a.current_version "
|
||||
"WHERE a.conversation_id = CAST(:conversation_id AS uuid) "
|
||||
" AND v.produced_by ->> 'attachment_id' = :attachment_id "
|
||||
"ORDER BY a.created_at ASC, a.id ASC LIMIT 1"
|
||||
),
|
||||
{"conversation_id": conversation_id, "attachment_id": str(attachment_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return _artifact_to_dict(row) if row is not None else None
|
||||
|
||||
def list_artifacts(
|
||||
self,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""List artifacts filtered by parent / owner (newest first); at least one filter is required."""
|
||||
if conversation_id is None and workflow_run_id is None and user_id is None:
|
||||
raise ValueError("list_artifacts requires at least one of conversation_id, workflow_run_id, user_id")
|
||||
clauses: list[str] = []
|
||||
params: dict[str, Any] = {}
|
||||
if conversation_id is not None:
|
||||
clauses.append("conversation_id = CAST(:conversation_id AS uuid)")
|
||||
params["conversation_id"] = conversation_id
|
||||
if workflow_run_id is not None:
|
||||
clauses.append("workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
||||
params["workflow_run_id"] = workflow_run_id
|
||||
if user_id is not None:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
result = self._conn.execute(
|
||||
text(f"SELECT * FROM artifacts {where} ORDER BY created_at DESC"),
|
||||
params,
|
||||
)
|
||||
return [_artifact_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_artifacts_for_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""List an agent's artifacts (owner-scoped), optionally narrowed to one conversation.
|
||||
|
||||
Scopes a per-agent api-key's artifact visibility to the conversations that
|
||||
agent produced, so the key cannot enumerate the owner's whole corpus. When
|
||||
``conversation_id`` is given, the SQL narrows to that single conversation
|
||||
(the per-visitor bearer capability for a public widget key).
|
||||
"""
|
||||
clauses = ["c.agent_id = CAST(:agent_id AS uuid)", "a.user_id = :user_id"]
|
||||
params: dict[str, Any] = {"agent_id": str(agent_id), "user_id": user_id}
|
||||
if conversation_id is not None:
|
||||
clauses.append("a.conversation_id = CAST(:conversation_id AS uuid)")
|
||||
params["conversation_id"] = conversation_id
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT a.* FROM artifacts a "
|
||||
"JOIN conversations c ON a.conversation_id = c.id "
|
||||
f"WHERE {' AND '.join(clauses)} "
|
||||
"ORDER BY a.created_at DESC, a.id DESC"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return [_artifact_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def artifact_in_agent_scope(self, artifact_id: str, agent_id: str) -> bool:
|
||||
"""True if ``artifact_id``'s parent conversation belongs to ``agent_id``."""
|
||||
clauses = [
|
||||
"a.id = CAST(:id AS uuid)",
|
||||
"c.agent_id = CAST(:agent_id AS uuid)",
|
||||
]
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM artifacts a "
|
||||
"JOIN conversations c ON a.conversation_id = c.id "
|
||||
f"WHERE {' AND '.join(clauses)} "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"id": artifact_id, "agent_id": str(agent_id)},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
def position_in_parent(
|
||||
self,
|
||||
artifact_id: str,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Return the 1-based position of an artifact within its parent (by created_at, id tie-break); 0 if absent."""
|
||||
if conversation_id is None and workflow_run_id is None:
|
||||
raise ValueError("position_in_parent requires conversation_id or workflow_run_id")
|
||||
outer, params = self._parent_clauses(conversation_id, workflow_run_id, alias="a")
|
||||
inner, _ = self._parent_clauses(conversation_id, workflow_run_id, alias="t")
|
||||
params["id"] = artifact_id
|
||||
# The inner SELECT applies the same parent scope, so an artifact in a
|
||||
# different parent yields no anchor row -> count() is 0 (not in this parent).
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"SELECT count(*) FROM artifacts a "
|
||||
f"WHERE {' AND '.join(outer)} AND (a.created_at, a.id) <= ("
|
||||
f" SELECT t.created_at, t.id FROM artifacts t "
|
||||
f" WHERE {' AND '.join(inner)} AND t.id = CAST(:id AS uuid)"
|
||||
f")"
|
||||
),
|
||||
params,
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def artifact_id_at_position(
|
||||
self,
|
||||
n: int,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return the id of the n-th artifact (1-based, created_at asc, id tie-break) in a parent, or None."""
|
||||
if conversation_id is None and workflow_run_id is None:
|
||||
raise ValueError("artifact_id_at_position requires conversation_id or workflow_run_id")
|
||||
if not isinstance(n, int) or n < 1:
|
||||
return None
|
||||
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
||||
params["offset"] = n - 1
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"SELECT id FROM artifacts WHERE {' AND '.join(clauses)} "
|
||||
f"ORDER BY created_at ASC, id ASC OFFSET :offset LIMIT 1"
|
||||
),
|
||||
params,
|
||||
).fetchone()
|
||||
return str(row[0]) if row is not None else None
|
||||
|
||||
def next_ref_seq(
|
||||
self,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Return the next stable ``ref_seq`` for a parent (max stored + 1; 1 when empty).
|
||||
|
||||
Caveat: two concurrent creates in one parent can read the same MAX and get a
|
||||
duplicate ``ref_seq`` (creates within a parent are normally serial from one agent
|
||||
turn); a dup resolves to the earliest — acceptable, and strictly better than the
|
||||
re-point bug. A DB unique index is a possible future hardening (no migration here).
|
||||
"""
|
||||
if conversation_id is None and workflow_run_id is None:
|
||||
raise ValueError("next_ref_seq requires conversation_id or workflow_run_id")
|
||||
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
||||
# Guard the cast: legacy rows have no ``ref_seq`` (NULL, ignored), and the regex
|
||||
# match keeps any non-numeric value from erroring the cast.
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT COALESCE(MAX(CASE WHEN metadata ->> 'ref_seq' ~ '^[0-9]+$' "
|
||||
"THEN (metadata ->> 'ref_seq')::int END), 0) + 1 "
|
||||
f"FROM artifacts WHERE {' AND '.join(clauses)}"
|
||||
),
|
||||
params,
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else 1
|
||||
|
||||
def resolve_id_by_ref_seq(
|
||||
self,
|
||||
seq: int,
|
||||
*,
|
||||
conversation_id: Optional[str] = None,
|
||||
workflow_run_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return the id of the artifact whose stored ``ref_seq`` == ``seq`` in a parent, or None.
|
||||
|
||||
Earliest ``created_at`` wins on the rare concurrent-create tie (see ``next_ref_seq``).
|
||||
"""
|
||||
if conversation_id is None and workflow_run_id is None:
|
||||
raise ValueError("resolve_id_by_ref_seq requires conversation_id or workflow_run_id")
|
||||
if not isinstance(seq, int) or seq < 1:
|
||||
return None
|
||||
clauses, params = self._parent_clauses(conversation_id, workflow_run_id)
|
||||
params["seq"] = str(seq)
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
f"SELECT id FROM artifacts WHERE {' AND '.join(clauses)} "
|
||||
"AND metadata ->> 'ref_seq' = :seq "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
),
|
||||
params,
|
||||
).fetchone()
|
||||
return str(row[0]) if row is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _parent_clauses(
|
||||
conversation_id: Optional[str], workflow_run_id: Optional[str], alias: str = ""
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Build the parent-scope WHERE clauses + params (optionally column-aliased) for the position helpers."""
|
||||
prefix = f"{alias}." if alias else ""
|
||||
clauses: list[str] = []
|
||||
params: dict[str, Any] = {}
|
||||
if conversation_id is not None:
|
||||
clauses.append(f"{prefix}conversation_id = CAST(:conversation_id AS uuid)")
|
||||
params["conversation_id"] = conversation_id
|
||||
if workflow_run_id is not None:
|
||||
clauses.append(f"{prefix}workflow_run_id = CAST(:workflow_run_id AS uuid)")
|
||||
params["workflow_run_id"] = workflow_run_id
|
||||
return clauses, params
|
||||
|
||||
def count_for_user(self, user_id: str) -> int:
|
||||
"""Return how many artifacts ``user_id`` currently owns (quota accounting)."""
|
||||
row = self._conn.execute(
|
||||
text("SELECT count(*) FROM artifacts WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def total_bytes_for_user(self, user_id: str) -> int:
|
||||
"""Return the stored byte size a user owns (quota accounting).
|
||||
|
||||
Deduplicated by ``storage_path``: ``restore`` appends a version that
|
||||
re-points at an existing version's stored object (same key + size), so
|
||||
summing every version row would charge the same bytes N times and inflate
|
||||
usage without any new bytes being written. Each distinct stored object is
|
||||
counted once; versions with no object yet (``storage_path IS NULL`` --
|
||||
spec-only, whose size is normally NULL too) are counted individually since
|
||||
they share no key to dedupe on.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT COALESCE(SUM(t.size), 0) FROM ("
|
||||
" SELECT DISTINCT v.storage_path, v.size "
|
||||
" FROM artifact_versions v "
|
||||
" JOIN artifacts a ON a.id = v.artifact_id "
|
||||
" WHERE a.user_id = :user_id AND v.storage_path IS NOT NULL "
|
||||
" UNION ALL "
|
||||
" SELECT v.storage_path, v.size "
|
||||
" FROM artifact_versions v "
|
||||
" JOIN artifacts a ON a.id = v.artifact_id "
|
||||
" WHERE a.user_id = :user_id AND v.storage_path IS NULL"
|
||||
") t"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def delete_artifact(self, artifact_id: str) -> list[str]:
|
||||
"""Delete an artifact (+ its versions, via FK cascade); return its stored paths.
|
||||
|
||||
The caller deletes the returned storage objects best-effort AFTER commit;
|
||||
the DB delete also frees the owner's count/byte quota.
|
||||
"""
|
||||
paths = [
|
||||
v["storage_path"]
|
||||
for v in self.list_versions(artifact_id)
|
||||
if v.get("storage_path")
|
||||
]
|
||||
self._conn.execute(
|
||||
text("DELETE FROM artifacts WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": artifact_id},
|
||||
)
|
||||
return paths
|
||||
|
||||
def storage_paths_for_conversation(self, conversation_id: str) -> list[str]:
|
||||
"""Return every stored version path for a conversation's artifacts (for byte cleanup)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT v.storage_path FROM artifact_versions v "
|
||||
"JOIN artifacts a ON a.id = v.artifact_id "
|
||||
"WHERE a.conversation_id = CAST(:cid AS uuid) "
|
||||
"AND v.storage_path IS NOT NULL"
|
||||
),
|
||||
{"cid": conversation_id},
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
def storage_paths_for_user_conversations(self, user_id: str) -> list[str]:
|
||||
"""Return every stored version path for artifacts under a user's conversations."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT v.storage_path FROM artifact_versions v "
|
||||
"JOIN artifacts a ON a.id = v.artifact_id "
|
||||
"JOIN conversations c ON c.id = a.conversation_id "
|
||||
"WHERE c.user_id = :user_id AND v.storage_path IS NOT NULL"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
def delete_for_conversation(self, conversation_id: str) -> int:
|
||||
"""Delete all artifacts (+ versions, via cascade) parented to a conversation.
|
||||
|
||||
``conversation_id`` is a bare uuid column (no FK), so the parent delete
|
||||
does not cascade — callers invoke this to reclaim the rows + their quota.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM artifacts WHERE conversation_id = CAST(:cid AS uuid)"),
|
||||
{"cid": conversation_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_for_user_conversations(self, user_id: str) -> int:
|
||||
"""Delete every artifact parented to one of a user's conversations (+ versions)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM artifacts WHERE conversation_id IN "
|
||||
"(SELECT id FROM conversations WHERE user_id = :user_id)"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def storage_paths_for_workflow(self, workflow_id: str) -> list[str]:
|
||||
"""Return every stored version path for artifacts under a workflow's runs (for byte cleanup)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT v.storage_path FROM artifact_versions v "
|
||||
"JOIN artifacts a ON a.id = v.artifact_id "
|
||||
"JOIN workflow_runs r ON r.id = a.workflow_run_id "
|
||||
"WHERE r.workflow_id = CAST(:wid AS uuid) "
|
||||
"AND v.storage_path IS NOT NULL"
|
||||
),
|
||||
{"wid": workflow_id},
|
||||
)
|
||||
return [r[0] for r in result.fetchall()]
|
||||
|
||||
def delete_for_workflow(self, workflow_id: str) -> int:
|
||||
"""Delete all artifacts (+ versions, via cascade) parented to a workflow's runs.
|
||||
|
||||
``artifacts.workflow_run_id`` is a bare uuid column (no FK), so deleting the
|
||||
workflow (which cascade-deletes its ``workflow_runs``) does NOT reap these
|
||||
rows -- callers invoke this to reclaim the rows + their quota. Must run
|
||||
BEFORE the workflow delete, while the run rows still resolve the subquery.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM artifacts WHERE workflow_run_id IN "
|
||||
"(SELECT id FROM workflow_runs WHERE workflow_id = CAST(:wid AS uuid))"
|
||||
),
|
||||
{"wid": workflow_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
@staticmethod
|
||||
def reap_storage(paths: list) -> None:
|
||||
"""Best-effort delete a batch of artifact byte objects; never raises."""
|
||||
if not paths:
|
||||
return
|
||||
try:
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
|
||||
storage = StorageCreator.get_storage()
|
||||
except Exception:
|
||||
logger.warning("artifact cleanup: storage unavailable", exc_info=True)
|
||||
return
|
||||
for path in paths:
|
||||
try:
|
||||
storage.delete_file(path)
|
||||
except Exception:
|
||||
logger.warning("artifact cleanup: failed to delete bytes %s", path, exc_info=True)
|
||||
|
||||
def get_version(self, artifact_id: str, version: int) -> Optional[dict]:
|
||||
"""Fetch a single version row by artifact id and version number."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM artifact_versions "
|
||||
"WHERE artifact_id = CAST(:artifact_id AS uuid) AND version = :version"
|
||||
),
|
||||
{"artifact_id": artifact_id, "version": version},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return _version_to_dict(row) if row is not None else None
|
||||
|
||||
def list_versions(self, artifact_id: str) -> list[dict]:
|
||||
"""List every version of an artifact, oldest first."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM artifact_versions "
|
||||
"WHERE artifact_id = CAST(:artifact_id AS uuid) ORDER BY version ASC"
|
||||
),
|
||||
{"artifact_id": artifact_id},
|
||||
)
|
||||
return [_version_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def append_version(
|
||||
self,
|
||||
artifact_id: str,
|
||||
*,
|
||||
mime_type: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
storage_path: Optional[str] = None,
|
||||
size: Optional[int] = None,
|
||||
sha256: Optional[str] = None,
|
||||
spec: Any = None,
|
||||
preview_text: Optional[str] = None,
|
||||
produced_by: Any = None,
|
||||
) -> dict:
|
||||
"""Append a new version and atomically bump ``current_version``; return the version dict."""
|
||||
new_version = self._conn.execute(
|
||||
text(
|
||||
"UPDATE artifacts "
|
||||
"SET current_version = current_version + 1, updated_at = now() "
|
||||
"WHERE id = CAST(:id AS uuid) "
|
||||
"RETURNING current_version"
|
||||
),
|
||||
{"id": artifact_id},
|
||||
).fetchone()
|
||||
if new_version is None:
|
||||
raise ValueError(f"artifact {artifact_id} not found")
|
||||
return self._insert_version(
|
||||
artifact_id=artifact_id,
|
||||
version=new_version[0],
|
||||
mime_type=mime_type,
|
||||
filename=filename,
|
||||
storage_path=storage_path,
|
||||
size=size,
|
||||
sha256=sha256,
|
||||
spec=spec,
|
||||
preview_text=preview_text,
|
||||
produced_by=produced_by,
|
||||
)
|
||||
|
||||
def _insert_version(
|
||||
self,
|
||||
*,
|
||||
artifact_id: str,
|
||||
version: int,
|
||||
mime_type: Optional[str],
|
||||
filename: Optional[str],
|
||||
storage_path: Optional[str],
|
||||
size: Optional[int],
|
||||
sha256: Optional[str],
|
||||
spec: Any,
|
||||
preview_text: Optional[str],
|
||||
produced_by: Any,
|
||||
) -> dict:
|
||||
"""Insert one append-only version row; UNIQUE(artifact_id, version) guards duplicates."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO artifact_versions (
|
||||
artifact_id, version, mime_type, filename, storage_path,
|
||||
size, sha256, spec, preview_text, produced_by
|
||||
)
|
||||
VALUES (
|
||||
CAST(:artifact_id AS uuid), :version, :mime_type, :filename,
|
||||
:storage_path, :size, :sha256, CAST(:spec AS jsonb),
|
||||
:preview_text, CAST(:produced_by AS jsonb)
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"artifact_id": artifact_id,
|
||||
"version": version,
|
||||
"mime_type": mime_type,
|
||||
"filename": filename,
|
||||
"storage_path": storage_path,
|
||||
"size": size,
|
||||
"sha256": sha256,
|
||||
"spec": json.dumps(spec) if spec is not None else None,
|
||||
"preview_text": preview_text,
|
||||
"produced_by": json.dumps(produced_by) if produced_by is not None else None,
|
||||
},
|
||||
)
|
||||
return _version_to_dict(result.fetchone())
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Repository for the ``attachments`` table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
_UPDATABLE_SCALARS = {
|
||||
"filename", "upload_path", "mime_type", "size",
|
||||
"content", "token_count", "openai_file_id", "google_file_uri",
|
||||
}
|
||||
_UPDATABLE_JSONB = {"metadata"}
|
||||
|
||||
|
||||
def _attachment_to_dict(row: Any) -> dict:
|
||||
"""row_to_dict + ``upload_path``→``path`` alias.
|
||||
|
||||
Pre-Postgres, the Mongo attachment shape used ``path``. The PG column
|
||||
is ``upload_path``; LLM provider code (google_ai/openai/anthropic and
|
||||
handlers/base) still reads ``attachment.get("path")``. Mirroring the
|
||||
``id``/``_id`` dual-emit in row_to_dict so consumers don't need to
|
||||
know which storage backend produced the dict.
|
||||
"""
|
||||
out = row_to_dict(row)
|
||||
if "upload_path" in out and out.get("path") is None:
|
||||
out["path"] = out["upload_path"]
|
||||
return out
|
||||
|
||||
|
||||
class AttachmentsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
filename: str,
|
||||
upload_path: str,
|
||||
*,
|
||||
mime_type: Optional[str] = None,
|
||||
size: Optional[int] = None,
|
||||
content: Optional[str] = None,
|
||||
token_count: Optional[int] = None,
|
||||
openai_file_id: Optional[str] = None,
|
||||
google_file_uri: Optional[str] = None,
|
||||
metadata: Any = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO attachments (
|
||||
user_id, filename, upload_path, mime_type, size,
|
||||
content, token_count, openai_file_id, google_file_uri,
|
||||
metadata, legacy_mongo_id
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :filename, :upload_path, :mime_type, :size,
|
||||
:content, :token_count, :openai_file_id, :google_file_uri,
|
||||
CAST(:metadata AS jsonb), :legacy_mongo_id
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"filename": filename,
|
||||
"upload_path": upload_path,
|
||||
"mime_type": mime_type,
|
||||
"size": size,
|
||||
"content": content,
|
||||
"token_count": token_count,
|
||||
"openai_file_id": openai_file_id,
|
||||
"google_file_uri": google_file_uri,
|
||||
"metadata": json.dumps(metadata) if metadata is not None else None,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return _attachment_to_dict(result.fetchone())
|
||||
|
||||
def get(self, attachment_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM attachments WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": attachment_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return _attachment_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, attachment_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve an attachment by either PG UUID or legacy Mongo ObjectId string."""
|
||||
if looks_like_uuid(attachment_id):
|
||||
row = self.get(attachment_id, user_id)
|
||||
if row is not None:
|
||||
return row
|
||||
return self.get_by_legacy_id(attachment_id, user_id)
|
||||
|
||||
def resolve_ids(self, ids: list[str]) -> dict[str, str]:
|
||||
"""Batch-resolve a list of attachment ids (PG UUID *or* Mongo
|
||||
ObjectId or post-cutover route-minted UUID stored only in
|
||||
``legacy_mongo_id``) to their canonical PG ``attachments.id``.
|
||||
|
||||
Returns a ``{input_id: pg_uuid}`` map. Inputs that don't match
|
||||
any row are simply absent from the map (caller decides whether
|
||||
to drop or keep). Single round-trip via ``= ANY(:ids)`` to
|
||||
avoid N+1.
|
||||
|
||||
Resolution prefers ``legacy_mongo_id`` matches first, since
|
||||
the post-cutover ``/store_attachment`` route mints a UUID that
|
||||
is UUID-shaped but only ever lives in ``legacy_mongo_id``
|
||||
(the row's own ``id`` is a fresh PG-generated UUID). A
|
||||
UUID-shaped input that is *also* a real ``attachments.id``
|
||||
falls back to the direct PK match.
|
||||
"""
|
||||
if not ids:
|
||||
return {}
|
||||
# Deduplicate while preserving order for stable output mapping.
|
||||
unique_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in ids:
|
||||
if raw is None:
|
||||
continue
|
||||
s = str(raw)
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
unique_ids.append(s)
|
||||
if not unique_ids:
|
||||
return {}
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT id::text AS id, legacy_mongo_id "
|
||||
"FROM attachments "
|
||||
"WHERE legacy_mongo_id = ANY(:ids) "
|
||||
"OR id::text = ANY(:ids)"
|
||||
),
|
||||
{"ids": unique_ids},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
# Build two indexes so we can apply the legacy-first preference.
|
||||
by_legacy: dict[str, str] = {}
|
||||
by_pk: dict[str, str] = {}
|
||||
for row in rows:
|
||||
pg_id = str(row[0])
|
||||
legacy = row[1]
|
||||
by_pk[pg_id] = pg_id
|
||||
if legacy is not None:
|
||||
by_legacy[str(legacy)] = pg_id
|
||||
out: dict[str, str] = {}
|
||||
for s in unique_ids:
|
||||
if s in by_legacy:
|
||||
out[s] = by_legacy[s]
|
||||
elif s in by_pk:
|
||||
out[s] = by_pk[s]
|
||||
return out
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str, user_id: str | None = None) -> Optional[dict]:
|
||||
"""Fetch an attachment by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM attachments WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return _attachment_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM attachments WHERE user_id = :user_id ORDER BY created_at DESC"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [_attachment_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, attachment_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Partial update. Used by the LLM providers to cache their
|
||||
uploaded file IDs (``openai_file_id`` / ``google_file_uri``) so we
|
||||
don't re-upload the same blob every call.
|
||||
"""
|
||||
filtered = {
|
||||
k: v for k, v in fields.items()
|
||||
if k in _UPDATABLE_SCALARS | _UPDATABLE_JSONB
|
||||
}
|
||||
if not filtered:
|
||||
return False
|
||||
set_clauses: list[str] = []
|
||||
params: dict = {"id": attachment_id, "user_id": user_id}
|
||||
for col, val in filtered.items():
|
||||
if col in _UPDATABLE_JSONB:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = json.dumps(val) if val is not None else None
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"UPDATE attachments SET {', '.join(set_clauses)} "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def update_any(self, attachment_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Partial update addressed by either PG UUID or legacy Mongo ObjectId.
|
||||
|
||||
Cutover helper used by the LLM provider file-ID caching hot path:
|
||||
the attachment dict in hand may carry a UUID (post-cutover shape)
|
||||
or an ObjectId-string ``_id`` (legacy). Try the UUID path first
|
||||
when the id looks like a UUID; otherwise fall back to the
|
||||
``legacy_mongo_id`` update. Both branches are user-scoped: the
|
||||
caller must pass the authenticated ``user_id`` so cross-tenant
|
||||
writes are prevented even when the fallback legacy path fires.
|
||||
"""
|
||||
if looks_like_uuid(attachment_id):
|
||||
if self.update(attachment_id, user_id, fields):
|
||||
return True
|
||||
return self.update_by_legacy_id(attachment_id, user_id, fields)
|
||||
|
||||
def update_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: str, fields: dict
|
||||
) -> bool:
|
||||
"""Like ``update`` but addressed by the Mongo ObjectId string.
|
||||
|
||||
Used by the LLM file-ID caching path which, at dual-write time,
|
||||
only has the Mongo ``_id`` in hand (the PG UUID hasn't been
|
||||
looked up yet). Scoped by ``user_id`` so a caller that happens to
|
||||
pass an id matching another user's ``legacy_mongo_id`` cannot
|
||||
mutate the wrong row (IDOR).
|
||||
"""
|
||||
if user_id is None:
|
||||
return False
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
filtered = {
|
||||
k: v for k, v in fields.items()
|
||||
if k in _UPDATABLE_SCALARS | _UPDATABLE_JSONB
|
||||
}
|
||||
if not filtered:
|
||||
return False
|
||||
set_clauses: list[str] = []
|
||||
params: dict = {"legacy_id": legacy_mongo_id, "user_id": user_id}
|
||||
for col, val in filtered.items():
|
||||
if col in _UPDATABLE_JSONB:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = json.dumps(val) if val is not None else None
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"UPDATE attachments SET {', '.join(set_clauses)} "
|
||||
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Repository for the ``auth_events`` audit table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class AuthEventsRepository:
|
||||
"""Append-only audit trail of login / logout / provisioning events."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def insert(
|
||||
self,
|
||||
user_id: str,
|
||||
event: str,
|
||||
ip: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Record one auth event and return the inserted row."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO auth_events (user_id, event, ip, user_agent, metadata)
|
||||
VALUES (:user_id, :event, :ip, :user_agent, CAST(:metadata AS jsonb))
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"event": event,
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def list_recent(self, user_id: str, limit: int = 50) -> list[dict]:
|
||||
"""Return the newest events for ``user_id``, newest first."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM auth_events
|
||||
WHERE user_id = :user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "limit": limit},
|
||||
)
|
||||
return [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
def _filter_clauses(event, user_id, since) -> tuple[str, dict]:
|
||||
clauses: list[str] = []
|
||||
params: dict = {}
|
||||
if event:
|
||||
clauses.append("event = :event")
|
||||
params["event"] = event
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if since is not None:
|
||||
clauses.append("created_at >= :since")
|
||||
params["since"] = since
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
event: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
since=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Global audit feed (admin), newest first; optional event/user/since filters."""
|
||||
where, params = self._filter_clauses(event, user_id, since)
|
||||
params.update({"limit": int(limit), "offset": int(offset)})
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"SELECT * FROM auth_events {where} "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
def count_all(
|
||||
self, *, event: Optional[str] = None, user_id: Optional[str] = None, since=None
|
||||
) -> int:
|
||||
"""Total matching the same filters as :meth:`list_all` (for pagination)."""
|
||||
where, params = self._filter_clauses(event, user_id, since)
|
||||
return int(
|
||||
self._conn.execute(
|
||||
text(f"SELECT count(*) FROM auth_events {where}"), params
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Repository for the ``connector_sessions`` table.
|
||||
|
||||
Shape notes:
|
||||
|
||||
* OAuth connectors (Google Drive, SharePoint, Confluence) write one row
|
||||
per ``(user_id, provider)`` with ``server_url = NULL``. The primary
|
||||
lookup key post-callback is ``session_token`` (see
|
||||
``complete_oauth`` style routes), so the table has a standalone
|
||||
unique constraint on ``session_token``.
|
||||
* MCP sessions key off ``server_url`` instead — a single user may have
|
||||
multiple MCP servers, one row each. The composite unique index
|
||||
``(user_id, COALESCE(server_url, ''), provider)`` makes both patterns
|
||||
coexist without collision.
|
||||
* ``session_data`` remains a catch-all JSONB for driver-specific state
|
||||
(tokens that don't fit anywhere else, per-provider scratch data).
|
||||
Promoted columns (``session_token``, ``user_email``, ``status``,
|
||||
``token_info``) are the ones route/auth code queries by.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
|
||||
_UPDATABLE_SCALARS = {
|
||||
"server_url", "session_token", "user_email", "status", "expires_at",
|
||||
}
|
||||
_UPDATABLE_JSONB = {"session_data", "token_info"}
|
||||
|
||||
|
||||
def _jsonb(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, cls=PGNativeJSONEncoder)
|
||||
|
||||
|
||||
class ConnectorSessionsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
session_data: Optional[dict] = None,
|
||||
*,
|
||||
server_url: Optional[str] = None,
|
||||
session_token: Optional[str] = None,
|
||||
user_email: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
token_info: Optional[dict] = None,
|
||||
expires_at: Any = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Insert or update a connector session row.
|
||||
|
||||
Conflict key is ``(user_id, COALESCE(server_url, ''), provider)``
|
||||
so MCP rows (per-server) and OAuth rows (per-provider) both get
|
||||
idempotent upsert semantics.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO connector_sessions (
|
||||
user_id, provider, server_url, session_token, user_email,
|
||||
status, token_info, session_data, expires_at, legacy_mongo_id
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :provider, :server_url, :session_token, :user_email,
|
||||
:status, CAST(:token_info AS jsonb),
|
||||
CAST(:session_data AS jsonb), :expires_at, :legacy_mongo_id
|
||||
)
|
||||
ON CONFLICT (user_id, COALESCE(server_url, ''), provider)
|
||||
DO UPDATE SET
|
||||
session_token = COALESCE(EXCLUDED.session_token, connector_sessions.session_token),
|
||||
user_email = COALESCE(EXCLUDED.user_email, connector_sessions.user_email),
|
||||
status = COALESCE(EXCLUDED.status, connector_sessions.status),
|
||||
token_info = COALESCE(EXCLUDED.token_info, connector_sessions.token_info),
|
||||
session_data = EXCLUDED.session_data,
|
||||
expires_at = COALESCE(EXCLUDED.expires_at, connector_sessions.expires_at)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"server_url": server_url,
|
||||
"session_token": session_token,
|
||||
"user_email": user_email,
|
||||
"status": status,
|
||||
"token_info": _jsonb(token_info),
|
||||
"session_data": _jsonb(session_data or {}),
|
||||
"expires_at": expires_at,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get_by_user_provider(
|
||||
self, user_id: str, provider: str, *, server_url: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Legacy (user_id, provider) lookup, optionally scoped by server_url.
|
||||
|
||||
Kept for OAuth providers that only have one row per user — they
|
||||
pass ``server_url=None`` and get the single OAuth row.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT * FROM connector_sessions "
|
||||
"WHERE user_id = :user_id AND provider = :provider"
|
||||
)
|
||||
params: dict[str, Any] = {"user_id": user_id, "provider": provider}
|
||||
if server_url is not None:
|
||||
sql += " AND server_url = :server_url"
|
||||
params["server_url"] = server_url
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_session_token(self, session_token: str) -> Optional[dict]:
|
||||
"""Post-OAuth-callback lookup.
|
||||
|
||||
Every OAuth flow (Google Drive, SharePoint, Confluence) redirects
|
||||
back with the ``session_token`` as the only handle; the callback
|
||||
route resolves it to the full session row.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM connector_sessions WHERE session_token = :token"),
|
||||
{"token": session_token},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_user_and_server_url(
|
||||
self, user_id: str, server_url: str,
|
||||
) -> Optional[dict]:
|
||||
"""MCP-tool lookup: resolve a session by the MCP server URL."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM connector_sessions "
|
||||
"WHERE user_id = :user_id AND server_url = :server_url "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"user_id": user_id, "server_url": server_url},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM connector_sessions WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM connector_sessions WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, session_id: str, fields: dict) -> bool:
|
||||
"""Partial update by PG UUID."""
|
||||
filtered = {
|
||||
k: v for k, v in fields.items()
|
||||
if k in _UPDATABLE_SCALARS | _UPDATABLE_JSONB
|
||||
}
|
||||
if not filtered:
|
||||
return False
|
||||
set_clauses: list[str] = []
|
||||
params: dict = {"id": session_id}
|
||||
for col, val in filtered.items():
|
||||
if col in _UPDATABLE_JSONB:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = _jsonb(val)
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"UPDATE connector_sessions SET {', '.join(set_clauses)} "
|
||||
"WHERE id = CAST(:id AS uuid)"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def update_by_legacy_id(self, legacy_mongo_id: str, fields: dict) -> bool:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
filtered = {
|
||||
k: v for k, v in fields.items()
|
||||
if k in _UPDATABLE_SCALARS | _UPDATABLE_JSONB
|
||||
}
|
||||
if not filtered:
|
||||
return False
|
||||
set_clauses: list[str] = []
|
||||
params: dict = {"legacy_id": legacy_mongo_id}
|
||||
for col, val in filtered.items():
|
||||
if col in _UPDATABLE_JSONB:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = _jsonb(val)
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"UPDATE connector_sessions SET {', '.join(set_clauses)} "
|
||||
"WHERE legacy_mongo_id = :legacy_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def merge_session_data(
|
||||
self,
|
||||
user_id: str,
|
||||
provider: str,
|
||||
server_url: Optional[str],
|
||||
patch: dict,
|
||||
) -> dict:
|
||||
"""Upsert by shallow-merging ``patch`` into ``session_data``.
|
||||
|
||||
Writes ``server_url`` to the scalar column so downstream
|
||||
``get_by_user_and_server_url`` lookups can find the row. If
|
||||
``patch`` still carries a ``"server_url"`` key (legacy callers)
|
||||
it is stripped before merging so the scalar column stays the
|
||||
single source of truth and we don't duplicate it inside the
|
||||
JSONB blob.
|
||||
|
||||
Args:
|
||||
user_id: Owner of the session.
|
||||
provider: Provider tag (e.g. ``"mcp:<base_url>"`` for MCP).
|
||||
server_url: Endpoint to pin the row to. ``None`` is valid
|
||||
for single-row-per-user OAuth providers.
|
||||
patch: Shallow-merge payload for ``session_data``. Keys
|
||||
mapped to ``None`` are *dropped* from the stored doc
|
||||
(used by the redirect-URI-mismatch clear path).
|
||||
|
||||
Returns:
|
||||
The upserted row as a dict.
|
||||
|
||||
Notes:
|
||||
The conflict target matches the table's composite unique
|
||||
constraint ``(user_id, COALESCE(server_url, ''), provider)``
|
||||
so MCP's per-URL rows and OAuth's single-row-per-user rows
|
||||
both upsert idempotently.
|
||||
"""
|
||||
# Defensively strip ``server_url`` from ``patch`` — the scalar
|
||||
# column is authoritative now. Callers still pass it for
|
||||
# backwards compatibility during the transition.
|
||||
patch = {k: v for k, v in patch.items() if k != "server_url"}
|
||||
set_entries = {k: v for k, v in patch.items() if v is not None}
|
||||
drop_keys = [k for k, v in patch.items() if v is None]
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO connector_sessions (
|
||||
user_id, provider, server_url, session_data
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :provider, :server_url,
|
||||
CAST(:patch AS jsonb)
|
||||
)
|
||||
ON CONFLICT (user_id, COALESCE(server_url, ''), provider)
|
||||
DO UPDATE SET
|
||||
server_url = COALESCE(EXCLUDED.server_url, connector_sessions.server_url),
|
||||
session_data =
|
||||
(connector_sessions.session_data || EXCLUDED.session_data)
|
||||
- CAST(:drop_keys AS text[])
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"server_url": server_url,
|
||||
"patch": json.dumps(set_entries),
|
||||
"drop_keys": "{" + ",".join(f'"{k}"' for k in drop_keys) + "}",
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def delete(
|
||||
self, user_id: str, provider: str, *, server_url: Optional[str] = None,
|
||||
) -> bool:
|
||||
sql = (
|
||||
"DELETE FROM connector_sessions "
|
||||
"WHERE user_id = :user_id AND provider = :provider"
|
||||
)
|
||||
params: dict[str, Any] = {"user_id": user_id, "provider": provider}
|
||||
if server_url is not None:
|
||||
sql += " AND server_url = :server_url"
|
||||
params["server_url"] = server_url
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_by_session_token(self, session_token: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM connector_sessions WHERE session_token = :token"
|
||||
),
|
||||
{"token": session_token},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
"""Append-only audit log for remote-device tool invocations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class DeviceAuditLogRepository:
|
||||
"""Server-side canonical record of every remote-device invocation."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def record_dispatch(
|
||||
self,
|
||||
*,
|
||||
device_id: str,
|
||||
user_id: str,
|
||||
invocation_id: str,
|
||||
command: str,
|
||||
approval_mode: str,
|
||||
decision: str,
|
||||
decision_reason: Optional[str],
|
||||
issued_at: datetime,
|
||||
action: str = "run_command",
|
||||
working_dir: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO device_audit_log (
|
||||
device_id, user_id, agent_id, conversation_id,
|
||||
invocation_id, action, command, working_dir,
|
||||
approval_mode, decision, decision_reason, issued_at
|
||||
) VALUES (
|
||||
:device_id, :user_id, :agent_id, :conversation_id,
|
||||
:invocation_id, :action, :command, :working_dir,
|
||||
:approval_mode, :decision, :decision_reason, :issued_at
|
||||
) RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"device_id": device_id,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"conversation_id": conversation_id,
|
||||
"invocation_id": invocation_id,
|
||||
"action": action,
|
||||
"command": command,
|
||||
"working_dir": working_dir,
|
||||
"approval_mode": approval_mode,
|
||||
"decision": decision,
|
||||
"decision_reason": decision_reason,
|
||||
"issued_at": issued_at,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
def record_result(
|
||||
self,
|
||||
invocation_id: str,
|
||||
*,
|
||||
started_at: Optional[datetime] = None,
|
||||
finished_at: Optional[datetime] = None,
|
||||
exit_code: Optional[int] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
stdout_sha256: Optional[str] = None,
|
||||
stderr_sha256: Optional[str] = None,
|
||||
stdout_bytes: Optional[int] = None,
|
||||
stderr_bytes: Optional[int] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Update the dispatched row with execution outcome."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE device_audit_log
|
||||
SET started_at = COALESCE(:started_at, started_at),
|
||||
finished_at = COALESCE(:finished_at, finished_at),
|
||||
exit_code = COALESCE(:exit_code, exit_code),
|
||||
duration_ms = COALESCE(:duration_ms, duration_ms),
|
||||
stdout_sha256 = COALESCE(:stdout_sha256, stdout_sha256),
|
||||
stderr_sha256 = COALESCE(:stderr_sha256, stderr_sha256),
|
||||
stdout_bytes = COALESCE(:stdout_bytes, stdout_bytes),
|
||||
stderr_bytes = COALESCE(:stderr_bytes, stderr_bytes),
|
||||
error = COALESCE(:error, error)
|
||||
WHERE invocation_id = :invocation_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"invocation_id": invocation_id,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout_sha256": stdout_sha256,
|
||||
"stderr_sha256": stderr_sha256,
|
||||
"stdout_bytes": stdout_bytes,
|
||||
"stderr_bytes": stderr_bytes,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def _global_filters(decision, user_id, device_id, since) -> tuple[str, dict]:
|
||||
clauses: list[str] = []
|
||||
params: dict = {}
|
||||
if decision:
|
||||
clauses.append("decision = :decision")
|
||||
params["decision"] = decision
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if device_id:
|
||||
clauses.append("device_id = CAST(:device_id AS uuid)")
|
||||
params["device_id"] = device_id
|
||||
if since is not None:
|
||||
clauses.append("created_at >= :since")
|
||||
params["since"] = since
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
def list_global(
|
||||
self,
|
||||
*,
|
||||
decision: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
since=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Cross-device admin feed of remote-command invocations, newest first."""
|
||||
where, params = self._global_filters(decision, user_id, device_id, since)
|
||||
params.update({"limit": int(limit), "offset": int(offset)})
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"SELECT * FROM device_audit_log {where} "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
params,
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def count_global(
|
||||
self,
|
||||
*,
|
||||
decision: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
since=None,
|
||||
) -> int:
|
||||
where, params = self._global_filters(decision, user_id, device_id, since)
|
||||
return int(
|
||||
self._conn.execute(
|
||||
text(f"SELECT count(*) FROM device_audit_log {where}"), params
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def list_for_device(
|
||||
self, device_id: str, user_id: str, *, limit: int = 100
|
||||
) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM device_audit_log
|
||||
WHERE device_id = :device_id AND user_id = :user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id, "user_id": user_id, "limit": int(limit)},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Per-device, per-user sticky "don't ask again" patterns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
|
||||
class DeviceAutoApprovePatternsRepository:
|
||||
"""Normalized sticky-approval patterns scoped to (device, user)."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def add(self, device_id: str, user_id: str, pattern: str) -> bool:
|
||||
"""Idempotently add a pattern; returns True if newly inserted."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO device_auto_approve_patterns
|
||||
(device_id, user_id, pattern)
|
||||
VALUES (:device_id, :user_id, :pattern)
|
||||
ON CONFLICT (device_id, user_id, pattern) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id, "user_id": user_id, "pattern": pattern},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def remove(self, device_id: str, user_id: str, pattern: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM device_auto_approve_patterns
|
||||
WHERE device_id = :device_id
|
||||
AND user_id = :user_id
|
||||
AND pattern = :pattern
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id, "user_id": user_id, "pattern": pattern},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_for_device(self, device_id: str, user_id: str) -> list[str]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT pattern FROM device_auto_approve_patterns
|
||||
WHERE device_id = :device_id AND user_id = :user_id
|
||||
ORDER BY created_at
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id, "user_id": user_id},
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
def has_pattern(
|
||||
self, device_id: str, user_id: str, pattern: str
|
||||
) -> bool:
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1 FROM device_auto_approve_patterns
|
||||
WHERE device_id = :device_id
|
||||
AND user_id = :user_id
|
||||
AND pattern = :pattern
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id, "user_id": user_id, "pattern": pattern},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def clear_for_device(self, device_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM device_auto_approve_patterns WHERE device_id = :device_id"
|
||||
),
|
||||
{"device_id": device_id},
|
||||
)
|
||||
return result.rowcount or 0
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Repository for the ``devices`` table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
_ALLOWED_UPDATES = frozenset(
|
||||
{
|
||||
"name", "description", "approval_mode", "cli_version", "hostname",
|
||||
"os", "arch",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DevicesRepository:
|
||||
"""CRUD for paired remote devices."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
device_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
*,
|
||||
machine_pubkey_fingerprint: str,
|
||||
token_hash: str,
|
||||
hostname: Optional[str] = None,
|
||||
os: Optional[str] = None,
|
||||
arch: Optional[str] = None,
|
||||
cli_version: Optional[str] = None,
|
||||
approval_mode: str = "ask",
|
||||
description: Optional[str] = None,
|
||||
) -> dict:
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO devices (
|
||||
id, user_id, name, hostname, os, arch, cli_version,
|
||||
machine_pubkey_fingerprint, token_hash, approval_mode,
|
||||
description
|
||||
) VALUES (
|
||||
:id, :user_id, :name, :hostname, :os, :arch, :cli_version,
|
||||
:fp, :token_hash, :approval_mode, :description
|
||||
) RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": device_id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"hostname": hostname,
|
||||
"os": os,
|
||||
"arch": arch,
|
||||
"cli_version": cli_version,
|
||||
"fp": machine_pubkey_fingerprint,
|
||||
"token_hash": token_hash,
|
||||
"approval_mode": approval_mode,
|
||||
"description": description,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
def get(self, device_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
||||
sql = "SELECT * FROM devices WHERE id = :id"
|
||||
params: dict = {"id": device_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
row = self._conn.execute(text(sql), params).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(
|
||||
self, user_id: str, *, include_revoked: bool = False
|
||||
) -> list[dict]:
|
||||
sql = "SELECT * FROM devices WHERE user_id = :user_id"
|
||||
if not include_revoked:
|
||||
sql += " AND status = 'active'"
|
||||
sql += " ORDER BY paired_at DESC"
|
||||
result = self._conn.execute(text(sql), {"user_id": user_id})
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, device_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Update name/description/approval_mode (and cli/host metadata)."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
if not filtered:
|
||||
return False
|
||||
set_clauses = [f"{col} = :{col}" for col in filtered]
|
||||
params = {**filtered, "id": device_id, "user_id": user_id}
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE devices
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def touch_last_seen(self, device_id: str) -> None:
|
||||
"""Bump ``last_seen_at`` to now(). Called on every poll/SSE open."""
|
||||
self._conn.execute(
|
||||
text("UPDATE devices SET last_seen_at = now() WHERE id = :id"),
|
||||
{"id": device_id},
|
||||
)
|
||||
|
||||
def revoke(
|
||||
self, device_id: str, user_id: str, *, reason: str = "user_revoked"
|
||||
) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET status = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoke_reason = :reason
|
||||
WHERE id = :id AND user_id = :user_id AND status = 'active'
|
||||
"""
|
||||
),
|
||||
{"id": device_id, "user_id": user_id, "reason": reason},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def find_by_token_hash(self, token_hash: str) -> Optional[dict]:
|
||||
"""Used by the session-token verifier on each device request."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM devices "
|
||||
"WHERE token_hash = :token_hash AND status = 'active' "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"token_hash": token_hash},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Repository for ``webhook_dedup`` and ``task_dedup``; 24h TTL enforced at read."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
# 24h TTL is the contract surfaced in the upload/webhook docstrings; the
|
||||
# read filters and the stale-row replacement predicate must agree, or the
|
||||
# upsert can fall into a window where the row is "fresh" to the writer
|
||||
# but "expired" to the reader (or vice versa). Keep one constant so any
|
||||
# future change moves both directions in lockstep.
|
||||
DEDUP_TTL_INTERVAL = "24 hours"
|
||||
|
||||
|
||||
def _jsonb(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, cls=PGNativeJSONEncoder)
|
||||
|
||||
|
||||
class IdempotencyRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# --- webhook_dedup -----------------------------------------------------
|
||||
|
||||
def get_webhook(self, key: str) -> Optional[dict]:
|
||||
"""Return the cached webhook row for ``key`` if still within the 24h window."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM webhook_dedup
|
||||
WHERE idempotency_key = :key
|
||||
AND created_at > now() - CAST(:ttl AS interval)
|
||||
"""
|
||||
),
|
||||
{"key": key, "ttl": DEDUP_TTL_INTERVAL},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def record_webhook(
|
||||
self,
|
||||
key: str,
|
||||
agent_id: str,
|
||||
task_id: str,
|
||||
response_json: dict,
|
||||
) -> Optional[dict]:
|
||||
"""Insert a webhook dedup row; return None if another writer raced and won.
|
||||
|
||||
``ON CONFLICT`` replaces an existing row only when its ``created_at``
|
||||
is past TTL — atomic stale-row recycling under the row lock. A
|
||||
within-TTL conflict yields no row; the caller resolves it via
|
||||
:meth:`get_webhook`.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO webhook_dedup (
|
||||
idempotency_key, agent_id, task_id, response_json
|
||||
)
|
||||
VALUES (
|
||||
:key, CAST(:agent_id AS uuid), :task_id,
|
||||
CAST(:response_json AS jsonb)
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO UPDATE
|
||||
SET agent_id = EXCLUDED.agent_id,
|
||||
task_id = EXCLUDED.task_id,
|
||||
response_json = EXCLUDED.response_json,
|
||||
created_at = now()
|
||||
WHERE webhook_dedup.created_at
|
||||
<= now() - CAST(:ttl AS interval)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"response_json": _jsonb(response_json),
|
||||
"ttl": DEDUP_TTL_INTERVAL,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
# --- task_dedup --------------------------------------------------------
|
||||
|
||||
def get_task(self, key: str) -> Optional[dict]:
|
||||
"""Return the cached task row for ``key`` if still within the 24h window."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM task_dedup
|
||||
WHERE idempotency_key = :key
|
||||
AND created_at > now() - CAST(:ttl AS interval)
|
||||
"""
|
||||
),
|
||||
{"key": key, "ttl": DEDUP_TTL_INTERVAL},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
key: str,
|
||||
task_name: str,
|
||||
task_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""Claim ``key`` for this task. Returns the inserted row, or None if
|
||||
another writer raced and won. The HTTP entry must call this *before*
|
||||
``.delay()`` so only the winner enqueues the Celery task.
|
||||
|
||||
``ON CONFLICT`` replaces an existing row in two cases:
|
||||
|
||||
- **status='failed'**: the worker's poison-loop guard or the
|
||||
reconciler's stuck-pending sweep finalised the prior attempt
|
||||
as failed. Both explicitly intend a same-key retry to re-run
|
||||
(see ``run_reconciliation`` Q5 docstring) — letting the row
|
||||
block for 24 h would silently undo that intent.
|
||||
- **created_at past TTL**: a stale claim from any status no
|
||||
longer represents a meaningful dedup signal.
|
||||
|
||||
``status='completed'`` rows still block within TTL — that's the
|
||||
cached-success contract callers rely on. ``status='pending'``
|
||||
rows still block within TTL so concurrent same-key requests
|
||||
collapse onto the in-flight task. Result/attempt fields are
|
||||
reset to their fresh-claim defaults during replacement.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO task_dedup (
|
||||
idempotency_key, task_name, task_id, result_json, status
|
||||
)
|
||||
VALUES (
|
||||
:key, :task_name, :task_id, NULL, 'pending'
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO UPDATE
|
||||
SET task_name = EXCLUDED.task_name,
|
||||
task_id = EXCLUDED.task_id,
|
||||
result_json = NULL,
|
||||
status = 'pending',
|
||||
attempt_count = 0,
|
||||
created_at = now()
|
||||
WHERE task_dedup.status = 'failed'
|
||||
OR task_dedup.created_at
|
||||
<= now() - CAST(:ttl AS interval)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"task_name": task_name,
|
||||
"task_id": task_id,
|
||||
"ttl": DEDUP_TTL_INTERVAL,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def try_claim_lease(
|
||||
self,
|
||||
key: str,
|
||||
task_name: str,
|
||||
task_id: str,
|
||||
owner_id: str,
|
||||
ttl_seconds: int = 60,
|
||||
) -> Optional[int]:
|
||||
"""Atomically claim the running lease for ``key``.
|
||||
|
||||
Returns the new ``attempt_count`` if this caller now owns the
|
||||
lease (fresh insert OR existing row whose lease was empty/expired),
|
||||
or ``None`` if a different worker holds a live lease.
|
||||
|
||||
The conflict path also bumps ``attempt_count`` so the
|
||||
poison-loop guard in :func:`with_idempotency` can fire after
|
||||
:data:`MAX_TASK_ATTEMPTS` reclaims. ``status='completed'`` rows
|
||||
are deliberately untouched — :func:`_lookup_completed` is the
|
||||
cache short-circuit and runs before this. Uses
|
||||
``clock_timestamp()`` so a same-transaction refresh actually
|
||||
moves the expiry forward (``now()`` is frozen at txn start).
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO task_dedup (
|
||||
idempotency_key, task_name, task_id, status, attempt_count,
|
||||
lease_owner_id, lease_expires_at
|
||||
) VALUES (
|
||||
:key, :task_name, :task_id, 'pending', 1,
|
||||
:owner,
|
||||
clock_timestamp() + make_interval(secs => :ttl)
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO UPDATE
|
||||
SET attempt_count = task_dedup.attempt_count + 1,
|
||||
task_name = EXCLUDED.task_name,
|
||||
lease_owner_id = EXCLUDED.lease_owner_id,
|
||||
lease_expires_at = EXCLUDED.lease_expires_at
|
||||
WHERE task_dedup.status <> 'completed'
|
||||
AND (task_dedup.lease_expires_at IS NULL
|
||||
OR task_dedup.lease_expires_at <= clock_timestamp())
|
||||
RETURNING attempt_count
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"task_name": task_name,
|
||||
"task_id": task_id,
|
||||
"owner": owner_id,
|
||||
"ttl": int(ttl_seconds),
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
def refresh_lease(
|
||||
self,
|
||||
key: str,
|
||||
owner_id: str,
|
||||
ttl_seconds: int = 60,
|
||||
) -> bool:
|
||||
"""Bump ``lease_expires_at`` if this caller still owns the lease.
|
||||
|
||||
Returns False when ownership was lost (lease stolen by another
|
||||
worker after expiry, or row finalised). The heartbeat thread
|
||||
logs that as a warning but doesn't try to abort the running
|
||||
task — at-most-one-worker is bounded by ``ttl_seconds``, the
|
||||
damage from a brief overlap window is unavoidable in this case.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_dedup
|
||||
SET lease_expires_at =
|
||||
clock_timestamp() + make_interval(secs => :ttl)
|
||||
WHERE idempotency_key = :key
|
||||
AND lease_owner_id = :owner
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"owner": owner_id,
|
||||
"ttl": int(ttl_seconds),
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def release_lease(self, key: str, owner_id: str) -> bool:
|
||||
"""Clear ``lease_owner_id`` / ``lease_expires_at`` on the
|
||||
wrapper's exception path so Celery's autoretry_for doesn't have
|
||||
to wait the full ``ttl_seconds`` before the next worker can
|
||||
re-claim. No-op if a different worker has since taken over the
|
||||
lease — that case is benign (we'd just be acknowledging we
|
||||
weren't the owner anymore).
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_dedup
|
||||
SET lease_owner_id = NULL,
|
||||
lease_expires_at = NULL
|
||||
WHERE idempotency_key = :key
|
||||
AND lease_owner_id = :owner
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{"key": key, "owner": owner_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def finalize_task(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
result_json: Optional[dict],
|
||||
status: str,
|
||||
) -> bool:
|
||||
"""Promote ``status='pending'`` → ``completed|failed`` with the
|
||||
recorded result. Also clears the lease columns so a stale
|
||||
``lease_expires_at`` doesn't show up in operator dashboards.
|
||||
No-op if the row is already terminal — preserves the first
|
||||
writer's outcome on a crash + retry.
|
||||
"""
|
||||
if status not in ("completed", "failed"):
|
||||
raise ValueError(f"finalize_task: invalid status {status!r}")
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_dedup
|
||||
SET status = :status,
|
||||
result_json = CAST(:result_json AS jsonb),
|
||||
lease_owner_id = NULL,
|
||||
lease_expires_at = NULL
|
||||
WHERE idempotency_key = :key
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"status": status,
|
||||
"result_json": _jsonb(result_json),
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
# --- housekeeping ------------------------------------------------------
|
||||
|
||||
def cleanup_expired(self) -> dict:
|
||||
"""Delete rows past TTL from both dedup tables; return per-table counts.
|
||||
|
||||
The TTL-aware upserts already prevent stale rows from blocking new
|
||||
work, so this is purely housekeeping — bounds table growth and
|
||||
keeps test isolation cheap. Safe to run concurrently with other
|
||||
writers: a same-key INSERT racing the DELETE will either find no
|
||||
row (acts as a fresh insert) or find a fresh row (re-created
|
||||
between DELETE and conflict-check), neither of which is wrong.
|
||||
"""
|
||||
task_deleted = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM task_dedup
|
||||
WHERE created_at <= now() - CAST(:ttl AS interval)
|
||||
"""
|
||||
),
|
||||
{"ttl": DEDUP_TTL_INTERVAL},
|
||||
).rowcount
|
||||
webhook_deleted = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM webhook_dedup
|
||||
WHERE created_at <= now() - CAST(:ttl AS interval)
|
||||
"""
|
||||
),
|
||||
{"ttl": DEDUP_TTL_INTERVAL},
|
||||
).rowcount
|
||||
return {
|
||||
"task_dedup_deleted": int(task_deleted or 0),
|
||||
"webhook_dedup_deleted": int(webhook_deleted or 0),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Repository for ``ingest_chunk_progress``; per-source resume + heartbeat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class IngestChunkProgressRepository:
|
||||
"""Read/write helpers for ``ingest_chunk_progress``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def init_progress(
|
||||
self,
|
||||
source_id: str,
|
||||
total_chunks: int,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Upsert the progress row, scoped by ``attempt_id``.
|
||||
|
||||
On conflict the upsert distinguishes two cases:
|
||||
|
||||
- **Same attempt** (``attempt_id`` matches the stored value):
|
||||
this is a Celery autoretry of the same task — preserve
|
||||
``last_index`` / ``embedded_chunks`` so the embed loop resumes
|
||||
from the checkpoint. Only ``total_chunks`` and
|
||||
``last_updated`` get refreshed.
|
||||
- **Different attempt** (a fresh invocation: manual reingest,
|
||||
scheduled sync, or any caller that didn't pass an
|
||||
``attempt_id``): reset ``last_index`` to ``-1`` and
|
||||
``embedded_chunks`` to ``0`` so the loop starts from chunk 0.
|
||||
This prevents a completed checkpoint from any prior run
|
||||
poisoning the index.
|
||||
|
||||
``IS NOT DISTINCT FROM`` treats two NULLs as equal — so legacy
|
||||
rows with NULL ``attempt_id`` resume against another NULL
|
||||
caller (e.g. test fixtures), but get reset the moment a real
|
||||
``attempt_id`` arrives.
|
||||
|
||||
Both branches also reset ``status`` to ``'active'``, clearing a
|
||||
prior reconciler ``'stalled'`` escalation.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ingest_chunk_progress (
|
||||
source_id, total_chunks, embedded_chunks, last_index,
|
||||
attempt_id, last_updated
|
||||
)
|
||||
VALUES (
|
||||
CAST(:source_id AS uuid), :total_chunks, 0, -1,
|
||||
:attempt_id, now()
|
||||
)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
total_chunks = EXCLUDED.total_chunks,
|
||||
last_updated = now(),
|
||||
last_index = CASE
|
||||
WHEN ingest_chunk_progress.attempt_id
|
||||
IS NOT DISTINCT FROM EXCLUDED.attempt_id
|
||||
THEN ingest_chunk_progress.last_index
|
||||
ELSE -1
|
||||
END,
|
||||
embedded_chunks = CASE
|
||||
WHEN ingest_chunk_progress.attempt_id
|
||||
IS NOT DISTINCT FROM EXCLUDED.attempt_id
|
||||
THEN ingest_chunk_progress.embedded_chunks
|
||||
ELSE 0
|
||||
END,
|
||||
attempt_id = EXCLUDED.attempt_id,
|
||||
status = 'active'
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"source_id": str(source_id),
|
||||
"total_chunks": int(total_chunks),
|
||||
"attempt_id": attempt_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def record_chunk(
|
||||
self, source_id: str, last_index: int, embedded_chunks: int
|
||||
) -> None:
|
||||
"""Persist progress after a chunk is embedded."""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE ingest_chunk_progress
|
||||
SET last_index = :last_index,
|
||||
embedded_chunks = :embedded_chunks,
|
||||
last_updated = now()
|
||||
WHERE source_id = CAST(:source_id AS uuid)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"source_id": str(source_id),
|
||||
"last_index": int(last_index),
|
||||
"embedded_chunks": int(embedded_chunks),
|
||||
},
|
||||
)
|
||||
|
||||
def get_progress(self, source_id: str) -> Optional[dict]:
|
||||
"""Return the progress row for ``source_id`` if it exists."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM ingest_chunk_progress "
|
||||
"WHERE source_id = CAST(:source_id AS uuid)"
|
||||
),
|
||||
{"source_id": str(source_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete(self, source_id: str) -> bool:
|
||||
"""Delete the progress row for ``source_id``.
|
||||
|
||||
A manual reingest supersedes any prior ingest state — including a
|
||||
reconciler ``'stalled'`` escalation — so dropping the row clears
|
||||
the derived ``failed`` ingest status the sources list shows.
|
||||
Returns ``True`` when a row was removed.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM ingest_chunk_progress "
|
||||
"WHERE source_id = CAST(:source_id AS uuid)"
|
||||
),
|
||||
{"source_id": str(source_id)},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def bump_heartbeat(self, source_id: str) -> None:
|
||||
"""Refresh ``last_updated`` so the row looks alive to the reconciler."""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE ingest_chunk_progress
|
||||
SET last_updated = now()
|
||||
WHERE source_id = CAST(:source_id AS uuid)
|
||||
"""
|
||||
),
|
||||
{"source_id": str(source_id)},
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Repository for the ``memories`` table.
|
||||
|
||||
Covers the operations in ``application/agents/tools/memory.py``:
|
||||
- upsert (create/overwrite file)
|
||||
- find by path (view file)
|
||||
- find by path prefix (view directory, regex scan)
|
||||
- delete by path / path prefix
|
||||
- rename (update path)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class MemoriesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def upsert(self, user_id: str, tool_id: str, path: str, content: str) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO memories (user_id, tool_id, path, content)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :path, :content)
|
||||
ON CONFLICT (user_id, tool_id, path)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = now()
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "path": path, "content": content},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get_by_path(self, user_id: str, tool_id: str, path: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM memories WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND path = :path"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "path": path},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_by_prefix(self, user_id: str, tool_id: str, prefix: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM memories WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND path LIKE :prefix"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "prefix": prefix + "%"},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def delete_by_path(self, user_id: str, tool_id: str, path: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM memories WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND path = :path"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "path": path},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_prefix(self, user_id: str, tool_id: str, prefix: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM memories WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND path LIKE :prefix"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "prefix": prefix + "%"},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_all(self, user_id: str, tool_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM memories WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid)"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_orphans(self, keep_tool_ids: Optional[list[str]] = None) -> int:
|
||||
"""Delete memories whose tool_id has no user_tools row, except keep_tool_ids."""
|
||||
keep = [str(tid) for tid in (keep_tool_ids or [])]
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM memories
|
||||
WHERE tool_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_tools u WHERE u.id = memories.tool_id
|
||||
)
|
||||
AND NOT (tool_id = ANY(CAST(:keep AS uuid[])))
|
||||
"""
|
||||
),
|
||||
{"keep": keep},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def update_path(self, user_id: str, tool_id: str, old_path: str, new_path: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE memories SET path = :new_path, updated_at = now() "
|
||||
"WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid) AND path = :old_path"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "old_path": old_path, "new_path": new_path},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Repository for ``message_events`` — the chat-stream snapshot journal.
|
||||
|
||||
``record`` / ``bulk_record`` write per-yield events; ``read_after``
|
||||
replays rows past a cursor for reconnect snapshots. Composite PK
|
||||
``(message_id, sequence_no)`` raises ``IntegrityError`` on duplicates.
|
||||
Callers must use short-lived per-call transactions — long-lived
|
||||
transactions hide writes from reconnecting clients on a separate
|
||||
connection and turn one bad row into ``InFailedSqlTransaction``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageEventsRepository:
|
||||
"""Read/write helpers for ``message_events``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def record(
|
||||
self,
|
||||
message_id: str,
|
||||
sequence_no: int,
|
||||
event_type: str,
|
||||
payload: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Append a single event to the journal.
|
||||
|
||||
At this raw repo layer ``payload`` is preserved as-is when not
|
||||
``None`` (lists, scalars, and dicts all round-trip via JSONB);
|
||||
``None`` substitutes an empty object so the column's NOT NULL
|
||||
invariant holds. The streaming-route wrapper
|
||||
``application/streaming/message_journal.py::record_event``
|
||||
tightens this contract to dicts only — the live and replay
|
||||
paths reconstruct non-dict payloads differently, so the wrapper
|
||||
rejects them at the gate. Direct callers of this repo method
|
||||
(cleanup tasks, tests, future ad-hoc consumers) keep the wider
|
||||
JSONB-compatible surface.
|
||||
|
||||
Raises ``sqlalchemy.exc.IntegrityError`` on duplicate
|
||||
``(message_id, sequence_no)`` and ``DataError`` on a malformed
|
||||
``message_id`` UUID. Both abort the surrounding transaction —
|
||||
callers must run inside a short-lived per-event session
|
||||
(see module docstring).
|
||||
"""
|
||||
if not event_type:
|
||||
raise ValueError("event_type must be a non-empty string")
|
||||
materialised_payload = payload if payload is not None else {}
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO message_events (
|
||||
message_id, sequence_no, event_type, payload
|
||||
) VALUES (
|
||||
CAST(:message_id AS uuid), :sequence_no, :event_type,
|
||||
CAST(:payload AS jsonb)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"message_id": str(message_id),
|
||||
"sequence_no": int(sequence_no),
|
||||
"event_type": event_type,
|
||||
"payload": json.dumps(materialised_payload),
|
||||
},
|
||||
)
|
||||
|
||||
def bulk_record(
|
||||
self,
|
||||
message_id: str,
|
||||
events: list[tuple[int, str, dict]],
|
||||
) -> None:
|
||||
"""Append multiple events for ``message_id`` in one INSERT.
|
||||
|
||||
``events`` is a list of ``(sequence_no, event_type, payload)``
|
||||
tuples. SQLAlchemy ``executemany`` issues one bulk INSERT;
|
||||
Postgres treats the whole batch as one statement, so an
|
||||
IntegrityError on any row aborts the entire batch.
|
||||
|
||||
Caller contract: on IntegrityError, do NOT retry this method
|
||||
with the same batch — fall back to per-row ``record()`` calls
|
||||
(each in its own short-lived session) so a single colliding
|
||||
seq doesn't drop the rest of the batch. ``BatchedJournalWriter``
|
||||
in ``application/streaming/message_journal.py`` is the canonical
|
||||
consumer.
|
||||
"""
|
||||
if not events:
|
||||
return
|
||||
params = [
|
||||
{
|
||||
"message_id": str(message_id),
|
||||
"sequence_no": int(seq),
|
||||
"event_type": event_type,
|
||||
"payload": json.dumps(payload if payload is not None else {}),
|
||||
}
|
||||
for seq, event_type, payload in events
|
||||
]
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO message_events (
|
||||
message_id, sequence_no, event_type, payload
|
||||
) VALUES (
|
||||
CAST(:message_id AS uuid), :sequence_no, :event_type,
|
||||
CAST(:payload AS jsonb)
|
||||
)
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
def read_after(
|
||||
self,
|
||||
message_id: str,
|
||||
last_sequence_no: Optional[int] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Return events with ``sequence_no > last_sequence_no``.
|
||||
|
||||
``last_sequence_no=None`` returns the full backlog. Rows are
|
||||
returned in ascending ``sequence_no`` order. The composite PK
|
||||
is the snapshot read index for this scan — Postgres typically
|
||||
picks an in-order index range scan, though for highly mixed
|
||||
data the planner may pick a bitmap+sort. Either way the result
|
||||
is sorted on ``sequence_no``.
|
||||
|
||||
When ``user_id`` is given the scan joins ``conversation_messages``
|
||||
and filters on ``cm.user_id`` — a non-owner gets an empty result.
|
||||
This lets the reconnect reader re-assert ownership at the data
|
||||
layer rather than trusting only the route gate.
|
||||
|
||||
Returns a ``list`` (not a generator) so the underlying
|
||||
``Result`` is fully drained before the caller can issue
|
||||
another query on the same connection.
|
||||
"""
|
||||
cursor = -1 if last_sequence_no is None else int(last_sequence_no)
|
||||
params = {"message_id": str(message_id), "cursor": cursor}
|
||||
if user_id is None:
|
||||
sql = """
|
||||
SELECT message_id, sequence_no, event_type, payload, created_at
|
||||
FROM message_events
|
||||
WHERE message_id = CAST(:message_id AS uuid)
|
||||
AND sequence_no > :cursor
|
||||
ORDER BY sequence_no ASC
|
||||
"""
|
||||
else:
|
||||
params["u"] = user_id
|
||||
sql = """
|
||||
SELECT me.message_id, me.sequence_no, me.event_type,
|
||||
me.payload, me.created_at
|
||||
FROM message_events me
|
||||
JOIN conversation_messages cm ON cm.id = me.message_id
|
||||
WHERE me.message_id = CAST(:message_id AS uuid)
|
||||
AND cm.user_id = :u
|
||||
AND me.sequence_no > :cursor
|
||||
ORDER BY me.sequence_no ASC
|
||||
"""
|
||||
rows = self._conn.execute(text(sql), params).fetchall()
|
||||
return [row_to_dict(row) for row in rows]
|
||||
|
||||
def cleanup_older_than(self, ttl_days: int) -> int:
|
||||
"""Delete journal rows older than ``ttl_days``. Returns row count.
|
||||
|
||||
Reconnect-replay is meaningful only for streams the client
|
||||
could plausibly still be waiting on, so old rows are dead
|
||||
weight. The ``message_events_created_at_idx`` btree makes the
|
||||
range delete a cheap index scan even on large tables.
|
||||
"""
|
||||
if ttl_days <= 0:
|
||||
raise ValueError("ttl_days must be positive")
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM message_events
|
||||
WHERE created_at < now() - make_interval(days => :ttl_days)
|
||||
"""
|
||||
),
|
||||
{"ttl_days": int(ttl_days)},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
def reconstruct_partial(self, message_id: str) -> dict:
|
||||
"""Rebuild partial response/thought/sources/tool_calls from journal events.
|
||||
|
||||
``answer``/``thought`` chunks concat in seq order; ``source``
|
||||
carries the full list at emit time (last-wins). ``tool_calls``
|
||||
and per-call ``tool_call`` events are merged by ``call_id`` —
|
||||
the most recent event for each call wins, and first-seen order
|
||||
is preserved. An empty bulk ``tool_calls: []`` is a no-op (the
|
||||
classic agent yields one at end-of-turn even when paused, and
|
||||
wiping the overlay there would erase the live awaiting-approval
|
||||
entry).
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT sequence_no, event_type, payload
|
||||
FROM message_events
|
||||
WHERE message_id = CAST(:message_id AS uuid)
|
||||
ORDER BY sequence_no ASC
|
||||
"""
|
||||
),
|
||||
{"message_id": str(message_id)},
|
||||
).fetchall()
|
||||
|
||||
response_parts: list[str] = []
|
||||
thought_parts: list[str] = []
|
||||
sources: list = []
|
||||
tool_calls: list = []
|
||||
# Per-call overlay: maps call_id -> index into ``tool_calls`` so a
|
||||
# later event for the same call replaces the earlier one in place
|
||||
# and preserves first-seen ordering. Bulk ``tool_calls`` emits
|
||||
# merge into the same overlay rather than reseeding so they can't
|
||||
# erase a per-call entry that arrived earlier in the stream.
|
||||
tool_call_index: dict[str, int] = {}
|
||||
|
||||
def _overlay(entry: dict) -> None:
|
||||
call_id = entry.get("call_id")
|
||||
if not call_id:
|
||||
return
|
||||
existing = tool_call_index.get(call_id)
|
||||
if existing is None:
|
||||
tool_call_index[call_id] = len(tool_calls)
|
||||
tool_calls.append(entry)
|
||||
else:
|
||||
tool_calls[existing] = entry
|
||||
|
||||
for row in rows:
|
||||
payload = row.payload
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
etype = row.event_type
|
||||
if etype == "answer":
|
||||
chunk = payload.get("answer")
|
||||
if isinstance(chunk, str):
|
||||
response_parts.append(chunk)
|
||||
elif etype == "thought":
|
||||
chunk = payload.get("thought")
|
||||
if isinstance(chunk, str):
|
||||
thought_parts.append(chunk)
|
||||
elif etype == "source":
|
||||
src = payload.get("source")
|
||||
if isinstance(src, list):
|
||||
sources = src
|
||||
elif etype == "tool_calls":
|
||||
tcs = payload.get("tool_calls")
|
||||
if not isinstance(tcs, list) or not tcs:
|
||||
# Empty bulk is a no-op: the classic-agent end-of-turn
|
||||
# yield emits one even on a paused turn where
|
||||
# ``self.tool_calls`` is empty, and the per-call
|
||||
# overlay carries the awaiting-approval entry.
|
||||
continue
|
||||
for tc in tcs:
|
||||
if isinstance(tc, dict):
|
||||
_overlay(tc)
|
||||
elif etype == "tool_call":
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
_overlay(data)
|
||||
|
||||
return {
|
||||
"response": "".join(response_parts),
|
||||
"thought": "".join(thought_parts),
|
||||
"sources": sources,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
|
||||
def latest_sequence_no(self, message_id: str) -> Optional[int]:
|
||||
"""Largest ``sequence_no`` recorded for ``message_id``, or ``None``.
|
||||
|
||||
Used by the route to seed the per-stream allocator on retry /
|
||||
process restart so a re-run continues numbering instead of
|
||||
trampling earlier entries with duplicate sequence_no.
|
||||
"""
|
||||
# ``MAX`` always returns one row — NULL when the journal is
|
||||
# empty — so we test the value, not the row presence.
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT MAX(sequence_no) AS s
|
||||
FROM message_events
|
||||
WHERE message_id = CAST(:message_id AS uuid)
|
||||
"""
|
||||
),
|
||||
{"message_id": str(message_id)},
|
||||
).first()
|
||||
value = row[0] if row is not None else None
|
||||
return int(value) if value is not None else None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Repository for the ``notes`` table.
|
||||
|
||||
Covers the operations in ``application/agents/tools/notes.py``.
|
||||
Note: the Mongo schema stores a single ``note`` text field per (user_id, tool_id),
|
||||
while the Postgres schema has ``title`` + ``content``. During dual-write,
|
||||
title is set to a default and content holds the note text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
class NotesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def upsert(self, user_id: str, tool_id: str, title: str, content: str) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO notes (user_id, tool_id, title, content)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :title, :content)
|
||||
ON CONFLICT (user_id, tool_id)
|
||||
DO UPDATE SET content = EXCLUDED.content, title = EXCLUDED.title, updated_at = now()
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "title": title, "content": content},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get_for_user_tool(self, user_id: str, tool_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM notes WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid)"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get(self, note_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM notes WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": note_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete(self, user_id: str, tool_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM notes WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid)"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str) -> Optional[dict]:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM notes WHERE legacy_mongo_id = :legacy"),
|
||||
{"legacy": legacy_mongo_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, identifier: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve a note by PG UUID or legacy Mongo ObjectId.
|
||||
|
||||
Picks the lookup path from the id shape so non-UUID input never
|
||||
reaches ``CAST(:id AS uuid)`` — that cast raises on the server
|
||||
and poisons the enclosing transaction, making any subsequent
|
||||
query on the same connection fail.
|
||||
"""
|
||||
if looks_like_uuid(identifier):
|
||||
doc = self.get(identifier, user_id)
|
||||
if doc is not None:
|
||||
return doc
|
||||
legacy = self.get_by_legacy_id(identifier)
|
||||
if legacy and legacy.get("user_id") == user_id:
|
||||
return legacy
|
||||
return None
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Repository for the ``pending_tool_state`` table.
|
||||
|
||||
Mirrors the continuation service's three operations on
|
||||
``pending_tool_state`` in Mongo:
|
||||
|
||||
- save_state → upsert (INSERT ... ON CONFLICT DO UPDATE)
|
||||
- load_state → find_one by (conversation_id, user_id)
|
||||
- delete_state → delete_one by (conversation_id, user_id)
|
||||
|
||||
Adds ``mark_resuming`` so a resumed run can claim a row without
|
||||
deleting it; a separate ``revert_stale_resuming`` flips abandoned
|
||||
``resuming`` rows back to ``pending`` so a crashed worker doesn't
|
||||
strand the user.
|
||||
|
||||
Plus a cleanup method for the Celery beat task that replaces Mongo's
|
||||
TTL index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
PENDING_STATE_TTL_SECONDS = 30 * 60 # 1800 seconds
|
||||
|
||||
|
||||
class PendingToolStateRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def save_state(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
messages: list,
|
||||
pending_tool_calls: list,
|
||||
tools_dict: dict,
|
||||
tool_schemas: list,
|
||||
agent_config: dict,
|
||||
client_tools: list | None = None,
|
||||
ttl_seconds: int = PENDING_STATE_TTL_SECONDS,
|
||||
) -> dict:
|
||||
"""Upsert pending tool state.
|
||||
|
||||
Mirrors Mongo's ``replace_one(..., upsert=True)``.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = datetime.fromtimestamp(
|
||||
now.timestamp() + ttl_seconds, tz=timezone.utc,
|
||||
)
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO pending_tool_state
|
||||
(conversation_id, user_id, messages, pending_tool_calls,
|
||||
tools_dict, tool_schemas, agent_config, client_tools,
|
||||
created_at, expires_at)
|
||||
VALUES
|
||||
(CAST(:conv_id AS uuid), :user_id,
|
||||
CAST(:messages AS jsonb), CAST(:pending AS jsonb),
|
||||
CAST(:tools_dict AS jsonb), CAST(:schemas AS jsonb),
|
||||
CAST(:agent_config AS jsonb), CAST(:client_tools AS jsonb),
|
||||
:created_at, :expires_at)
|
||||
ON CONFLICT (conversation_id, user_id) DO UPDATE SET
|
||||
messages = EXCLUDED.messages,
|
||||
pending_tool_calls = EXCLUDED.pending_tool_calls,
|
||||
tools_dict = EXCLUDED.tools_dict,
|
||||
tool_schemas = EXCLUDED.tool_schemas,
|
||||
agent_config = EXCLUDED.agent_config,
|
||||
client_tools = EXCLUDED.client_tools,
|
||||
created_at = EXCLUDED.created_at,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
status = 'pending',
|
||||
resumed_at = NULL
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"conv_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"messages": json.dumps(messages, cls=PGNativeJSONEncoder),
|
||||
"pending": json.dumps(pending_tool_calls, cls=PGNativeJSONEncoder),
|
||||
"tools_dict": json.dumps(tools_dict, cls=PGNativeJSONEncoder),
|
||||
"schemas": json.dumps(tool_schemas, cls=PGNativeJSONEncoder),
|
||||
"agent_config": json.dumps(agent_config, cls=PGNativeJSONEncoder),
|
||||
"client_tools": (
|
||||
json.dumps(client_tools, cls=PGNativeJSONEncoder)
|
||||
if client_tools is not None else None
|
||||
),
|
||||
"created_at": now,
|
||||
"expires_at": expires,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def load_state(self, conversation_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM pending_tool_state "
|
||||
"WHERE conversation_id = CAST(:conv_id AS uuid) "
|
||||
"AND user_id = :user_id"
|
||||
),
|
||||
{"conv_id": conversation_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete_state(self, conversation_id: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM pending_tool_state "
|
||||
"WHERE conversation_id = CAST(:conv_id AS uuid) "
|
||||
"AND user_id = :user_id"
|
||||
),
|
||||
{"conv_id": conversation_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def mark_resuming(self, conversation_id: str, user_id: str) -> bool:
|
||||
"""Flip a pending row to ``resuming`` and stamp ``resumed_at``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_tool_state
|
||||
SET status = 'resuming', resumed_at = clock_timestamp()
|
||||
WHERE conversation_id = CAST(:conv_id AS uuid)
|
||||
AND user_id = :user_id
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{"conv_id": conversation_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def revert_stale_resuming(
|
||||
self,
|
||||
grace_seconds: int = 600,
|
||||
ttl_extension_seconds: int = PENDING_STATE_TTL_SECONDS,
|
||||
) -> int:
|
||||
"""Revert ``resuming`` rows older than ``grace_seconds`` to ``pending``; bump TTL."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_tool_state
|
||||
SET status = 'pending',
|
||||
resumed_at = NULL,
|
||||
expires_at = clock_timestamp()
|
||||
+ make_interval(secs => :ttl)
|
||||
WHERE status = 'resuming'
|
||||
AND resumed_at
|
||||
< clock_timestamp() - make_interval(secs => :grace)
|
||||
"""
|
||||
),
|
||||
{"grace": grace_seconds, "ttl": ttl_extension_seconds},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def cleanup_expired(self) -> list[dict]:
|
||||
"""Delete TTL-expired rows; return their ``(conversation_id, user_id)``.
|
||||
|
||||
Replaces Mongo's ``expireAfterSeconds=0`` TTL index. Intended to
|
||||
be called from a Celery beat task every 60 seconds. The deleted
|
||||
rows are returned so the caller can revoke any approval prompt
|
||||
tied to the now-gone resumable state.
|
||||
"""
|
||||
# clock_timestamp() — not now() — since the latter is frozen to the
|
||||
# start of the transaction, which would let state that has just
|
||||
# expired survive one more cleanup tick.
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM pending_tool_state WHERE expires_at < clock_timestamp() "
|
||||
"RETURNING conversation_id, user_id"
|
||||
)
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Repository for the ``prompts`` table.
|
||||
|
||||
Covers every operation the legacy Mongo code performs on
|
||||
``prompts_collection``:
|
||||
|
||||
1. ``insert_one`` in prompts/routes.py (create)
|
||||
2. ``find`` by user in prompts/routes.py (list)
|
||||
3. ``find_one`` by id+user in prompts/routes.py (get single)
|
||||
4. ``find_one`` by id only in stream_processor.py (get content for rendering)
|
||||
5. ``update_one`` in prompts/routes.py (update name+content)
|
||||
6. ``delete_one`` in prompts/routes.py (delete)
|
||||
7. ``find_one`` + ``insert_one`` in seeder.py (upsert by user+name+content)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
class PromptsRepository:
|
||||
"""Postgres-backed replacement for Mongo ``prompts_collection``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
*,
|
||||
legacy_mongo_id: str | None = None,
|
||||
) -> dict:
|
||||
sql = """
|
||||
INSERT INTO prompts (user_id, name, content, legacy_mongo_id)
|
||||
VALUES (:user_id, :name, :content, :legacy_mongo_id)
|
||||
RETURNING *
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(sql),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"content": content,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, prompt_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM prompts WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": prompt_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str, user_id: str | None = None) -> Optional[dict]:
|
||||
"""Fetch a prompt by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM prompts WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, identifier: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve a prompt by PG UUID or legacy Mongo ObjectId.
|
||||
|
||||
Picks the lookup path from the id shape so non-UUID input never
|
||||
reaches ``CAST(:id AS uuid)`` — that cast raises on the server
|
||||
and poisons the enclosing transaction, making any subsequent
|
||||
query on the same connection fail.
|
||||
"""
|
||||
if looks_like_uuid(identifier):
|
||||
doc = self.get(identifier, user_id)
|
||||
if doc is not None:
|
||||
return doc
|
||||
return self.get_by_legacy_id(identifier, user_id)
|
||||
|
||||
def get_for_rendering(self, prompt_id: str) -> Optional[dict]:
|
||||
"""Fetch prompt content by ID without user scoping.
|
||||
|
||||
Used only by stream_processor to render a prompt whose owner is
|
||||
not known at call time. Do NOT use in user-facing routes.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM prompts WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": prompt_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM prompts WHERE user_id = :user_id ORDER BY created_at"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_by_ids(self, prompt_ids) -> list[dict]:
|
||||
"""Fetch prompts whose id is in ``prompt_ids`` (team-shared listing path)."""
|
||||
ids = [str(p) for p in prompt_ids if looks_like_uuid(str(p))]
|
||||
if not ids:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM prompts WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"),
|
||||
{"ids": ids},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update_by_id(
|
||||
self, prompt_id: str, name: str, content: str, expected_updated_at=None
|
||||
) -> Optional[bool]:
|
||||
"""Update a prompt by id WITHOUT user scoping (team ``editor`` write path).
|
||||
|
||||
Caller must have verified an ``editor`` grant first. Returns None on an
|
||||
optimistic-lock version mismatch so the route can answer 409.
|
||||
"""
|
||||
sql = (
|
||||
"UPDATE prompts SET name = :name, content = :content, updated_at = now() "
|
||||
"WHERE id = CAST(:id AS uuid)"
|
||||
)
|
||||
params: dict = {"id": prompt_id, "name": name, "content": content}
|
||||
if expected_updated_at is not None:
|
||||
sql += " AND updated_at = CAST(:expected AS timestamptz)"
|
||||
params["expected"] = expected_updated_at
|
||||
result = self._conn.execute(text(sql), params)
|
||||
if result.rowcount > 0:
|
||||
return True
|
||||
if expected_updated_at is not None and self.get_for_rendering(str(prompt_id)) is not None:
|
||||
return None
|
||||
return False
|
||||
|
||||
def update(self, prompt_id: str, user_id: str, name: str, content: str) -> None:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE prompts
|
||||
SET name = :name, content = :content, updated_at = now()
|
||||
WHERE id = CAST(:id AS uuid) AND user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"id": prompt_id, "user_id": user_id, "name": name, "content": content},
|
||||
)
|
||||
|
||||
def update_by_legacy_id(
|
||||
self,
|
||||
legacy_mongo_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
) -> bool:
|
||||
"""Update a prompt addressed by the Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE prompts
|
||||
SET name = :name, content = :content, updated_at = now()
|
||||
WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"legacy_id": legacy_mongo_id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, prompt_id: str, user_id: str) -> None:
|
||||
self._conn.execute(
|
||||
text("DELETE FROM prompts WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": prompt_id, "user_id": user_id},
|
||||
)
|
||||
|
||||
def delete_by_legacy_id(self, legacy_mongo_id: str, user_id: str) -> bool:
|
||||
"""Delete a prompt addressed by the Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM prompts "
|
||||
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
|
||||
),
|
||||
{"legacy_id": legacy_mongo_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def find(self, user_id: str, name: str, content: str) -> Optional[dict]:
|
||||
"""Return a prompt exactly matching (user, name, content), or None."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM prompts WHERE user_id = :user_id AND name = :name AND content = :content"
|
||||
),
|
||||
{"user_id": user_id, "name": name, "content": content},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_or_create(self, user_id: str, name: str, content: str) -> dict:
|
||||
"""Return existing prompt matching (user, name, content), or create one.
|
||||
|
||||
Used by the seeder and agent YAML import to avoid duplicating prompts.
|
||||
"""
|
||||
existing = self.find(user_id, name, content)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return self.create(user_id, name, content)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Repository for reconciliation sweeps over stuck durability rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class ReconciliationRepository:
|
||||
"""Sweeps and terminal writes for the reconciler beat task."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def find_and_lock_stuck_messages(
|
||||
self, *, age_minutes: int = 5, limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Lock stuck pending/streaming messages skipping live resumes.
|
||||
|
||||
Staleness rides on the **later of** ``cm.timestamp`` (creation)
|
||||
and ``message_metadata.last_heartbeat_at`` (route heartbeat). An
|
||||
in-flight stream that re-stamps the heartbeat each minute stays
|
||||
out of the sweep; reconciler-side writes deliberately don't
|
||||
touch either column so the per-row attempts counter advances
|
||||
across ticks. Liveness exemption covers both ``pending`` (paused
|
||||
waiting for resume) and ``resuming`` (actively executing)
|
||||
``pending_tool_state`` rows so a paused message survives until
|
||||
the PT row's own TTL retires it.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT cm.id, cm.conversation_id, cm.user_id, cm.timestamp,
|
||||
cm.message_metadata
|
||||
FROM conversation_messages cm
|
||||
WHERE cm.status IN ('pending', 'streaming')
|
||||
AND cm.timestamp < now() - make_interval(mins => :age)
|
||||
AND COALESCE(
|
||||
(cm.message_metadata->>'last_heartbeat_at')::timestamptz,
|
||||
cm.timestamp
|
||||
) < now() - make_interval(mins => :age)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pending_tool_state pts
|
||||
WHERE pts.conversation_id = cm.conversation_id
|
||||
AND pts.user_id = cm.user_id
|
||||
AND (
|
||||
(pts.status = 'pending'
|
||||
AND pts.expires_at > now())
|
||||
OR
|
||||
(pts.status = 'resuming'
|
||||
AND pts.resumed_at
|
||||
> now() - interval '10 minutes')
|
||||
)
|
||||
)
|
||||
ORDER BY cm.timestamp ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE OF cm SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": age_minutes, "limit": limit},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_and_lock_proposed_tool_calls(
|
||||
self, *, age_minutes: int = 5, limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Lock tool_call_attempts that never advanced past ``proposed``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT call_id, message_id, tool_id, tool_name, action_name,
|
||||
arguments, attempted_at, updated_at
|
||||
FROM tool_call_attempts
|
||||
WHERE status = 'proposed'
|
||||
AND attempted_at < now() - make_interval(mins => :age)
|
||||
ORDER BY attempted_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": age_minutes, "limit": limit},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_and_lock_executed_tool_calls(
|
||||
self, *, age_minutes: int = 15, limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Lock tool_call_attempts stuck in ``executed`` past confirm window."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT call_id, message_id, tool_id, tool_name, action_name,
|
||||
arguments, result, attempted_at, updated_at
|
||||
FROM tool_call_attempts
|
||||
WHERE status = 'executed'
|
||||
AND updated_at < now() - make_interval(mins => :age)
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": age_minutes, "limit": limit},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_and_lock_stalled_ingests(
|
||||
self, *, age_minutes: int = 30, limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Lock still-active ingest checkpoints with a silent heartbeat.
|
||||
|
||||
The ``status = 'active'`` filter skips rows already escalated to
|
||||
``'stalled'``, so a dead ingest is alerted once, not every tick.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT icp.source_id, icp.total_chunks, icp.embedded_chunks,
|
||||
icp.last_index, icp.last_updated,
|
||||
s.user_id, s.name AS source_name
|
||||
FROM ingest_chunk_progress icp
|
||||
LEFT JOIN sources s ON s.id = icp.source_id
|
||||
WHERE icp.last_updated < now() - make_interval(mins => :age)
|
||||
AND icp.embedded_chunks < icp.total_chunks
|
||||
AND icp.status = 'active'
|
||||
ORDER BY icp.last_updated ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE OF icp SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": age_minutes, "limit": limit},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def mark_ingest_stalled(self, source_id: str) -> bool:
|
||||
"""Escalate a stalled checkpoint to terminal ``status='stalled'``.
|
||||
|
||||
Drops the row out of the sweep so the reconciler alerts once;
|
||||
``init_progress`` flips it back to ``'active'`` on reingest.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE ingest_chunk_progress SET status = 'stalled' "
|
||||
"WHERE source_id = CAST(:sid AS uuid)"
|
||||
),
|
||||
{"sid": str(source_id)},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def increment_message_reconcile_attempts(self, message_id: str) -> int:
|
||||
"""Bump ``message_metadata.reconcile_attempts`` and return the new count."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE conversation_messages
|
||||
SET message_metadata = jsonb_set(
|
||||
COALESCE(message_metadata, '{}'::jsonb),
|
||||
'{reconcile_attempts}',
|
||||
to_jsonb(
|
||||
COALESCE(
|
||||
(message_metadata->>'reconcile_attempts')::int,
|
||||
0
|
||||
) + 1
|
||||
)
|
||||
)
|
||||
WHERE id = CAST(:message_id AS uuid)
|
||||
RETURNING (message_metadata->>'reconcile_attempts')::int
|
||||
AS new_count
|
||||
"""
|
||||
),
|
||||
{"message_id": message_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def mark_message_failed(self, message_id: str, *, error: str) -> bool:
|
||||
"""Flip a message to ``status='failed'`` and stash ``error`` in metadata."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE conversation_messages
|
||||
SET status = 'failed',
|
||||
message_metadata = jsonb_set(
|
||||
COALESCE(message_metadata, '{}'::jsonb),
|
||||
'{error}',
|
||||
to_jsonb(CAST(:error AS text))
|
||||
)
|
||||
WHERE id = CAST(:message_id AS uuid)
|
||||
"""
|
||||
),
|
||||
{"message_id": message_id, "error": error},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def mark_tool_call_failed(self, call_id: str, *, error: str) -> bool:
|
||||
"""Flip a tool_call_attempts row to ``failed`` with ``error``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE tool_call_attempts SET status = 'failed', "
|
||||
"error = :error WHERE call_id = :call_id"
|
||||
),
|
||||
{"call_id": call_id, "error": error},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def find_stuck_idempotency_pending(
|
||||
self,
|
||||
*,
|
||||
max_attempts: int,
|
||||
lease_grace_seconds: int = 60,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Lock ``task_dedup`` rows abandoned past the lease + retry budget.
|
||||
|
||||
A row is "stuck" when:
|
||||
|
||||
- ``status='pending'`` (lease was claimed but never finalised)
|
||||
- ``lease_expires_at`` is past by at least ``lease_grace_seconds``
|
||||
(the heartbeat thread is gone — the lease isn't going to come
|
||||
back)
|
||||
- ``attempt_count >= max_attempts`` (the poison-loop guard
|
||||
should already have escalated this; if it hasn't, the wrapper
|
||||
died before getting there)
|
||||
|
||||
These rows would otherwise sit in ``pending`` until the 24 h
|
||||
TTL aged them out, blocking same-key retries via
|
||||
``_lookup_completed`` returning None for the whole window.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT idempotency_key, task_name, task_id, attempt_count,
|
||||
lease_owner_id, lease_expires_at, created_at
|
||||
FROM task_dedup
|
||||
WHERE status = 'pending'
|
||||
AND lease_expires_at IS NOT NULL
|
||||
AND lease_expires_at
|
||||
< now() - make_interval(secs => :grace)
|
||||
AND attempt_count >= :max_attempts
|
||||
ORDER BY created_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{
|
||||
"max_attempts": int(max_attempts),
|
||||
"grace": int(lease_grace_seconds),
|
||||
"limit": int(limit),
|
||||
},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def mark_idempotency_pending_failed(
|
||||
self, key: str, *, error: str,
|
||||
) -> bool:
|
||||
"""Promote a stuck pending ``task_dedup`` row to ``failed``."""
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
import json
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE task_dedup
|
||||
SET status = 'failed',
|
||||
result_json = CAST(:result AS jsonb),
|
||||
lease_owner_id = NULL,
|
||||
lease_expires_at = NULL
|
||||
WHERE idempotency_key = :key
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"result": json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": error,
|
||||
"reconciled": True,
|
||||
},
|
||||
cls=PGNativeJSONEncoder,
|
||||
),
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Repository for ``schedule_runs`` (record_pending is the dedup primitive)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
_ALLOWED_UPDATES = frozenset(
|
||||
{
|
||||
"status", "started_at", "finished_at", "output", "output_truncated",
|
||||
"error", "error_type", "prompt_tokens", "generated_tokens",
|
||||
"conversation_id", "message_id", "celery_task_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRunsRepository:
|
||||
"""CRUD + dedup insert + reconciliation sweep for ``schedule_runs``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def record_pending(
|
||||
self,
|
||||
schedule_id: str,
|
||||
user_id: str,
|
||||
agent_id: Optional[str],
|
||||
scheduled_for: datetime,
|
||||
*,
|
||||
trigger_source: str = "cron",
|
||||
) -> Optional[dict]:
|
||||
"""Insert a ``pending`` row; ``None`` on conflict (already claimed)."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO schedule_runs (
|
||||
schedule_id, user_id, agent_id, scheduled_for,
|
||||
trigger_source, status
|
||||
) VALUES (
|
||||
CAST(:schedule_id AS uuid),
|
||||
:user_id,
|
||||
CAST(:agent_id AS uuid),
|
||||
:scheduled_for,
|
||||
:trigger_source,
|
||||
'pending'
|
||||
)
|
||||
ON CONFLICT (schedule_id, scheduled_for) DO NOTHING
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"schedule_id": str(schedule_id),
|
||||
"user_id": user_id,
|
||||
"agent_id": str(agent_id) if agent_id else None,
|
||||
"scheduled_for": scheduled_for,
|
||||
"trigger_source": trigger_source,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def record_skipped(
|
||||
self,
|
||||
schedule_id: str,
|
||||
user_id: str,
|
||||
agent_id: Optional[str],
|
||||
scheduled_for: datetime,
|
||||
*,
|
||||
error_type: str,
|
||||
error: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Write a terminal ``skipped`` row; returns ``None`` on conflict."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO schedule_runs (
|
||||
schedule_id, user_id, agent_id, scheduled_for,
|
||||
trigger_source, status, started_at, finished_at,
|
||||
error, error_type
|
||||
) VALUES (
|
||||
CAST(:schedule_id AS uuid),
|
||||
:user_id,
|
||||
CAST(:agent_id AS uuid),
|
||||
:scheduled_for,
|
||||
'cron',
|
||||
'skipped',
|
||||
now(),
|
||||
now(),
|
||||
:error,
|
||||
:error_type
|
||||
)
|
||||
ON CONFLICT (schedule_id, scheduled_for) DO NOTHING
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"schedule_id": str(schedule_id),
|
||||
"user_id": user_id,
|
||||
"agent_id": str(agent_id) if agent_id else None,
|
||||
"scheduled_for": scheduled_for,
|
||||
"error": error,
|
||||
"error_type": error_type,
|
||||
},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get(self, run_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Fetch an owned run row."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM schedule_runs "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": str(run_id), "user_id": user_id},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_internal(self, run_id: str) -> Optional[dict]:
|
||||
"""Fetch a run row with no ownership scoping (worker-only)."""
|
||||
row = self._conn.execute(
|
||||
text("SELECT * FROM schedule_runs WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(run_id)},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def has_active_run(self, schedule_id: str) -> bool:
|
||||
"""True iff a ``pending``/``running`` run exists for the schedule."""
|
||||
scalar = self._conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM schedule_runs "
|
||||
"WHERE schedule_id = CAST(:id AS uuid) "
|
||||
"AND status IN ('pending', 'running') "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"id": str(schedule_id)},
|
||||
).first()
|
||||
return scalar is not None
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
schedule_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Paginated newest-first run log for an owned schedule."""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM schedule_runs
|
||||
WHERE schedule_id = CAST(:id AS uuid) AND user_id = :user_id
|
||||
ORDER BY scheduled_for DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(schedule_id),
|
||||
"user_id": user_id,
|
||||
"limit": int(limit),
|
||||
"offset": int(offset),
|
||||
},
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def update(self, run_id: str, fields: dict) -> Optional[dict]:
|
||||
"""Apply a whitelisted partial update to a run row."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
if not filtered:
|
||||
return self.get_internal(run_id)
|
||||
set_parts: list[str] = []
|
||||
params: dict[str, Any] = {"id": str(run_id)}
|
||||
for key, val in filtered.items():
|
||||
if key in ("conversation_id", "message_id"):
|
||||
set_parts.append(f"{key} = CAST(:{key} AS uuid)")
|
||||
params[key] = str(val) if val else None
|
||||
else:
|
||||
set_parts.append(f"{key} = :{key}")
|
||||
params[key] = val
|
||||
sql = (
|
||||
"UPDATE schedule_runs SET " + ", ".join(set_parts) +
|
||||
" WHERE id = CAST(:id AS uuid) RETURNING *"
|
||||
)
|
||||
row = self._conn.execute(text(sql), params).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def mark_running(self, run_id: str, celery_task_id: Optional[str]) -> bool:
|
||||
"""Flip ``pending`` → ``running`` and stamp ``started_at``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE schedule_runs
|
||||
SET status = 'running',
|
||||
started_at = now(),
|
||||
celery_task_id = :celery_task_id
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND status = 'pending'
|
||||
"""
|
||||
),
|
||||
{"id": str(run_id), "celery_task_id": celery_task_id},
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
def list_stuck_running(
|
||||
self, *, age_minutes: int = 15, limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Lock ``running`` rows past the soft-time-limit envelope."""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM schedule_runs
|
||||
WHERE status = 'running'
|
||||
AND started_at IS NOT NULL
|
||||
AND started_at < now() - make_interval(mins => :age)
|
||||
ORDER BY started_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": int(age_minutes), "limit": int(limit)},
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def list_stuck_pending(
|
||||
self, *, age_minutes: int = 15, limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Lock 'pending' rows whose worker never picked them up (created_at-based)."""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM schedule_runs
|
||||
WHERE status = 'pending'
|
||||
AND started_at IS NULL
|
||||
AND created_at < now() - make_interval(mins => :age)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"age": int(age_minutes), "limit": int(limit)},
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def cleanup_older_than(
|
||||
self,
|
||||
ttl_days: int,
|
||||
*,
|
||||
keep_recent_per_schedule: int = 50,
|
||||
) -> int:
|
||||
"""Trim run rows older than ``ttl_days``, keeping the recent log slice."""
|
||||
if ttl_days <= 0:
|
||||
raise ValueError("ttl_days must be positive")
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM schedule_runs
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY schedule_id
|
||||
ORDER BY scheduled_for DESC
|
||||
) AS rn,
|
||||
created_at
|
||||
FROM schedule_runs
|
||||
) ranked
|
||||
WHERE ranked.rn > :keep
|
||||
AND ranked.created_at < now() - make_interval(days => :ttl)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"keep": int(keep_recent_per_schedule), "ttl": int(ttl_days)},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Repository for the ``schedules`` table (CRUD + dispatcher claim query)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
_ALLOWED_UPDATES = frozenset(
|
||||
{
|
||||
"name", "instruction", "status", "cron", "run_at", "timezone",
|
||||
"next_run_at", "last_run_at", "end_at", "tool_allowlist",
|
||||
"model_id", "token_budget", "consecutive_failure_count",
|
||||
"origin_conversation_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SchedulesRepository:
|
||||
"""CRUD + dispatcher hot path for ``schedules``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
agent_id: Optional[str],
|
||||
trigger_type: str,
|
||||
instruction: str,
|
||||
*,
|
||||
cron: Optional[str] = None,
|
||||
run_at: Optional[datetime] = None,
|
||||
timezone: str = "UTC",
|
||||
next_run_at: Optional[datetime] = None,
|
||||
end_at: Optional[datetime] = None,
|
||||
name: Optional[str] = None,
|
||||
tool_allowlist: Optional[Iterable[str]] = None,
|
||||
model_id: Optional[str] = None,
|
||||
token_budget: Optional[int] = None,
|
||||
origin_conversation_id: Optional[str] = None,
|
||||
created_via: str = "ui",
|
||||
status: str = "active",
|
||||
) -> dict:
|
||||
"""Insert a new schedule and return the populated row."""
|
||||
params = {
|
||||
"user_id": user_id,
|
||||
"agent_id": str(agent_id) if agent_id else None,
|
||||
"trigger_type": trigger_type,
|
||||
"instruction": instruction,
|
||||
"cron": cron,
|
||||
"run_at": run_at,
|
||||
"tz": timezone,
|
||||
"next_run_at": next_run_at,
|
||||
"end_at": end_at,
|
||||
"name": name,
|
||||
"allowlist": json.dumps(list(tool_allowlist or [])),
|
||||
"model_id": model_id,
|
||||
"token_budget": int(token_budget) if token_budget is not None else None,
|
||||
"origin_conversation_id": (
|
||||
str(origin_conversation_id) if origin_conversation_id else None
|
||||
),
|
||||
"created_via": created_via,
|
||||
"status": status,
|
||||
}
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO schedules (
|
||||
user_id, agent_id, trigger_type, instruction, status,
|
||||
cron, run_at, timezone, next_run_at, end_at, name,
|
||||
tool_allowlist, model_id, token_budget,
|
||||
origin_conversation_id, created_via
|
||||
) VALUES (
|
||||
:user_id,
|
||||
CAST(:agent_id AS uuid),
|
||||
:trigger_type,
|
||||
:instruction,
|
||||
:status,
|
||||
:cron,
|
||||
:run_at,
|
||||
:tz,
|
||||
:next_run_at,
|
||||
:end_at,
|
||||
:name,
|
||||
CAST(:allowlist AS jsonb),
|
||||
:model_id,
|
||||
:token_budget,
|
||||
CAST(:origin_conversation_id AS uuid),
|
||||
:created_via
|
||||
) RETURNING *
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
def get(self, schedule_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Fetch an owned schedule (None when missing or owned by another)."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM schedules "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": str(schedule_id), "user_id": user_id},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_internal(self, schedule_id: str) -> Optional[dict]:
|
||||
"""Fetch a schedule with no ownership scoping (worker-only)."""
|
||||
row = self._conn.execute(
|
||||
text("SELECT * FROM schedules WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(schedule_id)},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_for_update(
|
||||
self, schedule_id: str, user_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""Owned fetch with FOR UPDATE; closes the Run-Now TOCTOU."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM schedules "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id "
|
||||
"FOR UPDATE"
|
||||
),
|
||||
{"id": str(schedule_id), "user_id": user_id},
|
||||
).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
statuses: Optional[Iterable[str]] = None,
|
||||
trigger_type: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Owned schedules for an agent, newest-created first."""
|
||||
sql = (
|
||||
"SELECT * FROM schedules "
|
||||
"WHERE agent_id = CAST(:agent_id AS uuid) AND user_id = :user_id"
|
||||
)
|
||||
params: dict[str, Any] = {"agent_id": str(agent_id), "user_id": user_id}
|
||||
if statuses is not None:
|
||||
status_list = [str(s) for s in statuses]
|
||||
if not status_list:
|
||||
return []
|
||||
placeholders = ", ".join(f":s{i}" for i, _ in enumerate(status_list))
|
||||
sql += f" AND status IN ({placeholders})"
|
||||
for i, s in enumerate(status_list):
|
||||
params[f"s{i}"] = s
|
||||
if trigger_type:
|
||||
sql += " AND trigger_type = :trigger_type"
|
||||
params["trigger_type"] = trigger_type
|
||||
sql += " ORDER BY created_at DESC"
|
||||
rows = self._conn.execute(text(sql), params).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def list_for_conversation(
|
||||
self,
|
||||
user_id: str,
|
||||
origin_conversation_id: str,
|
||||
*,
|
||||
statuses: Optional[Iterable[str]] = None,
|
||||
trigger_type: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Owned agentless schedules anchored to an originating conversation."""
|
||||
sql = (
|
||||
"SELECT * FROM schedules "
|
||||
"WHERE user_id = :user_id "
|
||||
"AND agent_id IS NULL "
|
||||
"AND origin_conversation_id = CAST(:conv AS uuid)"
|
||||
)
|
||||
params: dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"conv": str(origin_conversation_id),
|
||||
}
|
||||
if statuses is not None:
|
||||
status_list = [str(s) for s in statuses]
|
||||
if not status_list:
|
||||
return []
|
||||
placeholders = ", ".join(f":s{i}" for i, _ in enumerate(status_list))
|
||||
sql += f" AND status IN ({placeholders})"
|
||||
for i, s in enumerate(status_list):
|
||||
params[f"s{i}"] = s
|
||||
if trigger_type:
|
||||
sql += " AND trigger_type = :trigger_type"
|
||||
params["trigger_type"] = trigger_type
|
||||
sql += " ORDER BY created_at DESC"
|
||||
rows = self._conn.execute(text(sql), params).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def list_for_user(self, user_id: str, *, limit: int = 200) -> list[dict]:
|
||||
"""Owned schedules across all agents — admin / debugging path."""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM schedules WHERE user_id = :user_id "
|
||||
"ORDER BY created_at DESC LIMIT :limit"
|
||||
),
|
||||
{"user_id": user_id, "limit": int(limit)},
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def count_active_for_user(self, user_id: str) -> int:
|
||||
"""Active+paused schedules for quota enforcement."""
|
||||
scalar = self._conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM schedules "
|
||||
"WHERE user_id = :user_id AND status IN ('active', 'paused')"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).scalar()
|
||||
return int(scalar or 0)
|
||||
|
||||
def list_due(self, *, limit: int = 100) -> list[dict]:
|
||||
"""Lock and return schedules with ``next_run_at <= now()``."""
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM schedules
|
||||
WHERE status = 'active'
|
||||
AND next_run_at IS NOT NULL
|
||||
AND next_run_at <= now()
|
||||
AND (end_at IS NULL OR next_run_at <= end_at)
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"""
|
||||
),
|
||||
{"limit": int(limit)},
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def update(
|
||||
self,
|
||||
schedule_id: str,
|
||||
user_id: str,
|
||||
fields: dict,
|
||||
) -> Optional[dict]:
|
||||
"""Apply a whitelisted partial update; return the new row or None."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
if not filtered:
|
||||
return self.get(schedule_id, user_id)
|
||||
set_parts: list[str] = []
|
||||
params: dict[str, Any] = {"id": str(schedule_id), "user_id": user_id}
|
||||
for key, val in filtered.items():
|
||||
if key == "tool_allowlist":
|
||||
set_parts.append("tool_allowlist = CAST(:tool_allowlist AS jsonb)")
|
||||
params["tool_allowlist"] = json.dumps(list(val or []))
|
||||
elif key == "origin_conversation_id":
|
||||
set_parts.append(
|
||||
"origin_conversation_id = CAST(:origin_conversation_id AS uuid)"
|
||||
)
|
||||
params["origin_conversation_id"] = str(val) if val else None
|
||||
else:
|
||||
set_parts.append(f"{key} = :{key}")
|
||||
params[key] = val
|
||||
sql = (
|
||||
"UPDATE schedules SET " + ", ".join(set_parts) +
|
||||
" WHERE id = CAST(:id AS uuid) AND user_id = :user_id "
|
||||
"RETURNING *"
|
||||
)
|
||||
row = self._conn.execute(text(sql), params).fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def update_internal(self, schedule_id: str, fields: dict) -> None:
|
||||
"""Apply a whitelisted partial update from a worker context."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
if not filtered:
|
||||
return
|
||||
set_parts: list[str] = []
|
||||
params: dict[str, Any] = {"id": str(schedule_id)}
|
||||
for key, val in filtered.items():
|
||||
if key == "tool_allowlist":
|
||||
set_parts.append("tool_allowlist = CAST(:tool_allowlist AS jsonb)")
|
||||
params["tool_allowlist"] = json.dumps(list(val or []))
|
||||
elif key == "origin_conversation_id":
|
||||
set_parts.append(
|
||||
"origin_conversation_id = CAST(:origin_conversation_id AS uuid)"
|
||||
)
|
||||
params["origin_conversation_id"] = str(val) if val else None
|
||||
else:
|
||||
set_parts.append(f"{key} = :{key}")
|
||||
params[key] = val
|
||||
sql = (
|
||||
"UPDATE schedules SET " + ", ".join(set_parts) +
|
||||
" WHERE id = CAST(:id AS uuid)"
|
||||
)
|
||||
self._conn.execute(text(sql), params)
|
||||
|
||||
def cancel(self, schedule_id: str, user_id: str) -> bool:
|
||||
"""Soft-cancel — flips ``status`` to ``cancelled`` and clears ``next_run_at``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE schedules SET status = 'cancelled', next_run_at = NULL "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id "
|
||||
"AND status NOT IN ('cancelled', 'completed')"
|
||||
),
|
||||
{"id": str(schedule_id), "user_id": user_id},
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
def delete(self, schedule_id: str, user_id: str) -> bool:
|
||||
"""Hard-delete an owned schedule and its runs (FK cascade)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM schedules "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": str(schedule_id), "user_id": user_id},
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
def bump_failure_count(self, schedule_id: str) -> int:
|
||||
"""Increment ``consecutive_failure_count`` and return the new value."""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"UPDATE schedules "
|
||||
"SET consecutive_failure_count = consecutive_failure_count + 1 "
|
||||
"WHERE id = CAST(:id AS uuid) "
|
||||
"RETURNING consecutive_failure_count"
|
||||
),
|
||||
{"id": str(schedule_id)},
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def reset_failure_count(self, schedule_id: str) -> None:
|
||||
"""Reset the failure counter to 0 after a successful run."""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"UPDATE schedules SET consecutive_failure_count = 0 "
|
||||
"WHERE id = CAST(:id AS uuid)"
|
||||
),
|
||||
{"id": str(schedule_id)},
|
||||
)
|
||||
|
||||
def autopause(self, schedule_id: str) -> bool:
|
||||
"""Flip an active schedule to ``paused`` after repeated failures."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE schedules SET status = 'paused', next_run_at = NULL "
|
||||
"WHERE id = CAST(:id AS uuid) AND status = 'active'"
|
||||
),
|
||||
{"id": str(schedule_id)},
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Repository for the ``shared_conversations`` table.
|
||||
|
||||
Covers the sharing operations from ``shared_conversations_collections``
|
||||
in Mongo:
|
||||
|
||||
- create a share record (with UUID, conversation_id, user, visibility flags)
|
||||
- look up by uuid (public access)
|
||||
- look up by conversation_id + user + flags (dedup check)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_mod
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
from application.storage.db.models import shared_conversations_table
|
||||
|
||||
|
||||
class SharedConversationsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
is_promptable: bool = False,
|
||||
first_n_queries: int = 0,
|
||||
api_key: str | None = None,
|
||||
prompt_id: str | None = None,
|
||||
chunks: int | None = None,
|
||||
share_uuid: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a share record.
|
||||
|
||||
``share_uuid`` allows the dual-write caller to supply the same
|
||||
UUID that Mongo received, so public ``/shared/{uuid}`` links
|
||||
keep resolving from both stores during the dual-write window.
|
||||
|
||||
Callers that need race-free dedup on the logical share key
|
||||
should use :meth:`get_or_create` instead — it relies on the
|
||||
composite partial unique index added in migration 0008 to
|
||||
collapse concurrent requests to a single row.
|
||||
"""
|
||||
final_uuid = share_uuid or str(uuid_mod.uuid4())
|
||||
values: dict = {
|
||||
"uuid": final_uuid,
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"is_promptable": is_promptable,
|
||||
"first_n_queries": first_n_queries,
|
||||
}
|
||||
if api_key:
|
||||
values["api_key"] = api_key
|
||||
if prompt_id:
|
||||
values["prompt_id"] = prompt_id
|
||||
if chunks is not None:
|
||||
values["chunks"] = chunks
|
||||
|
||||
stmt = (
|
||||
pg_insert(shared_conversations_table)
|
||||
.values(**values)
|
||||
.returning(shared_conversations_table)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
is_promptable: bool = False,
|
||||
first_n_queries: int = 0,
|
||||
api_key: str | None = None,
|
||||
prompt_id: str | None = None,
|
||||
chunks: int | None = None,
|
||||
share_uuid: str | None = None,
|
||||
) -> dict:
|
||||
"""Race-free share create/lookup keyed on the logical dedup tuple.
|
||||
|
||||
Leverages the partial unique index on
|
||||
``(conversation_id, user_id, is_promptable, first_n_queries,
|
||||
COALESCE(api_key, ''))`` added in migration 0008. Concurrent
|
||||
requests for the same logical share converge on one row. The
|
||||
returned dict's ``uuid`` is the canonical public identifier.
|
||||
|
||||
Dedup key rationale — ``prompt_id`` and ``chunks`` are
|
||||
deliberately *not* part of the uniqueness key. A share row is
|
||||
identified by "who shared what conversation under which
|
||||
visibility rules"; ``prompt_id`` / ``chunks`` are mutable
|
||||
properties of that share and are last-write-wins on re-share.
|
||||
This preserves existing public ``/shared/{uuid}`` URLs when a
|
||||
user updates the prompt or chunk count, matching the Mongo
|
||||
``find_one`` + ``update`` semantics.
|
||||
"""
|
||||
final_uuid = share_uuid or str(uuid_mod.uuid4())
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO shared_conversations
|
||||
(uuid, conversation_id, user_id, is_promptable,
|
||||
first_n_queries, api_key, prompt_id, chunks)
|
||||
VALUES
|
||||
(CAST(:uuid AS uuid), CAST(:conversation_id AS uuid),
|
||||
:user_id, :is_promptable, :first_n_queries,
|
||||
:api_key, CAST(:prompt_id AS uuid), :chunks)
|
||||
ON CONFLICT (conversation_id, user_id, is_promptable,
|
||||
first_n_queries, COALESCE(api_key, ''))
|
||||
DO UPDATE SET prompt_id = EXCLUDED.prompt_id,
|
||||
chunks = EXCLUDED.chunks
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"uuid": final_uuid,
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"is_promptable": is_promptable,
|
||||
"first_n_queries": first_n_queries,
|
||||
"api_key": api_key,
|
||||
"prompt_id": prompt_id,
|
||||
"chunks": chunks,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def find_by_uuid(self, share_uuid: str) -> Optional[dict]:
|
||||
# Shape-gate: the public ``/api/shared_conversation/<identifier>``
|
||||
# endpoint threads the URL path segment straight here. A non-UUID
|
||||
# (e.g. a legacy Mongo ObjectId still embedded in an old link or
|
||||
# an outright garbage path) must resolve to ``None`` rather than
|
||||
# raise — the CAST would otherwise poison the txn and mask the
|
||||
# real "not found" response behind a generic 400.
|
||||
if not looks_like_uuid(share_uuid):
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM shared_conversations "
|
||||
"WHERE uuid = CAST(:uuid AS uuid)"
|
||||
),
|
||||
{"uuid": share_uuid},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def find_existing(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
is_promptable: bool,
|
||||
first_n_queries: int,
|
||||
api_key: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Check for an existing share with matching parameters.
|
||||
|
||||
Mirrors the Mongo ``find_one`` dedup check before creating a share.
|
||||
"""
|
||||
if api_key:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM shared_conversations "
|
||||
"WHERE conversation_id = CAST(:conv_id AS uuid) "
|
||||
"AND user_id = :user_id "
|
||||
"AND is_promptable = :is_promptable "
|
||||
"AND first_n_queries = :fnq "
|
||||
"AND api_key = :api_key "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{
|
||||
"conv_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"is_promptable": is_promptable,
|
||||
"fnq": first_n_queries,
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
else:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM shared_conversations "
|
||||
"WHERE conversation_id = CAST(:conv_id AS uuid) "
|
||||
"AND user_id = :user_id "
|
||||
"AND is_promptable = :is_promptable "
|
||||
"AND first_n_queries = :fnq "
|
||||
"AND api_key IS NULL "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{
|
||||
"conv_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"is_promptable": is_promptable,
|
||||
"fnq": first_n_queries,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_conversation(self, conversation_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM shared_conversations "
|
||||
"WHERE conversation_id = CAST(:conv_id AS uuid) "
|
||||
"ORDER BY created_at DESC"
|
||||
),
|
||||
{"conv_id": conversation_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Repository for the ``sources`` table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import case, Connection, func, or_, select, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
from application.storage.db.models import ingest_chunk_progress_table, sources_table
|
||||
from application.storage.db.source_config import SourceConfig
|
||||
|
||||
|
||||
_SCALAR_COLUMNS = {
|
||||
"name", "type", "retriever", "sync_frequency", "tokens", "file_path",
|
||||
"language", "model", "date",
|
||||
}
|
||||
_JSONB_COLUMNS = {"metadata", "remote_data", "directory_structure", "file_name_map", "config"}
|
||||
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
|
||||
|
||||
# Whitelist for sort columns exposed via ``list_for_user``. Anything not in
|
||||
# this set falls back to ``date`` so user-supplied sort params can't be
|
||||
# interpolated into SQL unchecked.
|
||||
_SORTABLE_COLUMNS = {"date", "name", "tokens", "type", "created_at", "updated_at"}
|
||||
|
||||
|
||||
def _coerce_uuid_ids(extra_ids: Optional[list]) -> list:
|
||||
"""Coerce id strings to ``uuid.UUID`` for binding against UUID columns.
|
||||
|
||||
Non-UUID-looking ids (e.g. legacy/synthetic) are dropped — they can never
|
||||
match a ``sources.id`` value. Used to OR team-shared source ids into the
|
||||
owner-scoped queries.
|
||||
"""
|
||||
if not extra_ids:
|
||||
return []
|
||||
out: list = []
|
||||
for raw in extra_ids:
|
||||
s = str(raw)
|
||||
if looks_like_uuid(s):
|
||||
out.append(uuid.UUID(s))
|
||||
return out
|
||||
|
||||
|
||||
def _owned_or_shared_scope(user_id: str, extra_ids: Optional[list]):
|
||||
"""WHERE predicate matching the owner's rows plus any ``extra_ids``.
|
||||
|
||||
Lets the paginated/count queries include team-shared sources (passed by id)
|
||||
alongside owned ones in a single query, so count/sort/search/pagination
|
||||
stay correct across the union.
|
||||
"""
|
||||
scope = sources_table.c.user_id == user_id
|
||||
ids = _coerce_uuid_ids(extra_ids)
|
||||
if ids:
|
||||
scope = or_(scope, sources_table.c.id.in_(ids))
|
||||
return scope
|
||||
|
||||
|
||||
def _escape_like(pattern: str) -> str:
|
||||
"""Escape wildcards so a user-supplied substring is matched literally.
|
||||
|
||||
We use ``LIKE ESCAPE '\\'`` on the query side so backslash, percent, and
|
||||
underscore in the input don't accidentally turn into regex-like wildcards.
|
||||
"""
|
||||
return (
|
||||
pattern
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
|
||||
|
||||
def _coerce_jsonb(value: Any) -> Any:
|
||||
"""Normalize incoming JSONB values for the Core ``Table.update()`` path.
|
||||
|
||||
``remote_data`` in particular arrives as either a dict or a JSON string
|
||||
(the legacy Mongo docs stored both shapes). Strings are parsed so the
|
||||
stored representation is always structured JSONB; dicts/lists pass
|
||||
through untouched for the SQLAlchemy JSONB type processor.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": value}
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_config(config: Any) -> dict:
|
||||
"""Strict-validate the write-path ``config`` and return a plain dict.
|
||||
|
||||
``None`` becomes ``{}`` (classic defaults). A dict is validated through
|
||||
``SourceConfig`` (raises on bad input, D7 strict-on-write) and dumped
|
||||
back to a normalized dict for JSONB storage.
|
||||
"""
|
||||
if config is None:
|
||||
return {}
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("config must be a dict")
|
||||
return SourceConfig.model_validate(config).model_dump()
|
||||
|
||||
|
||||
def _ingest_status_case():
|
||||
"""Derive a user-facing ingest status from the joined progress row.
|
||||
|
||||
``failed`` — reconciler-escalated stall. ``processing`` — embed in
|
||||
flight. ``None`` — no progress row, or the embed completed.
|
||||
"""
|
||||
icp = ingest_chunk_progress_table
|
||||
return case(
|
||||
(icp.c.source_id.is_(None), None),
|
||||
(icp.c.status == "stalled", "failed"),
|
||||
(icp.c.embedded_chunks < icp.c.total_chunks, "processing"),
|
||||
else_=None,
|
||||
).label("ingest_status")
|
||||
|
||||
|
||||
class SourcesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
source_id: Optional[str] = None,
|
||||
user_id: str,
|
||||
type: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
config: Optional[dict] = None,
|
||||
retriever: Optional[str] = None,
|
||||
sync_frequency: Optional[str] = None,
|
||||
tokens: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
remote_data: Any = None,
|
||||
directory_structure: Any = None,
|
||||
file_name_map: Any = None,
|
||||
language: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
date: Any = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sources (
|
||||
id, user_id, name, type, metadata, config,
|
||||
retriever, sync_frequency, tokens, file_path,
|
||||
remote_data, directory_structure, file_name_map,
|
||||
language, model, date, legacy_mongo_id
|
||||
)
|
||||
VALUES (
|
||||
COALESCE(CAST(:source_id AS uuid), gen_random_uuid()),
|
||||
:user_id, :name, :type, CAST(:metadata AS jsonb),
|
||||
CAST(:config AS jsonb),
|
||||
:retriever, :sync_frequency, :tokens, :file_path,
|
||||
CAST(:remote_data AS jsonb),
|
||||
CAST(:directory_structure AS jsonb),
|
||||
CAST(:file_name_map AS jsonb),
|
||||
:language, :model,
|
||||
COALESCE(:date, now()),
|
||||
:legacy_mongo_id
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"source_id": source_id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"type": type,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"config": json.dumps(_normalize_config(config)),
|
||||
"retriever": retriever,
|
||||
"sync_frequency": sync_frequency,
|
||||
"tokens": tokens,
|
||||
"file_path": file_path,
|
||||
"remote_data": (
|
||||
None if remote_data is None
|
||||
else json.dumps(_coerce_jsonb(remote_data))
|
||||
),
|
||||
"directory_structure": (
|
||||
None if directory_structure is None
|
||||
else json.dumps(_coerce_jsonb(directory_structure))
|
||||
),
|
||||
"file_name_map": (
|
||||
None if file_name_map is None
|
||||
else json.dumps(_coerce_jsonb(file_name_map))
|
||||
),
|
||||
"language": language,
|
||||
"model": model,
|
||||
"date": date,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, source_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM sources WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": source_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, source_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve a source by either PG UUID or legacy Mongo ObjectId string.
|
||||
|
||||
Cutover helper: URLs / bookmarks may still hold Mongo ObjectIds.
|
||||
Tries the UUID path first, then falls back to ``legacy_mongo_id``.
|
||||
Both paths are scoped by ``user_id``.
|
||||
"""
|
||||
if looks_like_uuid(source_id):
|
||||
row = self.get(source_id, user_id)
|
||||
if row is not None:
|
||||
return row
|
||||
return self.get_by_legacy_id(source_id, user_id)
|
||||
|
||||
def get_by_id(self, source_id: str) -> Optional[dict]:
|
||||
"""Fetch a source by id with NO ownership scoping.
|
||||
|
||||
Used ONLY after a team-grant authorization check (team sharing). Never
|
||||
call on a raw user-supplied id without that check.
|
||||
"""
|
||||
if not looks_like_uuid(source_id):
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM sources WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": source_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_by_ids(self, source_ids) -> list[dict]:
|
||||
"""Fetch sources whose id is in ``source_ids`` (team-shared listing path)."""
|
||||
ids = [str(s) for s in source_ids if looks_like_uuid(str(s))]
|
||||
if not ids:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM sources WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"),
|
||||
{"ids": ids},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_by_name(self, user_id: str, name: str) -> Optional[dict]:
|
||||
"""Return a user's source whose name matches ``name`` (case-insensitive).
|
||||
|
||||
Used by agent YAML import to map a portable source name back to a
|
||||
concrete source id. Returns the oldest match when names collide.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM sources "
|
||||
"WHERE user_id = :user_id AND lower(name) = lower(:name) "
|
||||
"ORDER BY created_at LIMIT 1"
|
||||
),
|
||||
{"user_id": user_id, "name": name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
search_term: Optional[str] = None,
|
||||
sort_field: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
extra_ids: Optional[list] = None,
|
||||
) -> list[dict]:
|
||||
"""Return sources owned by ``user_id``, paginated and optionally filtered.
|
||||
|
||||
All pagination, filtering, and sorting are pushed into SQL so large
|
||||
accounts don't materialize their full source list in Python for every
|
||||
page. See ``PaginatedSources`` in the sources routes for the matching
|
||||
call site.
|
||||
|
||||
Args:
|
||||
user_id: Scope rows to this owner.
|
||||
limit: Page size. ``None`` returns every matching row (legacy
|
||||
full-list path used by ``CombinedJson``).
|
||||
offset: Rows to skip before collecting ``limit`` results.
|
||||
search_term: Case-insensitive substring filter on ``name``.
|
||||
``%`` and ``_`` in the input are escaped so they match
|
||||
literally rather than as LIKE wildcards.
|
||||
sort_field: Column to sort by. Unknown values fall back to
|
||||
``date``. Resolved against ``sources_table.c`` so the
|
||||
column identity is bound by SQLAlchemy — user input never
|
||||
reaches the emitted SQL as a string.
|
||||
sort_order: ``"asc"`` or ``"desc"``; anything else is treated
|
||||
as ``"desc"``.
|
||||
|
||||
Returns:
|
||||
A list of source rows as plain dicts (via ``row_to_dict``),
|
||||
each carrying a derived ``ingest_status`` (``failed`` /
|
||||
``processing`` / ``None``) from the joined progress row.
|
||||
"""
|
||||
column_name = sort_field if sort_field in _SORTABLE_COLUMNS else "date"
|
||||
sort_column = sources_table.c[column_name]
|
||||
ascending = sort_order.lower() == "asc"
|
||||
|
||||
stmt = (
|
||||
select(sources_table, _ingest_status_case())
|
||||
.select_from(
|
||||
sources_table.outerjoin(
|
||||
ingest_chunk_progress_table,
|
||||
ingest_chunk_progress_table.c.source_id
|
||||
== sources_table.c.id,
|
||||
)
|
||||
)
|
||||
.where(_owned_or_shared_scope(user_id, extra_ids))
|
||||
)
|
||||
if search_term:
|
||||
stmt = stmt.where(
|
||||
sources_table.c.name.ilike(
|
||||
f"%{_escape_like(search_term)}%",
|
||||
escape="\\",
|
||||
)
|
||||
)
|
||||
|
||||
# ``id`` is appended as a stable tiebreaker so paginated windows
|
||||
# are deterministic across equal sort keys.
|
||||
id_column = sources_table.c.id
|
||||
if ascending:
|
||||
stmt = stmt.order_by(sort_column.asc(), id_column.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(sort_column.desc(), id_column.desc())
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
result = self._conn.execute(stmt)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def count_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
search_term: Optional[str] = None,
|
||||
extra_ids: Optional[list] = None,
|
||||
) -> int:
|
||||
"""Return the count of rows that ``list_for_user`` would produce.
|
||||
|
||||
The filter mirrors ``list_for_user`` exactly so ``total`` and the
|
||||
paginated window stay consistent page-to-page.
|
||||
|
||||
Args:
|
||||
user_id: Scope rows to this owner.
|
||||
search_term: Same substring filter semantics as
|
||||
``list_for_user``; ``None``/empty disables the filter.
|
||||
|
||||
Returns:
|
||||
The total number of matching rows.
|
||||
"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(sources_table)
|
||||
.where(_owned_or_shared_scope(user_id, extra_ids))
|
||||
)
|
||||
if search_term:
|
||||
stmt = stmt.where(
|
||||
sources_table.c.name.ilike(
|
||||
f"%{_escape_like(search_term)}%",
|
||||
escape="\\",
|
||||
)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
row = result.fetchone()
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
def update(self, source_id: str, user_id: str, fields: dict) -> None:
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_COLUMNS}
|
||||
if not filtered:
|
||||
return
|
||||
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
values[col] = _coerce_jsonb(val) if col in _JSONB_COLUMNS else val
|
||||
values["updated_at"] = func.now()
|
||||
|
||||
t = sources_table
|
||||
stmt = (
|
||||
t.update()
|
||||
.where(t.c.id == source_id)
|
||||
.where(t.c.user_id == user_id)
|
||||
.values(**values)
|
||||
)
|
||||
self._conn.execute(stmt)
|
||||
|
||||
def get_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM sources WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def update_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: str, fields: dict,
|
||||
) -> bool:
|
||||
"""Update a source addressed by the Mongo ObjectId string.
|
||||
|
||||
Used by dual_write call sites that hold the Mongo ``_id`` but
|
||||
haven't resolved the PG UUID yet. Returns ``True`` if a row was
|
||||
updated (i.e. the legacy id was found).
|
||||
"""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
row = self.get_by_legacy_id(legacy_mongo_id, user_id)
|
||||
if row is None:
|
||||
return False
|
||||
self.update(str(row["id"]), user_id, fields)
|
||||
return True
|
||||
|
||||
def delete_by_legacy_id(self, legacy_mongo_id: str, user_id: str) -> bool:
|
||||
"""Delete by Mongo ObjectId. Used by dual_write in DeleteOldIndexes."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM sources "
|
||||
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
|
||||
),
|
||||
{"legacy_id": legacy_mongo_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, source_id: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM sources WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": source_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Repository for the ``stack_logs`` table.
|
||||
|
||||
Covers the single operation the legacy Mongo code performs:
|
||||
|
||||
1. ``insert_one`` in logging.py ``_log_to_mongodb`` — append-only debug/error
|
||||
activity log. The Mongo collection is ``stack_logs``; the Mongo variable
|
||||
inside ``_log_to_mongodb`` is misleadingly named ``user_logs_collection``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from application.storage.db.redaction import redact_secrets
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
|
||||
class StackLogsRepository:
|
||||
"""Postgres-backed replacement for Mongo ``stack_logs`` collection."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def insert(
|
||||
self,
|
||||
*,
|
||||
activity_id: str,
|
||||
endpoint: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
stacks: Optional[list] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO stack_logs (activity_id, endpoint, level, user_id, api_key, query, stacks, timestamp)
|
||||
VALUES (
|
||||
:activity_id, :endpoint, :level, :user_id, :api_key, :query,
|
||||
CAST(:stacks AS jsonb),
|
||||
COALESCE(:timestamp, now())
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"activity_id": activity_id,
|
||||
"endpoint": endpoint,
|
||||
"level": level,
|
||||
"user_id": user_id,
|
||||
"api_key": api_key,
|
||||
"query": query,
|
||||
"stacks": json.dumps(
|
||||
redact_secrets(stacks or []), cls=PGNativeJSONEncoder
|
||||
),
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Repository for the ``team_members`` table (membership + team-scoped roles).
|
||||
|
||||
Field-for-field on ``user_roles``: grants are keyed by
|
||||
``(team_id, user_id, role, source)`` so a manual grant and a future IdP-derived
|
||||
grant for the same user coexist and revoke independently. ``user_id`` is the
|
||||
auth ``sub``. The ``team_member`` role is NOT implicit — every member has at
|
||||
least one row (unlike the global ``user`` role). All methods take a
|
||||
``Connection`` and do not manage their own transactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
ROLE_TEAM_ADMIN = "team_admin"
|
||||
ROLE_TEAM_MEMBER = "team_member"
|
||||
|
||||
|
||||
class TeamMembersRepository:
|
||||
"""Team membership grants keyed by the auth ``sub`` (``user_id``)."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def role_for(self, user_id: str, team_id: str) -> Optional[str]:
|
||||
"""Strongest role ``user_id`` holds in ``team_id`` (any source), or None.
|
||||
|
||||
``team_admin`` outranks ``team_member``. Returns None when the user is
|
||||
not a member — the caller treats that as "no team access".
|
||||
"""
|
||||
if not user_id or not team_id:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT CASE WHEN bool_or(role = 'team_admin') THEN 'team_admin'
|
||||
ELSE 'team_member' END AS role
|
||||
FROM team_members
|
||||
WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id
|
||||
HAVING count(*) > 0
|
||||
"""
|
||||
),
|
||||
{"team_id": team_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row is not None else None
|
||||
|
||||
def is_member(self, user_id: str, team_id: str) -> bool:
|
||||
return self.role_for(user_id, team_id) is not None
|
||||
|
||||
def list_team_ids_for(self, user_id: str) -> list[str]:
|
||||
"""The distinct team ids ``user_id`` belongs to (hot authz reverse lookup)."""
|
||||
if not user_id:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT DISTINCT team_id FROM team_members WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [str(row[0]) for row in result.fetchall()]
|
||||
|
||||
def list_members(self, team_id: str) -> list[dict]:
|
||||
"""One row per (user, role, source) in the team, ordered by grant time.
|
||||
|
||||
Left-joins ``users`` to surface each member's ``email`` (when on file)
|
||||
so the UI can show a human-readable identity instead of the raw sub.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT m.user_id, m.role, m.source, m.granted_by, m.granted_at,
|
||||
u.email
|
||||
FROM team_members m
|
||||
LEFT JOIN users u ON u.user_id = m.user_id
|
||||
WHERE m.team_id = CAST(:team_id AS uuid)
|
||||
ORDER BY m.granted_at
|
||||
"""
|
||||
),
|
||||
{"team_id": team_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def count_admins(self, team_id: str) -> int:
|
||||
"""Distinct users holding ``team_admin`` — for the last-admin guard."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(DISTINCT user_id) FROM team_members
|
||||
WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin'
|
||||
"""
|
||||
),
|
||||
{"team_id": team_id},
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
def lock_admins(self, team_id: str) -> list[str]:
|
||||
"""Lock the team's ``team_admin`` rows and return the distinct admins.
|
||||
|
||||
``SELECT ... FOR UPDATE`` inside the caller's write transaction
|
||||
serializes concurrent demote/remove operations on the team's admins, so
|
||||
the last-admin guard can't be raced into orphaning the team (two
|
||||
concurrent removes both seeing count=2). ``DISTINCT`` is incompatible
|
||||
with ``FOR UPDATE``, so de-dup in Python (a user may hold team_admin via
|
||||
multiple sources).
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id FROM team_members
|
||||
WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin'
|
||||
FOR UPDATE
|
||||
"""
|
||||
),
|
||||
{"team_id": team_id},
|
||||
)
|
||||
return sorted({str(row[0]) for row in result.fetchall()})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def add_member(
|
||||
self,
|
||||
team_id: str,
|
||||
user_id: str,
|
||||
role: str = ROLE_TEAM_MEMBER,
|
||||
source: str = "manual",
|
||||
granted_by: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Idempotently add a membership grant. Returns True if a row was inserted."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO team_members (team_id, user_id, role, source, granted_by)
|
||||
VALUES (CAST(:team_id AS uuid), :user_id, :role, :source, :granted_by)
|
||||
ON CONFLICT (team_id, user_id, role, source) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
"role": role,
|
||||
"source": source,
|
||||
"granted_by": granted_by,
|
||||
},
|
||||
)
|
||||
return bool(result.rowcount)
|
||||
|
||||
def set_manual_role(
|
||||
self,
|
||||
team_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
granted_by: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Set the user's ``manual`` role to exactly ``role`` (promote/demote).
|
||||
|
||||
Deletes any other manual role row for the user in this team, then adds
|
||||
the target — keeping the single-manual-role invariant. IdP-sourced rows
|
||||
are untouched.
|
||||
"""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM team_members
|
||||
WHERE team_id = CAST(:team_id AS uuid)
|
||||
AND user_id = :user_id AND source = 'manual' AND role <> :role
|
||||
"""
|
||||
),
|
||||
{"team_id": team_id, "user_id": user_id, "role": role},
|
||||
)
|
||||
self.add_member(team_id, user_id, role=role, source="manual", granted_by=granted_by)
|
||||
|
||||
def remove_member(
|
||||
self,
|
||||
team_id: str,
|
||||
user_id: str,
|
||||
source: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Remove the user's membership rows from the team.
|
||||
|
||||
When ``source`` is given only that source's rows are removed (so an IdP
|
||||
sync can't wipe a manual grant); otherwise every role/source row for the
|
||||
user in the team is removed. Returns True if anything was deleted.
|
||||
"""
|
||||
sql = "DELETE FROM team_members WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id"
|
||||
params: dict = {"team_id": team_id, "user_id": user_id}
|
||||
if source is not None:
|
||||
sql += " AND source = :source"
|
||||
params["source"] = source
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return bool(result.rowcount)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Repository for the ``team_resource_grants`` table (resource sharing).
|
||||
|
||||
One polymorphic table for all four shareable resource types
|
||||
(``agent``/``source``/``prompt``/``tool``). A grant makes a resource visible to
|
||||
a team as additive visibility — never ownership transfer; the resource's
|
||||
``user_id`` owner is unchanged. ``owner_id`` is denormalised owner-at-share-time.
|
||||
Per-user effective visibility/access (the JOIN to ``team_members``) lives in
|
||||
[[team_scope]]. All methods take a ``Connection``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
VALID_RESOURCE_TYPES = ("agent", "source", "prompt", "tool")
|
||||
VALID_ACCESS_LEVELS = ("viewer", "editor")
|
||||
|
||||
|
||||
class TeamResourceGrantsRepository:
|
||||
"""Share grants keyed by ``(team_id, resource_type, resource_id)``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def list_for_team(
|
||||
self, team_id: str, resource_type: Optional[str] = None
|
||||
) -> list[dict]:
|
||||
"""All grants for a team, optionally filtered to one resource type."""
|
||||
sql = "SELECT * FROM team_resource_grants WHERE team_id = CAST(:team_id AS uuid)"
|
||||
params: dict = {"team_id": team_id}
|
||||
if resource_type is not None:
|
||||
sql += " AND resource_type = :resource_type"
|
||||
params["resource_type"] = resource_type
|
||||
sql += " ORDER BY created_at DESC"
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_for_resource(self, resource_type: str, resource_id: str) -> list[dict]:
|
||||
"""Which teams a resource is shared with (the owner's share-management view)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT g.*, t.name AS team_name, t.slug AS team_slug
|
||||
FROM team_resource_grants g
|
||||
JOIN teams t ON t.id = g.team_id
|
||||
WHERE g.resource_type = :resource_type
|
||||
AND g.resource_id = CAST(:resource_id AS uuid)
|
||||
ORDER BY t.name
|
||||
"""
|
||||
),
|
||||
{"resource_type": resource_type, "resource_id": resource_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def get(
|
||||
self,
|
||||
team_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
target_user_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Fetch one grant. ``target_user_id`` None matches the whole-team grant;
|
||||
a sub matches that member's grant (NULL/'' coalesced so both compare)."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT * FROM team_resource_grants
|
||||
WHERE team_id = CAST(:team_id AS uuid)
|
||||
AND resource_type = :resource_type
|
||||
AND resource_id = CAST(:resource_id AS uuid)
|
||||
AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '')
|
||||
"""
|
||||
),
|
||||
{
|
||||
"team_id": team_id,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"target_user_id": target_user_id,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def grant(
|
||||
self,
|
||||
team_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
owner_id: str,
|
||||
granted_by: str,
|
||||
access_level: str = "viewer",
|
||||
target_user_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Share a resource with a team (or one member), upserting the access level.
|
||||
|
||||
``target_user_id`` None shares with the whole team; a sub shares with
|
||||
that one member (the caller must have validated they're a team member).
|
||||
``ON CONFLICT`` on the functional dedup index makes re-sharing
|
||||
last-write-wins on ``access_level``. The caller MUST have verified
|
||||
``granted_by`` owns the resource (dispatched by ``resource_type``) — the
|
||||
polymorphic table has no FK to catch a type/id mismatch.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO team_resource_grants
|
||||
(team_id, resource_type, resource_id, owner_id, access_level,
|
||||
granted_by, target_user_id)
|
||||
VALUES
|
||||
(CAST(:team_id AS uuid), :resource_type, CAST(:resource_id AS uuid),
|
||||
:owner_id, :access_level, :granted_by, :target_user_id)
|
||||
ON CONFLICT (team_id, resource_type, resource_id, COALESCE(target_user_id, ''))
|
||||
DO UPDATE SET access_level = EXCLUDED.access_level,
|
||||
granted_by = EXCLUDED.granted_by
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"team_id": team_id,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"owner_id": owner_id,
|
||||
"access_level": access_level,
|
||||
"granted_by": granted_by,
|
||||
"target_user_id": target_user_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def revoke(
|
||||
self,
|
||||
team_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
target_user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Unshare. ``target_user_id`` None removes the whole-team grant; a sub
|
||||
removes that member's grant. Access is lost on the next request."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM team_resource_grants
|
||||
WHERE team_id = CAST(:team_id AS uuid)
|
||||
AND resource_type = :resource_type
|
||||
AND resource_id = CAST(:resource_id AS uuid)
|
||||
AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '')
|
||||
"""
|
||||
),
|
||||
{
|
||||
"team_id": team_id,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"target_user_id": target_user_id,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Per-user team visibility resolution.
|
||||
|
||||
This is the read-side authorization helper for team sharing: it answers "which
|
||||
resources of type X can ``user_id`` see via their team memberships?" and "what
|
||||
access level does ``user_id`` effectively have on resource R?". Membership is
|
||||
JOINed LIVE against ``team_members`` on every call — never cached, never read
|
||||
from the JWT — so revoking a membership or a grant drops access on the very next
|
||||
request (closes the revocation race by construction). All methods take a
|
||||
``Connection``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
|
||||
|
||||
class TeamScopeRepository:
|
||||
"""Live JOIN of ``team_members`` × ``team_resource_grants`` for one user."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def visible_resource_ids(self, user_id: str, resource_type: str) -> set[str]:
|
||||
"""The set of ``resource_type`` ids shared to any team ``user_id`` is in.
|
||||
|
||||
Returned as canonical-string UUIDs so callers can build an
|
||||
``id = ANY(:ids)`` union clause against the resource table.
|
||||
"""
|
||||
if not user_id:
|
||||
return set()
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT g.resource_id
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
WHERE m.user_id = :user_id AND g.resource_type = :resource_type
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "resource_type": resource_type},
|
||||
)
|
||||
return {str(row[0]) for row in result.fetchall()}
|
||||
|
||||
def visible_with_access(self, user_id: str, resource_type: str) -> dict[str, str]:
|
||||
"""Map of ``resource_id -> strongest access`` shared to the user's teams.
|
||||
|
||||
One query for the whole list path — ``editor`` outranks ``viewer`` per
|
||||
resource. Keys are canonical-string UUIDs.
|
||||
"""
|
||||
if not user_id:
|
||||
return {}
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT g.resource_id,
|
||||
CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor'
|
||||
ELSE 'viewer' END AS access_level
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
WHERE m.user_id = :user_id AND g.resource_type = :resource_type
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
GROUP BY g.resource_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "resource_type": resource_type},
|
||||
)
|
||||
return {str(row[0]): row[1] for row in result.fetchall()}
|
||||
|
||||
def effective_access(
|
||||
self, user_id: str, resource_type: str, resource_id: str
|
||||
) -> Optional[str]:
|
||||
"""Strongest access ``user_id`` has on a resource via teams, or None.
|
||||
|
||||
``editor`` outranks ``viewer``. None means no team grant reaches the user
|
||||
(they may still be the owner — that is checked separately by the repo's
|
||||
dual-key path). Used for both read authz (any value → visible) and write
|
||||
authz (``editor`` → may edit).
|
||||
"""
|
||||
# Grant rows only ever hold canonical UUIDs; a non-UUID id (e.g. a
|
||||
# legacy Mongo ObjectId reaching the single-fetch fallback) can never
|
||||
# match a grant, and CASTing it would raise and poison the txn — so
|
||||
# short-circuit to None instead.
|
||||
if not user_id or not looks_like_uuid(resource_id):
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor'
|
||||
ELSE 'viewer' END AS access_level
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
WHERE m.user_id = :user_id
|
||||
AND g.resource_type = :resource_type
|
||||
AND g.resource_id = CAST(:resource_id AS uuid)
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
HAVING count(*) > 0
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "resource_type": resource_type, "resource_id": resource_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row is not None else None
|
||||
|
||||
def can_read(self, user_id: str, resource_type: str, resource_id: str) -> bool:
|
||||
return self.effective_access(user_id, resource_type, resource_id) is not None
|
||||
|
||||
def can_write(self, user_id: str, resource_type: str, resource_id: str) -> bool:
|
||||
return self.effective_access(user_id, resource_type, resource_id) == "editor"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Repository for the ``teams`` table.
|
||||
|
||||
A team is owned by ``owner_id`` (the creator's auth ``sub``), which is the
|
||||
durable "who can delete the team" anchor. Membership and role grants live in
|
||||
``team_members`` ([[team_members]]); shared resources in ``team_resource_grants``.
|
||||
All methods take a ``Connection`` and do not manage their own transactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
class TeamsRepository:
|
||||
"""CRUD for teams keyed by the team UUID."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def get(self, team_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM teams WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": team_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_slug(self, slug: str) -> Optional[dict]:
|
||||
if not slug:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM teams WHERE slug = :slug"),
|
||||
{"slug": slug},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def slug_exists(self, slug: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text("SELECT 1 FROM teams WHERE slug = :slug"),
|
||||
{"slug": slug},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
"""Teams ``user_id`` belongs to, each annotated with the member's role.
|
||||
|
||||
``role`` is the strongest grant (team_admin > team_member). Ordered by
|
||||
team name for stable UI listing.
|
||||
"""
|
||||
if not user_id:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.*,
|
||||
CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin'
|
||||
ELSE 'team_member' END AS member_role,
|
||||
(SELECT count(DISTINCT mm.user_id) FROM team_members mm
|
||||
WHERE mm.team_id = t.id) AS member_count,
|
||||
(SELECT count(DISTINCT g.resource_id)
|
||||
FROM team_resource_grants g
|
||||
WHERE g.team_id = t.id) AS shared_count
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = :user_id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_all(self) -> list[dict]:
|
||||
"""All teams with member counts — global-admin oversight only."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.*,
|
||||
(SELECT count(DISTINCT user_id) FROM team_members m
|
||||
WHERE m.team_id = t.id) AS member_count
|
||||
FROM teams t
|
||||
ORDER BY t.created_at DESC
|
||||
"""
|
||||
)
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
slug: str,
|
||||
owner_id: str,
|
||||
description: Optional[str] = None,
|
||||
) -> dict:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO teams (name, slug, owner_id, description)
|
||||
VALUES (:name, :slug, :owner_id, :description)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"name": name, "slug": slug, "owner_id": owner_id, "description": description},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
# Each updatable column maps to a CONSTANT SQL fragment. The SET clause is
|
||||
# assembled only from these literals (selected by whitelisted keys), so no
|
||||
# user-controlled string ever reaches the query text — values travel as
|
||||
# bound parameters. Keeps the query injection-free by construction.
|
||||
_UPDATABLE_COLUMNS = {
|
||||
"name": "name = :name",
|
||||
"description": "description = :description",
|
||||
}
|
||||
|
||||
def update(self, team_id: str, fields: dict) -> bool:
|
||||
"""Update mutable team fields (``name``, ``description``). updated_at is
|
||||
bumped by the ``teams_set_updated_at`` trigger."""
|
||||
values = {k: v for k, v in fields.items() if k in self._UPDATABLE_COLUMNS}
|
||||
if not values:
|
||||
return False
|
||||
set_clause = ", ".join(self._UPDATABLE_COLUMNS[k] for k in values)
|
||||
params = dict(values)
|
||||
params["id"] = team_id
|
||||
result = self._conn.execute(
|
||||
text(f"UPDATE teams SET {set_clause} WHERE id = CAST(:id AS uuid)"),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def reassign_owner(self, team_id: str, new_owner_id: str) -> bool:
|
||||
"""Transfer the deletion-anchor owner to another user (team_admin action)."""
|
||||
result = self._conn.execute(
|
||||
text("UPDATE teams SET owner_id = :owner_id WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": team_id, "owner_id": new_owner_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, team_id: str) -> bool:
|
||||
"""Delete the team. ``team_members`` and ``team_resource_grants`` cascade;
|
||||
shared resources revert to owner-only visibility."""
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM teams WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": team_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Repository for the ``todos`` table.
|
||||
|
||||
Covers the operations in ``application/agents/tools/todo_list.py``.
|
||||
|
||||
The Mongo schema uses ``todo_id`` (a per-tool monotonic integer that the
|
||||
LLM uses as its handle) and ``status`` ("open"/"completed"). The Postgres
|
||||
schema mirrors that with a dedicated ``todo_id INTEGER`` column (unique
|
||||
per ``tool_id`` via a partial index) for the LLM-facing handle, while the
|
||||
primary key remains a UUID, and ``status`` is collapsed to a ``completed``
|
||||
boolean. ``legacy_mongo_id`` lets the backfill stay idempotent across
|
||||
reruns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
class TodosRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_id: str,
|
||||
title: str,
|
||||
*,
|
||||
todo_id: Optional[int] = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Insert a todo row.
|
||||
|
||||
Allocates the per-tool monotonic ``todo_id`` inside the same
|
||||
transaction when the caller does not supply one. The allocation
|
||||
is ``COALESCE(MAX(todo_id), 0) + 1`` scoped to ``tool_id``; the
|
||||
partial unique index ``todos_tool_todo_id_uidx`` enforces
|
||||
correctness if two callers race.
|
||||
"""
|
||||
if todo_id is None:
|
||||
todo_id = self._conn.execute(
|
||||
text(
|
||||
"SELECT COALESCE(MAX(todo_id), 0) + 1 FROM todos "
|
||||
"WHERE tool_id = CAST(:tool_id AS uuid)"
|
||||
),
|
||||
{"tool_id": tool_id},
|
||||
).scalar_one()
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO todos (user_id, tool_id, todo_id, title, legacy_mongo_id)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :todo_id, :title, :legacy_mongo_id)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"tool_id": tool_id,
|
||||
"todo_id": todo_id,
|
||||
"title": title,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, todo_uuid: str, user_id: str) -> Optional[dict]:
|
||||
"""Look up a todo by its UUID primary key."""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM todos WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": todo_uuid, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user_tool(self, user_id: str, tool_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM todos WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) ORDER BY created_at"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_for_tool(self, user_id: str, tool_id: str) -> list[dict]:
|
||||
"""Return all todos for a (user, tool) ordered by ``todo_id``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM todos WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) "
|
||||
"ORDER BY todo_id NULLS LAST, created_at"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def get_by_tool_and_todo_id(
|
||||
self, user_id: str, tool_id: str, todo_id: int
|
||||
) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM todos WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND todo_id = :todo_id"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "todo_id": todo_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def update_title(self, todo_uuid: str, user_id: str, title: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE todos SET title = :title, updated_at = now() "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": todo_uuid, "user_id": user_id, "title": title},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def update_title_by_tool_and_todo_id(
|
||||
self, user_id: str, tool_id: str, todo_id: int, title: str
|
||||
) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE todos SET title = :title, updated_at = now() "
|
||||
"WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid) "
|
||||
"AND todo_id = :todo_id"
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"tool_id": tool_id,
|
||||
"todo_id": todo_id,
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def set_completed(
|
||||
self,
|
||||
user_id_or_uuid: str,
|
||||
tool_id_or_user_id: str,
|
||||
todo_id_or_completed: Any,
|
||||
completed: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""Mark a todo's ``completed`` flag.
|
||||
|
||||
Two call shapes are supported during the migration window:
|
||||
|
||||
* Legacy UUID form (kept for existing tests):
|
||||
``set_completed(todo_uuid, user_id, completed: bool)``.
|
||||
* Per-tool integer-handle form (used by the tool's dual-write):
|
||||
``set_completed(user_id, tool_id, todo_id: int, completed: bool)``.
|
||||
"""
|
||||
if completed is None:
|
||||
# Legacy three-arg form: (todo_uuid, user_id, completed)
|
||||
todo_uuid = user_id_or_uuid
|
||||
user_id = tool_id_or_user_id
|
||||
completed_value = bool(todo_id_or_completed)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE todos SET completed = :completed, updated_at = now() "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": todo_uuid, "user_id": user_id, "completed": completed_value},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
# New form: (user_id, tool_id, todo_id, completed)
|
||||
user_id = user_id_or_uuid
|
||||
tool_id = tool_id_or_user_id
|
||||
todo_id = int(todo_id_or_completed)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE todos SET completed = :completed, updated_at = now() "
|
||||
"WHERE user_id = :user_id AND tool_id = CAST(:tool_id AS uuid) "
|
||||
"AND todo_id = :todo_id"
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"tool_id": tool_id,
|
||||
"todo_id": todo_id,
|
||||
"completed": bool(completed),
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, todo_uuid: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM todos WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": todo_uuid, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_by_tool_and_todo_id(
|
||||
self, user_id: str, tool_id: str, todo_id: int
|
||||
) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM todos WHERE user_id = :user_id "
|
||||
"AND tool_id = CAST(:tool_id AS uuid) AND todo_id = :todo_id"
|
||||
),
|
||||
{"user_id": user_id, "tool_id": tool_id, "todo_id": todo_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str) -> Optional[dict]:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM todos WHERE legacy_mongo_id = :legacy"),
|
||||
{"legacy": legacy_mongo_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, identifier: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve a todo by PG UUID or legacy Mongo ObjectId.
|
||||
|
||||
Picks the lookup path from the id shape so non-UUID input never
|
||||
reaches ``CAST(:id AS uuid)`` — that cast raises on the server
|
||||
and poisons the enclosing transaction, making any subsequent
|
||||
query on the same connection fail.
|
||||
"""
|
||||
if looks_like_uuid(identifier):
|
||||
doc = self.get(identifier, user_id)
|
||||
if doc is not None:
|
||||
return doc
|
||||
legacy = self.get_by_legacy_id(identifier)
|
||||
if legacy and legacy.get("user_id") == user_id:
|
||||
return legacy
|
||||
return None
|
||||
|
||||
def update_by_legacy_id(
|
||||
self,
|
||||
legacy_mongo_id: str,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
completed: Optional[bool] = None,
|
||||
) -> bool:
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sets = []
|
||||
params: dict[str, Any] = {"legacy": legacy_mongo_id}
|
||||
if title is not None:
|
||||
sets.append("title = :title")
|
||||
params["title"] = title
|
||||
if completed is not None:
|
||||
sets.append("completed = :completed")
|
||||
params["completed"] = bool(completed)
|
||||
if not sets:
|
||||
return False
|
||||
sets.append("updated_at = now()")
|
||||
sql = "UPDATE todos SET " + ", ".join(sets) + " WHERE legacy_mongo_id = :legacy"
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Repository for the ``token_usage`` table.
|
||||
|
||||
Covers every operation the legacy Mongo code performs on
|
||||
``token_usage_collection`` / ``usage_collection``:
|
||||
|
||||
1. ``insert_one`` in usage.py (record per-call token counts)
|
||||
2. ``aggregate`` in analytics/routes.py (time-bucketed totals)
|
||||
3. ``aggregate`` in answer/routes/base.py (24h sum for rate limiting)
|
||||
4. ``count_documents`` in answer/routes/base.py (24h request count)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
|
||||
class TokenUsageRepository:
|
||||
"""Postgres-backed replacement for Mongo ``token_usage_collection``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def insert(
|
||||
self,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
prompt_tokens: int = 0,
|
||||
generated_tokens: int = 0,
|
||||
source: str = "agent_stream",
|
||||
request_id: Optional[str] = None,
|
||||
model_id: Optional[str] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> None:
|
||||
# Attribution guard: the ``token_usage_attribution_chk`` CHECK
|
||||
# constraint requires at least one of ``user_id`` / ``api_key``
|
||||
# to be non-null. Raise here for a clear error rather than
|
||||
# relying on the DB to reject the row.
|
||||
if not user_id and not api_key:
|
||||
raise ValueError("token_usage insert requires user_id or api_key")
|
||||
|
||||
# ``agent_id`` is a UUID column. Legacy callers occasionally pass
|
||||
# a Mongo ObjectId string (24 hex chars) — those would make
|
||||
# psycopg raise at CAST time. Coerce anything that isn't shaped
|
||||
# like a UUID (36 chars with hyphens) to NULL so a stray legacy
|
||||
# id never breaks token accounting.
|
||||
agent_id_uuid: Optional[str] = None
|
||||
if agent_id:
|
||||
s = str(agent_id)
|
||||
if len(s) == 36 and "-" in s:
|
||||
agent_id_uuid = s
|
||||
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO token_usage (
|
||||
user_id, api_key, agent_id,
|
||||
prompt_tokens, generated_tokens,
|
||||
source, request_id, model_id, timestamp
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :api_key,
|
||||
CAST(:agent_id AS uuid),
|
||||
:prompt_tokens, :generated_tokens,
|
||||
:source, :request_id, :model_id, COALESCE(:timestamp, now())
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"api_key": api_key,
|
||||
"agent_id": agent_id_uuid,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"generated_tokens": generated_tokens,
|
||||
"source": source,
|
||||
"request_id": request_id,
|
||||
"model_id": model_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
def sum_tokens_in_range(
|
||||
self,
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Total (prompt + generated) tokens in the given time range."""
|
||||
clauses = ["timestamp >= :start", "timestamp <= :end"]
|
||||
params: dict = {"start": start, "end": end}
|
||||
if user_id is not None:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if api_key is not None:
|
||||
clauses.append("api_key = :api_key")
|
||||
params["api_key"] = api_key
|
||||
where = " AND ".join(clauses)
|
||||
result = self._conn.execute(
|
||||
text(f"SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) FROM token_usage WHERE {where}"),
|
||||
params,
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
# Token usage written outside a user-initiated request (conversation
|
||||
# title generation, history compression, RAG question condensing,
|
||||
# provider fallback). Mirrors the exclusion list in ``count_in_range``.
|
||||
SIDE_CHANNEL_SOURCES = ("title", "compression", "rag_condense", "fallback")
|
||||
|
||||
# Run-level roll-ups that duplicate per-call rows. The scheduler worker
|
||||
# inserts one ``source='schedule'`` row summing a run's tokens, but the
|
||||
# run's individual LLM calls were already persisted as ``agent_stream``
|
||||
# rows by the usage decorators — counting both doubles scheduled spend.
|
||||
# The rollup is never used as a fallback: if a per-call insert failed
|
||||
# (logged in usage.py), that call's tokens go uncounted, the same loss
|
||||
# mode as any other traffic whose insert fails.
|
||||
ROLLUP_SOURCES = ("schedule",)
|
||||
|
||||
# Allowed ``group_by`` values → the SQL expression producing the
|
||||
# group key. ``agent`` resolves to the agent's display name so the
|
||||
# dashboard never has to map UUIDs client-side.
|
||||
_GROUP_KEY_EXPRS = {
|
||||
"model": "COALESCE(tu.model_id, 'unknown')",
|
||||
"agent": "COALESCE(a.name, 'No agent')",
|
||||
"source": "COALESCE(tu.source, 'agent_stream')",
|
||||
}
|
||||
|
||||
def bucketed_totals(
|
||||
self,
|
||||
*,
|
||||
bucket_unit: str,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
timestamp_gte: Optional[datetime] = None,
|
||||
timestamp_lt: Optional[datetime] = None,
|
||||
group_by: Optional[str] = None,
|
||||
include_side_channel: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Sum ``prompt_tokens`` / ``generated_tokens`` bucketed by time.
|
||||
|
||||
Replacement for the legacy Mongo ``$dateToString`` aggregation
|
||||
used by the analytics dashboard. The ``bucket`` format string
|
||||
mirrors Mongo's output so the route layer doesn't reshape:
|
||||
``"YYYY-MM-DD HH:MM:00"`` (minute), ``"YYYY-MM-DD HH:00"``
|
||||
(hour), ``"YYYY-MM-DD"`` (day). Rows are ordered by bucket ASC.
|
||||
|
||||
``group_by`` (``"model"`` / ``"agent"`` / ``"source"``) adds a
|
||||
second grouping dimension; each returned row then carries a
|
||||
``group_key``. ``include_side_channel=False`` drops rows whose
|
||||
``source`` is a side-channel call (title generation etc.).
|
||||
"""
|
||||
formats = {
|
||||
"minute": "YYYY-MM-DD HH24:MI:00",
|
||||
"hour": "YYYY-MM-DD HH24:00",
|
||||
"day": "YYYY-MM-DD",
|
||||
}
|
||||
if bucket_unit not in formats:
|
||||
raise ValueError(f"unsupported bucket_unit: {bucket_unit!r}")
|
||||
if group_by is not None and group_by not in self._GROUP_KEY_EXPRS:
|
||||
raise ValueError(f"unsupported group_by: {group_by!r}")
|
||||
fmt = formats[bucket_unit]
|
||||
|
||||
clauses: list[str] = []
|
||||
params: dict = {"fmt": fmt}
|
||||
if user_id is not None:
|
||||
clauses.append("tu.user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
# Rows stamp ``api_key`` (external traffic) or ``agent_id``
|
||||
# (owner chats / headless runs), so a per-agent filter must
|
||||
# match either shape.
|
||||
agent_clauses: list[str] = []
|
||||
if api_key is not None:
|
||||
agent_clauses.append("tu.api_key = :api_key")
|
||||
params["api_key"] = api_key
|
||||
if agent_id is not None:
|
||||
agent_clauses.append("tu.agent_id = CAST(:agent_id AS uuid)")
|
||||
params["agent_id"] = agent_id
|
||||
if agent_clauses:
|
||||
clauses.append(f"({' OR '.join(agent_clauses)})")
|
||||
if timestamp_gte is not None:
|
||||
clauses.append("tu.timestamp >= :timestamp_gte")
|
||||
params["timestamp_gte"] = timestamp_gte
|
||||
if timestamp_lt is not None:
|
||||
clauses.append("tu.timestamp < :timestamp_lt")
|
||||
params["timestamp_lt"] = timestamp_lt
|
||||
excluded_sources = list(self.ROLLUP_SOURCES)
|
||||
if not include_side_channel:
|
||||
excluded_sources.extend(self.SIDE_CHANNEL_SOURCES)
|
||||
placeholders = []
|
||||
for i, src in enumerate(excluded_sources):
|
||||
key = f"excl_src_{i}"
|
||||
placeholders.append(f":{key}")
|
||||
params[key] = src
|
||||
clauses.append(
|
||||
f"COALESCE(tu.source, 'agent_stream') NOT IN ({', '.join(placeholders)})"
|
||||
)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
|
||||
group_select = ""
|
||||
group_clause = ""
|
||||
join = ""
|
||||
if group_by is not None:
|
||||
group_select = f", {self._GROUP_KEY_EXPRS[group_by]} AS group_key"
|
||||
group_clause = ", group_key"
|
||||
if group_by == "agent":
|
||||
join = "LEFT JOIN agents a ON a.id = tu.agent_id"
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT to_char(tu.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket,
|
||||
COALESCE(SUM(tu.prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(tu.generated_tokens), 0) AS generated_tokens
|
||||
{group_select}
|
||||
FROM token_usage tu
|
||||
{join}
|
||||
{where}
|
||||
GROUP BY bucket{group_clause}
|
||||
ORDER BY bucket ASC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"bucket": row._mapping["bucket"],
|
||||
"prompt_tokens": int(row._mapping["prompt_tokens"]),
|
||||
"generated_tokens": int(row._mapping["generated_tokens"]),
|
||||
**(
|
||||
{"group_key": row._mapping["group_key"]}
|
||||
if group_by is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
def count_in_range(
|
||||
self,
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Count user-initiated requests in the given time range.
|
||||
|
||||
A request = one ``agent_stream`` invocation. Multi-tool agent
|
||||
runs produce multiple rows (one per LLM call) tagged with the
|
||||
same ``request_id``; we DISTINCT on that to count the request
|
||||
once. Pre-migration rows have ``request_id=NULL`` and are
|
||||
counted one-per-row via the second branch (back-compat).
|
||||
Side-channel sources (``title`` / ``compression`` /
|
||||
``rag_condense`` / ``fallback``) are excluded — they aren't
|
||||
user-initiated and shouldn't tick the request limit.
|
||||
"""
|
||||
clauses = [
|
||||
"timestamp >= :start",
|
||||
"timestamp <= :end",
|
||||
"source = 'agent_stream'",
|
||||
]
|
||||
params: dict = {"start": start, "end": end}
|
||||
if user_id is not None:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if api_key is not None:
|
||||
clauses.append("api_key = :api_key")
|
||||
params["api_key"] = api_key
|
||||
where = " AND ".join(clauses)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(DISTINCT request_id) FILTER (WHERE request_id IS NOT NULL)
|
||||
+ COUNT(*) FILTER (WHERE request_id IS NULL)
|
||||
FROM token_usage
|
||||
WHERE {where}
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.scalar()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Repository for ``tool_call_attempts``; executor's proposed/executed/failed writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
|
||||
class ToolCallAttemptsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def record_proposed(
|
||||
self,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
action_name: str,
|
||||
arguments: Any,
|
||||
*,
|
||||
tool_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Insert a ``proposed`` row before the tool executes.
|
||||
|
||||
Returns True if a new row was created. ``ON CONFLICT DO NOTHING``
|
||||
guards against the LLM emitting a duplicate ``call_id``: the
|
||||
existing row stays put rather than a re-insert raising
|
||||
``IntegrityError``.
|
||||
|
||||
``message_id`` / ``user_id`` / ``agent_id`` attribute the attempt
|
||||
from the start — headless runs and parse-failure rows never reach
|
||||
``mark_executed``, and headless runs have no message at all.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tool_call_attempts
|
||||
(call_id, tool_id, tool_name, action_name, arguments,
|
||||
message_id, user_id, agent_id, status)
|
||||
VALUES
|
||||
(:call_id, CAST(:tool_id AS uuid), :tool_name,
|
||||
:action_name, CAST(:arguments AS jsonb),
|
||||
CAST(:message_id AS uuid), :user_id,
|
||||
CAST(:agent_id AS uuid), 'proposed')
|
||||
ON CONFLICT (call_id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"call_id": call_id,
|
||||
"tool_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"action_name": action_name,
|
||||
"arguments": json.dumps(arguments if arguments is not None else {}, cls=PGNativeJSONEncoder),
|
||||
"message_id": message_id,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def upsert_executed(
|
||||
self,
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
action_name: str,
|
||||
arguments: Any,
|
||||
result: Any,
|
||||
*,
|
||||
tool_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
artifact_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Insert OR upgrade a row to ``executed`` — or ``confirmed`` when
|
||||
there is no ``message_id``, as in ``mark_executed``.
|
||||
|
||||
Used as a fallback when ``record_proposed`` failed (DB outage)
|
||||
and the tool ran anyway — preserves the journal so the
|
||||
reconciler can still see the attempt. ``user_id`` / ``agent_id``
|
||||
attribute the synthesized row so it stays visible to per-user /
|
||||
per-agent analytics.
|
||||
|
||||
The ``ON CONFLICT`` upgrade is guarded to a still-``proposed`` row
|
||||
owned by the same ``user_id``: ``call_id`` is the table-wide PK
|
||||
and LLMs reuse ids, so without it a colliding write could upgrade
|
||||
another request's (possibly another tenant's) row.
|
||||
"""
|
||||
result_payload: dict = {"result": result}
|
||||
if artifact_id:
|
||||
result_payload["artifact_id"] = artifact_id
|
||||
status = "executed" if message_id is not None else "confirmed"
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tool_call_attempts
|
||||
(call_id, tool_id, tool_name, action_name, arguments,
|
||||
result, message_id, user_id, agent_id, status)
|
||||
VALUES
|
||||
(:call_id, CAST(:tool_id AS uuid), :tool_name,
|
||||
:action_name, CAST(:arguments AS jsonb),
|
||||
CAST(:result AS jsonb), CAST(:message_id AS uuid),
|
||||
:user_id, CAST(:agent_id AS uuid), :status)
|
||||
ON CONFLICT (call_id) DO UPDATE
|
||||
SET status = :status,
|
||||
result = EXCLUDED.result,
|
||||
message_id = COALESCE(EXCLUDED.message_id, tool_call_attempts.message_id)
|
||||
WHERE tool_call_attempts.status = 'proposed'
|
||||
AND tool_call_attempts.user_id IS NOT DISTINCT FROM EXCLUDED.user_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"call_id": call_id,
|
||||
"tool_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"action_name": action_name,
|
||||
"arguments": json.dumps(arguments if arguments is not None else {}, cls=PGNativeJSONEncoder),
|
||||
"result": json.dumps(result_payload, cls=PGNativeJSONEncoder),
|
||||
"message_id": message_id,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"status": status,
|
||||
},
|
||||
)
|
||||
|
||||
def mark_executed(
|
||||
self,
|
||||
call_id: str,
|
||||
result: Any,
|
||||
*,
|
||||
message_id: Optional[str] = None,
|
||||
artifact_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Flip ``proposed`` → ``executed``, or straight to ``confirmed``
|
||||
when there is no ``message_id`` (a ``save_conversation=False``
|
||||
request reserves no message, so no finalize will confirm it).
|
||||
|
||||
``artifact_id`` (when present) is stored alongside ``result`` in
|
||||
the JSONB as audit data — the reconciler reads it for diagnostic
|
||||
alerts when escalating stuck rows to ``failed``.
|
||||
|
||||
Guarded to a still-``proposed`` row (and, when ``user_id`` is
|
||||
given, to one owned by that user): ``call_id`` is the table-wide
|
||||
PK and LLMs reuse ids, so an unguarded update could flip another
|
||||
request's already-terminal — or another tenant's — row.
|
||||
"""
|
||||
result_payload: dict = {"result": result}
|
||||
if artifact_id:
|
||||
result_payload["artifact_id"] = artifact_id
|
||||
status = "executed" if message_id is not None else "confirmed"
|
||||
sql = (
|
||||
"UPDATE tool_call_attempts SET "
|
||||
"status = :status, result = CAST(:result AS jsonb)"
|
||||
)
|
||||
params: dict[str, Any] = {
|
||||
"call_id": call_id,
|
||||
"status": status,
|
||||
"result": json.dumps(result_payload, cls=PGNativeJSONEncoder),
|
||||
}
|
||||
if message_id is not None:
|
||||
sql += ", message_id = CAST(:message_id AS uuid)"
|
||||
params["message_id"] = message_id
|
||||
sql += " WHERE call_id = :call_id AND status = 'proposed'"
|
||||
if user_id is not None:
|
||||
sql += " AND user_id IS NOT DISTINCT FROM :user_id"
|
||||
params["user_id"] = user_id
|
||||
result_proxy = self._conn.execute(text(sql), params)
|
||||
return result_proxy.rowcount > 0
|
||||
|
||||
def mark_failed(
|
||||
self, call_id: str, error: str, *, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Flip ``proposed`` → ``failed`` with the exception text.
|
||||
|
||||
The status guard matters: ``call_id`` is the table-wide PK and
|
||||
LLMs have been observed reusing ids ("call_0"-style). Without it,
|
||||
a duplicate id hitting an error path would flip an already
|
||||
``executed``/``confirmed`` row — possibly another request's —
|
||||
to ``failed``, and nothing ever repairs that. When ``user_id`` is
|
||||
given the update is additionally scoped to that owner, so a
|
||||
colliding id from another tenant can't fail this row.
|
||||
"""
|
||||
sql = (
|
||||
"UPDATE tool_call_attempts SET status = 'failed', error = :error "
|
||||
"WHERE call_id = :call_id AND status = 'proposed'"
|
||||
)
|
||||
params: dict[str, Any] = {"call_id": call_id, "error": error}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id IS NOT DISTINCT FROM :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Repository for the ``user_custom_models`` table.
|
||||
|
||||
Backs the end-user "Bring Your Own Model" feature. Each row is one
|
||||
user-supplied OpenAI-compatible endpoint (Mistral, Together, vLLM, ...).
|
||||
The ``id`` UUID is the internal DocsGPT identifier (what agents store
|
||||
in ``default_model_id``); ``upstream_model_id`` is what we send verbatim
|
||||
to the provider's API.
|
||||
|
||||
API key handling: callers pass plaintext via ``api_key_plaintext``;
|
||||
this module wraps the existing ``application.security.encryption``
|
||||
helper (AES-CBC + per-user PBKDF2 salt) and writes the base64 ciphertext
|
||||
to the ``api_key_encrypted`` column. Decryption is the caller's
|
||||
responsibility (they hold the ``user_id``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, func, text
|
||||
|
||||
from application.security.encryption import (
|
||||
decrypt_credentials,
|
||||
encrypt_credentials,
|
||||
)
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.models import user_custom_models_table
|
||||
|
||||
|
||||
_ALLOWED_CAPABILITY_KEYS = frozenset(
|
||||
{
|
||||
"supports_tools",
|
||||
"supports_structured_output",
|
||||
"supports_streaming",
|
||||
"attachments",
|
||||
"context_window",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserCustomModelsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Encryption wrappers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _encrypt_api_key(api_key_plaintext: str, user_id: str) -> str:
|
||||
"""Encrypt ``api_key_plaintext`` with the per-user PBKDF2 scheme."""
|
||||
return encrypt_credentials({"api_key": api_key_plaintext}, user_id)
|
||||
|
||||
@staticmethod
|
||||
def _decrypt_api_key(api_key_encrypted: str, user_id: str) -> Optional[str]:
|
||||
"""Decrypt the API key. Returns None on failure (which the caller
|
||||
should surface as a configuration error rather than silently
|
||||
proceeding with the upstream call)."""
|
||||
if not api_key_encrypted:
|
||||
return None
|
||||
creds = decrypt_credentials(api_key_encrypted, user_id)
|
||||
return creds.get("api_key") if creds else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_capabilities(caps: Optional[dict]) -> dict:
|
||||
"""Drop unknown keys; nothing else is forced. Callers (the route
|
||||
layer) are responsible for value validation (numeric ranges,
|
||||
attachment alias resolution)."""
|
||||
if not caps:
|
||||
return {}
|
||||
return {k: v for k, v in caps.items() if k in _ALLOWED_CAPABILITY_KEYS}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
upstream_model_id: str,
|
||||
display_name: str,
|
||||
base_url: str,
|
||||
api_key_plaintext: str,
|
||||
description: str = "",
|
||||
capabilities: Optional[dict] = None,
|
||||
enabled: bool = True,
|
||||
) -> dict:
|
||||
values = {
|
||||
"user_id": user_id,
|
||||
"upstream_model_id": upstream_model_id,
|
||||
"display_name": display_name,
|
||||
"description": description or "",
|
||||
"base_url": base_url,
|
||||
"api_key_encrypted": self._encrypt_api_key(api_key_plaintext, user_id),
|
||||
"capabilities": self._normalize_capabilities(capabilities),
|
||||
"enabled": bool(enabled),
|
||||
}
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = (
|
||||
pg_insert(user_custom_models_table)
|
||||
.values(**values)
|
||||
.returning(user_custom_models_table)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, model_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM user_custom_models "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": str(model_id), "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM user_custom_models "
|
||||
"WHERE user_id = :user_id ORDER BY created_at DESC"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, model_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Apply a partial update.
|
||||
|
||||
Special-cases ``api_key_plaintext``: when present, it is encrypted
|
||||
and stored in ``api_key_encrypted``. When absent (or empty), the
|
||||
existing ciphertext is kept untouched. This is the wire-shape
|
||||
``PATCH`` expects (the UI sends a blank password field when the
|
||||
operator wants to keep the existing key).
|
||||
"""
|
||||
allowed = {
|
||||
"upstream_model_id",
|
||||
"display_name",
|
||||
"description",
|
||||
"base_url",
|
||||
"capabilities",
|
||||
"enabled",
|
||||
}
|
||||
values: dict[str, Any] = {}
|
||||
for col, val in fields.items():
|
||||
if col not in allowed or val is None:
|
||||
continue
|
||||
if col == "capabilities":
|
||||
values[col] = self._normalize_capabilities(val)
|
||||
elif col == "enabled":
|
||||
values[col] = bool(val)
|
||||
else:
|
||||
values[col] = val
|
||||
|
||||
api_key_plaintext = fields.get("api_key_plaintext")
|
||||
if api_key_plaintext:
|
||||
values["api_key_encrypted"] = self._encrypt_api_key(
|
||||
api_key_plaintext, user_id
|
||||
)
|
||||
|
||||
if not values:
|
||||
return False
|
||||
values["updated_at"] = func.now()
|
||||
|
||||
t = user_custom_models_table
|
||||
stmt = (
|
||||
t.update()
|
||||
.where(t.c.id == str(model_id))
|
||||
.where(t.c.user_id == user_id)
|
||||
.values(**values)
|
||||
)
|
||||
result = self._conn.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, model_id: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM user_custom_models "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": str(model_id), "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Decryption helpers exposed to the registry layer
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_decrypted_api_key(
|
||||
self, model_id: str, user_id: str
|
||||
) -> Optional[str]:
|
||||
"""Convenience: fetch the row and return the decrypted API key,
|
||||
or ``None`` if the row is missing or decryption fails."""
|
||||
row = self.get(model_id, user_id)
|
||||
if row is None:
|
||||
return None
|
||||
return self._decrypt_api_key(row.get("api_key_encrypted", ""), user_id)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Repository for the ``user_logs`` table (write-only).
|
||||
|
||||
The single production write site is the per-stream log entry in
|
||||
answer/routes/base.py. Reads go through the unified timeline query in
|
||||
api/user/analytics/routes.py (GetUserLogs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.serialization import PGNativeJSONEncoder
|
||||
|
||||
|
||||
class UserLogsRepository:
|
||||
"""Postgres-backed replacement for Mongo ``user_logs_collection``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def insert(
|
||||
self,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
data: Optional[dict] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_logs (user_id, endpoint, data, timestamp)
|
||||
VALUES (:user_id, :endpoint, CAST(:data AS jsonb), COALESCE(:timestamp, now()))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"endpoint": endpoint,
|
||||
"data": json.dumps(data, cls=PGNativeJSONEncoder) if data is not None else None,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
# NOTE: reads live in the unified timeline query in
|
||||
# api/user/analytics/routes.py (GetUserLogs) — extend that rather
|
||||
# than re-adding per-table readers here.
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Repository for the ``user_roles`` RBAC grant table.
|
||||
|
||||
The table stores only elevated grants; the ``user`` role is implicit. Grants
|
||||
are keyed by ``(user_id, role, source)`` so a manual grant and an
|
||||
OIDC-group-derived grant for the same user coexist and revoke independently.
|
||||
All methods take a ``Connection`` and do not manage their own transactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
VALID_SOURCES = ("manual", "oidc_group")
|
||||
|
||||
|
||||
class UserRolesRepository:
|
||||
"""Persisted role grants keyed by the auth ``sub`` (``user_id``)."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def role_names_for(self, user_id: str) -> list[str]:
|
||||
"""Return the distinct elevated roles granted to ``user_id`` (any source)."""
|
||||
if not user_id:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT DISTINCT role FROM user_roles WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
def list_admins(self) -> list[dict]:
|
||||
"""Return one row per user holding ``admin``, with earliest grant + sources."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_id,
|
||||
min(granted_at) AS granted_at,
|
||||
array_agg(DISTINCT source ORDER BY source) AS sources
|
||||
FROM user_roles
|
||||
WHERE role = 'admin'
|
||||
GROUP BY user_id
|
||||
ORDER BY min(granted_at)
|
||||
"""
|
||||
)
|
||||
)
|
||||
return [
|
||||
{"user_id": row[0], "granted_at": row[1], "sources": list(row[2])}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
def list_for(self, user_id: str) -> list[dict]:
|
||||
"""Return all grant rows for ``user_id`` (for audit/inspection)."""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM user_roles WHERE user_id = :user_id ORDER BY granted_at"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def grant(
|
||||
self,
|
||||
user_id: str,
|
||||
role: str = "admin",
|
||||
source: str = "manual",
|
||||
granted_by: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Idempotently grant ``role`` to ``user_id`` from ``source``.
|
||||
|
||||
Returns True when a new row was inserted, False when it already existed.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_roles (user_id, role, source, granted_by)
|
||||
VALUES (:user_id, :role, :source, :granted_by)
|
||||
ON CONFLICT (user_id, role, source) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "role": role, "source": source, "granted_by": granted_by},
|
||||
)
|
||||
return bool(result.rowcount)
|
||||
|
||||
def revoke(self, user_id: str, role: str = "admin", source: str = "manual") -> bool:
|
||||
"""Revoke ``role`` from ``user_id`` for ``source``. Returns True if removed."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM user_roles
|
||||
WHERE user_id = :user_id AND role = :role AND source = :source
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "role": role, "source": source},
|
||||
)
|
||||
return bool(result.rowcount)
|
||||
|
||||
def reconcile_oidc_admin(self, user_id: str, is_admin: bool) -> Optional[str]:
|
||||
"""Sync the ``oidc_group`` admin grant for ``user_id``; never touches manual grants.
|
||||
|
||||
Returns ``"granted"`` / ``"revoked"`` when a change occurred, else ``None``.
|
||||
"""
|
||||
if is_admin:
|
||||
granted = self.grant(user_id, "admin", source="oidc_group", granted_by="oidc")
|
||||
return "granted" if granted else None
|
||||
revoked = self.revoke(user_id, "admin", source="oidc_group")
|
||||
return "revoked" if revoked else None
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Repository for the ``user_tools`` table.
|
||||
|
||||
Covers every operation the legacy Mongo code performs on
|
||||
``user_tools_collection``:
|
||||
|
||||
1. ``find`` by user in tools/routes.py and base.py (list all / active)
|
||||
2. ``find_one`` by id in tools/routes.py and sharing.py (get single)
|
||||
3. ``insert_one`` in tools/routes.py and mcp.py (create)
|
||||
4. ``update_one`` in tools/routes.py and mcp.py (update fields)
|
||||
5. ``delete_one`` in tools/routes.py (delete)
|
||||
6. ``find`` by user+status in stream_processor.py and tool_executor.py (active tools)
|
||||
7. ``find_one`` by user+name in mcp.py (upsert check)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
|
||||
|
||||
_JSONB_COLUMNS = {"config", "config_requirements", "actions"}
|
||||
_SCALAR_COLUMNS = {"name", "custom_name", "display_name", "description", "status"}
|
||||
_ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
|
||||
|
||||
|
||||
def _encode_jsonb(value: Any) -> Any:
|
||||
"""Serialize a Python value for a JSONB bind parameter.
|
||||
|
||||
Accepts ``None``, already-encoded strings, or Python dict/list. Returns a
|
||||
JSON string suitable for ``CAST(:x AS jsonb)``.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
class UserToolsRepository:
|
||||
"""Postgres-backed replacement for Mongo ``user_tools_collection``."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
*,
|
||||
config: Optional[dict] = None,
|
||||
custom_name: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
config_requirements: Optional[dict] = None,
|
||||
actions: Optional[list] = None,
|
||||
status: bool = True,
|
||||
extra: Optional[dict] = None,
|
||||
legacy_mongo_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Insert a new tool row. ``extra`` is merged into the config JSONB."""
|
||||
cfg = config or {}
|
||||
if extra:
|
||||
cfg.update(extra)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_tools (
|
||||
user_id, name, custom_name, display_name, description,
|
||||
config, config_requirements, actions, status, legacy_mongo_id
|
||||
)
|
||||
VALUES (
|
||||
:user_id, :name, :custom_name, :display_name, :description,
|
||||
CAST(:config AS jsonb),
|
||||
CAST(:config_requirements AS jsonb),
|
||||
CAST(:actions AS jsonb),
|
||||
:status, :legacy_mongo_id
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"custom_name": custom_name,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"config": json.dumps(cfg),
|
||||
"config_requirements": _encode_jsonb(config_requirements or {}),
|
||||
"actions": _encode_jsonb(actions or []),
|
||||
"status": status,
|
||||
"legacy_mongo_id": legacy_mongo_id,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, tool_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": tool_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Fetch a user_tool by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM user_tools WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_any(self, tool_id: str, user_id: str) -> Optional[dict]:
|
||||
"""Resolve a user_tool by PG UUID or legacy Mongo ObjectId string.
|
||||
|
||||
Cutover helper: route handlers may receive either shape from
|
||||
older clients. Always returns a row scoped to ``user_id``.
|
||||
"""
|
||||
if looks_like_uuid(tool_id):
|
||||
row = self.get(tool_id, user_id)
|
||||
if row is not None:
|
||||
return row
|
||||
return self.get_by_legacy_id(tool_id, user_id)
|
||||
|
||||
def get_by_id(self, tool_id: str) -> Optional[dict]:
|
||||
"""Fetch a user_tool by id with NO ownership scoping.
|
||||
|
||||
Used ONLY after a team-grant authorization check (team sharing). Callers
|
||||
MUST strip ``encrypted_credentials`` before returning a non-owner's tool.
|
||||
"""
|
||||
if not looks_like_uuid(tool_id):
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": tool_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_by_ids(self, tool_ids) -> list[dict]:
|
||||
"""Fetch user_tools whose id is in ``tool_ids`` (team-shared listing path)."""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
if not ids:
|
||||
return []
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"),
|
||||
{"ids": ids},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM user_tools WHERE user_id = :user_id ORDER BY created_at"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_active_for_user(self, user_id: str) -> list[dict]:
|
||||
"""Return only tools with ``status = true`` — matches the legacy
|
||||
``find({"user": user, "status": True})`` used by the answer pipeline."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM user_tools "
|
||||
"WHERE user_id = :user_id AND status = true "
|
||||
"ORDER BY created_at"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_by_user_and_name(self, user_id: str, name: str) -> Optional[dict]:
|
||||
"""Used by the MCP save flow to decide between insert and update."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM user_tools "
|
||||
"WHERE user_id = :user_id AND name = :name "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"user_id": user_id, "name": name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def update(self, tool_id: str, user_id: str, fields: dict) -> bool:
|
||||
"""Update arbitrary fields on a tool row.
|
||||
|
||||
``fields`` maps column names to new values. Only columns in
|
||||
``_ALLOWED_COLUMNS`` are honored; unknown keys are silently dropped
|
||||
to keep route handlers from accidentally leaking DB shape. JSONB
|
||||
values are accepted as dicts/lists and serialized here.
|
||||
|
||||
Returns ``True`` if the row was updated, ``False`` if the id/user
|
||||
didn't match anything.
|
||||
"""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_COLUMNS}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
set_clauses: list[str] = []
|
||||
params: dict = {"id": tool_id, "user_id": user_id}
|
||||
for col, val in filtered.items():
|
||||
if col not in _ALLOWED_COLUMNS:
|
||||
raise ValueError(f"disallowed column: {col!r}")
|
||||
if col in _JSONB_COLUMNS:
|
||||
set_clauses.append(f"{col} = CAST(:{col} AS jsonb)")
|
||||
params[col] = _encode_jsonb(val)
|
||||
else:
|
||||
set_clauses.append(f"{col} = :{col}")
|
||||
params[col] = val
|
||||
set_clauses.append("updated_at = now()")
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE user_tools
|
||||
SET {", ".join(set_clauses)}
|
||||
WHERE id = CAST(:id AS uuid) AND user_id = :user_id
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete(self, tool_id: str, user_id: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text("DELETE FROM user_tools WHERE id = CAST(:id AS uuid) AND user_id = :user_id"),
|
||||
{"id": tool_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Repository for the ``users`` table.
|
||||
|
||||
Covers every operation the legacy Mongo code performs on
|
||||
``users_collection``:
|
||||
|
||||
1. ``ensure_user_doc`` in ``application/api/user/base.py`` (upsert + get)
|
||||
2. Pin/unpin agents in ``application/api/user/agents/routes.py`` (add/remove
|
||||
on ``agent_preferences.pinned``)
|
||||
3. Share accept/reject in ``application/api/user/agents/sharing.py`` (add/
|
||||
bulk-remove on ``agent_preferences.shared_with_me``)
|
||||
4. Cascade delete of an agent id from both arrays at once
|
||||
|
||||
All array mutations are implemented as single atomic UPDATE statements
|
||||
using JSONB operators (``jsonb_set``, ``jsonb_array_elements``, ``@>``)
|
||||
so there is no read-modify-write race between concurrent writers on the
|
||||
same user row.
|
||||
|
||||
The repository takes a ``Connection`` and does not manage its own
|
||||
transactions. Callers are responsible for wrapping writes in
|
||||
``with engine.begin() as conn:`` (production) or the test fixture's
|
||||
rollback-per-test connection (tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
|
||||
|
||||
_DEFAULT_PREFERENCES = '{"pinned": [], "shared_with_me": []}'
|
||||
|
||||
|
||||
def _canonical_uuid(value: str) -> Optional[str]:
|
||||
"""Return the canonical UUID string for ``value``, or ``None`` when malformed."""
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class UsersRepository:
|
||||
"""Postgres-backed replacement for Mongo ``users_collection`` writes/reads."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def get(self, user_id: str) -> Optional[dict]:
|
||||
"""Return the user row as a dict, or ``None`` if missing.
|
||||
|
||||
Args:
|
||||
user_id: Auth-provider ``sub`` (opaque string).
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM users WHERE user_id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upsert
|
||||
# ------------------------------------------------------------------
|
||||
def upsert(self, user_id: str, email: Optional[str] = None) -> dict:
|
||||
"""Ensure a row exists for ``user_id`` and return it.
|
||||
|
||||
Matches Mongo's ``find_one_and_update(..., $setOnInsert, upsert=True,
|
||||
return_document=AFTER)`` semantics: if the row exists, preferences
|
||||
are preserved untouched; if it doesn't, a new row is created with
|
||||
default preferences.
|
||||
|
||||
``email`` (from the OIDC claim) is set on insert and refreshed on
|
||||
conflict, but only when provided — ``COALESCE`` preserves a previously
|
||||
stored email when a non-OIDC caller upserts with ``email=None`` (most
|
||||
upserts come from request paths that don't carry the email claim).
|
||||
|
||||
The conflict branch also updates ``user_id`` (a no-op) so ``RETURNING *``
|
||||
fires on both the insert and conflict paths.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (user_id, email, agent_preferences)
|
||||
VALUES (:user_id, :email, CAST(:default_prefs AS jsonb))
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET user_id = EXCLUDED.user_id,
|
||||
email = COALESCE(EXCLUDED.email, users.email)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "email": email, "default_prefs": _DEFAULT_PREFERENCES},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def set_email(self, user_id: str, email: Optional[str]) -> None:
|
||||
"""Set a user's email when provided (targeted update, no full upsert).
|
||||
|
||||
Used to backfill an existing user's email from the OIDC claim on login
|
||||
without re-running the upsert. No-op when ``email`` is falsy so a login
|
||||
without the claim never wipes a stored value.
|
||||
"""
|
||||
if not email:
|
||||
return
|
||||
self._conn.execute(
|
||||
text("UPDATE users SET email = :email WHERE user_id = :user_id"),
|
||||
{"user_id": user_id, "email": email},
|
||||
)
|
||||
|
||||
def find_by_email(self, email: str) -> Optional[dict]:
|
||||
"""Return the user row whose email matches ``email`` (case-insensitive).
|
||||
|
||||
Used by the add-team-member-by-email flow to resolve an email to its
|
||||
``user_id`` (sub). Returns None when no user has that email.
|
||||
"""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM users WHERE lower(email) = lower(:email) LIMIT 1"),
|
||||
{"email": email.strip()},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pinned agents
|
||||
# ------------------------------------------------------------------
|
||||
def add_pinned(self, user_id: str, agent_id: str) -> None:
|
||||
"""Idempotently append ``agent_id`` to ``agent_preferences.pinned``.
|
||||
|
||||
Uses ``@>`` containment so a duplicate add is a no-op rather than a
|
||||
silent double-insert. The whole update is a single atomic statement
|
||||
so concurrent add_pinned calls on the same user cannot interleave
|
||||
into a read-modify-write race.
|
||||
"""
|
||||
self._append_to_jsonb_array(user_id, "pinned", agent_id)
|
||||
|
||||
def remove_pinned(self, user_id: str, agent_id: str) -> None:
|
||||
"""Remove ``agent_id`` from ``agent_preferences.pinned`` if present."""
|
||||
self._remove_from_jsonb_array(user_id, "pinned", [agent_id])
|
||||
|
||||
def remove_pinned_bulk(self, user_id: str, agent_ids: Iterable[str]) -> None:
|
||||
"""Remove every id in ``agent_ids`` from ``agent_preferences.pinned``.
|
||||
|
||||
No-op if the list is empty. Unknown ids are silently ignored so
|
||||
callers can pass the full "stale" set without pre-filtering.
|
||||
"""
|
||||
ids = list(agent_ids)
|
||||
if not ids:
|
||||
return
|
||||
self._remove_from_jsonb_array(user_id, "pinned", ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shared-with-me agents
|
||||
# ------------------------------------------------------------------
|
||||
def add_shared(self, user_id: str, agent_id: str) -> None:
|
||||
"""Idempotently append ``agent_id`` to ``agent_preferences.shared_with_me``."""
|
||||
self._append_to_jsonb_array(user_id, "shared_with_me", agent_id)
|
||||
|
||||
def remove_shared_bulk(self, user_id: str, agent_ids: Iterable[str]) -> None:
|
||||
"""Bulk-remove from ``agent_preferences.shared_with_me``. Empty list is a no-op."""
|
||||
ids = list(agent_ids)
|
||||
if not ids:
|
||||
return
|
||||
self._remove_from_jsonb_array(user_id, "shared_with_me", ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Combined removal — called when an agent is hard-deleted
|
||||
# ------------------------------------------------------------------
|
||||
def remove_agent_from_all(self, user_id: str, agent_id: str) -> None:
|
||||
"""Remove ``agent_id`` from BOTH pinned and shared_with_me atomically.
|
||||
|
||||
Mirrors the Mongo ``$pull`` that targets both nested array fields
|
||||
in one ``update_one`` — see ``application/api/user/agents/routes.py``
|
||||
around the agent-delete path.
|
||||
"""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET
|
||||
agent_preferences = jsonb_set(
|
||||
jsonb_set(
|
||||
agent_preferences,
|
||||
'{pinned}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(agent_preferences->'pinned', '[]'::jsonb)
|
||||
) AS elem
|
||||
WHERE (elem #>> '{}') != :agent_id
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
'{shared_with_me}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(agent_preferences->'shared_with_me', '[]'::jsonb)
|
||||
) AS elem
|
||||
WHERE (elem #>> '{}') != :agent_id
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "agent_id": agent_id},
|
||||
)
|
||||
|
||||
def set_default_tool_enabled(
|
||||
self, user_id: str, tool_name: str, enabled: bool
|
||||
) -> None:
|
||||
"""Toggle a default chat tool in ``tool_preferences`` (idempotent)."""
|
||||
self.upsert(user_id)
|
||||
if enabled:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET tool_preferences = jsonb_set(
|
||||
COALESCE(tool_preferences, '{}'::jsonb),
|
||||
'{disabled_default_tools}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(
|
||||
tool_preferences->'disabled_default_tools',
|
||||
'[]'::jsonb
|
||||
)
|
||||
) AS elem
|
||||
WHERE (elem #>> '{}') != :tool_name
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_name": tool_name},
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET tool_preferences = jsonb_set(
|
||||
COALESCE(tool_preferences, '{}'::jsonb),
|
||||
'{disabled_default_tools}',
|
||||
CASE
|
||||
WHEN COALESCE(
|
||||
tool_preferences->'disabled_default_tools',
|
||||
'[]'::jsonb
|
||||
) @> to_jsonb(CAST(:tool_name AS text))
|
||||
THEN tool_preferences->'disabled_default_tools'
|
||||
ELSE
|
||||
COALESCE(
|
||||
tool_preferences->'disabled_default_tools',
|
||||
'[]'::jsonb
|
||||
) || to_jsonb(CAST(:tool_name AS text))
|
||||
END
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_name": tool_name},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SCIM provisioning
|
||||
# ------------------------------------------------------------------
|
||||
def create(self, user_id: str, active: bool = True) -> Optional[dict]:
|
||||
"""Insert a new user row; ``None`` means a user with that id already exists.
|
||||
|
||||
SCIM ``userName`` is case-insensitive (caseExact=false), so a row that
|
||||
differs only by case counts as a duplicate. The pre-check keeps SCIM
|
||||
from provisioning a second row for a user the OIDC login already
|
||||
created under different casing; the ``ON CONFLICT`` clause remains an
|
||||
exact-match backstop for the concurrent-insert race.
|
||||
"""
|
||||
existing = self._conn.execute(
|
||||
text("SELECT 1 FROM users WHERE lower(user_id) = lower(:user_id)"),
|
||||
{"user_id": user_id},
|
||||
).first()
|
||||
if existing is not None:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (user_id, agent_preferences, active)
|
||||
VALUES (:user_id, CAST(:default_prefs AS jsonb), :active)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "default_prefs": _DEFAULT_PREFERENCES, "active": active},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_pk(self, pk: str) -> Optional[dict]:
|
||||
"""Return the user row by primary-key ``id``, or ``None`` (including malformed UUIDs)."""
|
||||
canonical = _canonical_uuid(pk)
|
||||
if canonical is None:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM users WHERE id = CAST(:pk AS uuid)"),
|
||||
{"pk": canonical},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def set_active(self, pk: str, active: bool) -> Optional[dict]:
|
||||
"""Set ``active`` on the row with primary-key ``id`` and return the updated row."""
|
||||
canonical = _canonical_uuid(pk)
|
||||
if canonical is None:
|
||||
return None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET active = :active, updated_at = now()
|
||||
WHERE id = CAST(:pk AS uuid)
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{"pk": canonical, "active": active},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_paginated(
|
||||
self, user_name: Optional[str], offset: int, limit: int
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""Return ``(total, page)`` ordered by ``created_at, id``; optional exact ``user_id`` filter."""
|
||||
where = ""
|
||||
filter_params: dict = {}
|
||||
if user_name is not None:
|
||||
# SCIM userName is case-insensitive (caseExact=false); match on
|
||||
# lower(user_id) so the IdP finds the account regardless of casing.
|
||||
where = "WHERE lower(user_id) = lower(:user_name)"
|
||||
filter_params = {"user_name": user_name}
|
||||
total = self._conn.execute(
|
||||
text(f"SELECT count(*) FROM users {where}"), filter_params
|
||||
).scalar_one()
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT * FROM users
|
||||
{where}
|
||||
ORDER BY created_at, id
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{**filter_params, "limit": limit, "offset": offset},
|
||||
)
|
||||
return int(total), [row_to_dict(row) for row in result.fetchall()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _append_to_jsonb_array(self, user_id: str, key: str, agent_id: str) -> None:
|
||||
"""Idempotent append of ``agent_id`` to ``agent_preferences.<key>``.
|
||||
|
||||
The ``key`` argument is NOT user input — it's hard-coded by the
|
||||
calling method (``pinned`` / ``shared_with_me``). It goes into the
|
||||
SQL literal because ``jsonb_set`` requires a path literal, not a
|
||||
bind parameter. This is safe as long as callers never pass
|
||||
untrusted strings for ``key``.
|
||||
"""
|
||||
if key not in ("pinned", "shared_with_me"):
|
||||
raise ValueError(f"unsupported jsonb key: {key!r}")
|
||||
self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET
|
||||
agent_preferences = jsonb_set(
|
||||
agent_preferences,
|
||||
'{{{key}}}',
|
||||
CASE
|
||||
WHEN agent_preferences->'{key}' @> to_jsonb(CAST(:agent_id AS text))
|
||||
THEN agent_preferences->'{key}'
|
||||
ELSE
|
||||
COALESCE(agent_preferences->'{key}', '[]'::jsonb)
|
||||
|| to_jsonb(CAST(:agent_id AS text))
|
||||
END
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "agent_id": agent_id},
|
||||
)
|
||||
|
||||
def _remove_from_jsonb_array(
|
||||
self, user_id: str, key: str, agent_ids: list[str]
|
||||
) -> None:
|
||||
"""Remove every id in ``agent_ids`` from ``agent_preferences.<key>``."""
|
||||
if key not in ("pinned", "shared_with_me"):
|
||||
raise ValueError(f"unsupported jsonb key: {key!r}")
|
||||
self._conn.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET
|
||||
agent_preferences = jsonb_set(
|
||||
agent_preferences,
|
||||
'{{{key}}}',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(agent_preferences->'{key}', '[]'::jsonb)
|
||||
) AS elem
|
||||
WHERE NOT ((elem #>> '{{}}') = ANY(:agent_ids))
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "agent_ids": agent_ids},
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Repository for the ``wiki_pages`` table.
|
||||
|
||||
Authoritative, source-scoped storage for an LLM-editable wiki source. Mirrors
|
||||
``MemoriesRepository`` but is keyed on ``source_id`` (not ``user_id``/``tool_id``)
|
||||
and adds wiki-specific semantics: ``content_hash`` short-circuit on identical
|
||||
writes, a monotonically increasing ``version``, and an ``embed_status`` setter
|
||||
driving the async per-page re-embed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.utils import num_tokens_from_string
|
||||
|
||||
|
||||
class WikiPageConflict(Exception):
|
||||
"""Raised when a version-checked upsert loses an optimistic-lock race."""
|
||||
|
||||
|
||||
def _content_hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape ``LIKE`` wildcards so a path prefix matches literally.
|
||||
|
||||
Without this a path containing ``_`` or ``%`` (e.g. ``/api_v1/``) would be
|
||||
a wildcard pattern, so a prefix list/delete could over-match sibling paths.
|
||||
Backslash is escaped first so the wildcard escapes are not double-escaped;
|
||||
callers must pair the result with ``LIKE ... ESCAPE '\\'``.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
class WikiPagesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
source_id: str,
|
||||
path: str,
|
||||
content: str,
|
||||
title: Optional[str] = None,
|
||||
updated_by: Optional[str] = None,
|
||||
updated_via: Optional[str] = None,
|
||||
expected_version: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Create or overwrite a page.
|
||||
|
||||
Short-circuits (no write, returns the row unchanged) when an existing
|
||||
page already has the same ``content_hash``. Otherwise inserts, or on
|
||||
conflict updates content/title/hash/updated_by, bumps ``version``, and
|
||||
resets ``embed_status`` to ``pending``.
|
||||
|
||||
When ``expected_version`` is given, the update on an existing row is
|
||||
conditional on its ``version`` still matching; a concurrent write that
|
||||
changed the version causes zero rows to update and raises
|
||||
:class:`WikiPageConflict` so the caller can re-read and retry.
|
||||
"""
|
||||
content_hash = _content_hash(content)
|
||||
|
||||
existing = self.get_by_path(source_id, path)
|
||||
if existing is not None and existing.get("content_hash") == content_hash:
|
||||
return existing
|
||||
|
||||
if expected_version is not None and existing is not None:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE wiki_pages SET
|
||||
title = :title,
|
||||
content = :content,
|
||||
token_count = :token_count,
|
||||
content_hash = :content_hash,
|
||||
updated_by = :updated_by,
|
||||
updated_via = :updated_via,
|
||||
version = version + 1,
|
||||
embed_status = 'pending',
|
||||
updated_at = now()
|
||||
WHERE source_id = CAST(:source_id AS uuid)
|
||||
AND path = :path
|
||||
AND version = :expected_version
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"source_id": source_id,
|
||||
"path": path,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"token_count": num_tokens_from_string(content),
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
"expected_version": expected_version,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None:
|
||||
raise WikiPageConflict(
|
||||
f"Page {path} changed underneath the edit (expected version "
|
||||
f"{expected_version})."
|
||||
)
|
||||
return row_to_dict(row)
|
||||
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO wiki_pages
|
||||
(source_id, path, title, content, token_count,
|
||||
content_hash, updated_by, updated_via, embed_status)
|
||||
VALUES
|
||||
(CAST(:source_id AS uuid), :path, :title, :content, :token_count,
|
||||
:content_hash, :updated_by, :updated_via, 'pending')
|
||||
ON CONFLICT (source_id, path)
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
token_count = EXCLUDED.token_count,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_via = EXCLUDED.updated_via,
|
||||
version = wiki_pages.version + 1,
|
||||
embed_status = 'pending',
|
||||
updated_at = now()
|
||||
RETURNING *
|
||||
"""
|
||||
),
|
||||
{
|
||||
"source_id": source_id,
|
||||
"path": path,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"token_count": num_tokens_from_string(content),
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get_by_path(self, source_id: str, path: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM wiki_pages "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) AND path = :path"
|
||||
),
|
||||
{"source_id": source_id, "path": path},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_by_prefix(self, source_id: str, prefix: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM wiki_pages "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) "
|
||||
"AND path LIKE :prefix ESCAPE '\\' "
|
||||
"ORDER BY path"
|
||||
),
|
||||
{"source_id": source_id, "prefix": _escape_like(prefix) + "%"},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def list_for_source(self, source_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM wiki_pages "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) ORDER BY path"
|
||||
),
|
||||
{"source_id": source_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def delete_by_path(self, source_id: str, path: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM wiki_pages "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) AND path = :path"
|
||||
),
|
||||
{"source_id": source_id, "path": path},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_prefix(self, source_id: str, prefix: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM wiki_pages "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) "
|
||||
"AND path LIKE :prefix ESCAPE '\\'"
|
||||
),
|
||||
{"source_id": source_id, "prefix": _escape_like(prefix) + "%"},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def update_path(self, source_id: str, old_path: str, new_path: str) -> bool:
|
||||
"""Move a page. Rejects (returns ``False``) if ``new_path`` already exists."""
|
||||
if self.get_by_path(source_id, new_path) is not None:
|
||||
return False
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE wiki_pages SET path = :new_path, updated_at = now() "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) AND path = :old_path"
|
||||
),
|
||||
{"source_id": source_id, "old_path": old_path, "new_path": new_path},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def set_embed_status(self, source_id: str, path: str, status: str) -> bool:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE wiki_pages SET embed_status = :status "
|
||||
"WHERE source_id = CAST(:source_id AS uuid) AND path = :path"
|
||||
),
|
||||
{"source_id": source_id, "path": path, "status": status},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
def build_wiki_directory_structure(pages: list[dict]) -> dict:
|
||||
"""Build the nested ``directory_structure`` tree from wiki page rows.
|
||||
|
||||
Mirrors the ingest pipeline's tree shape so ``internal_search.list_files``
|
||||
and the UI file tree work unchanged: leaf files carry ``type``,
|
||||
``size_bytes`` and ``token_count`` metadata.
|
||||
"""
|
||||
tree: dict = {}
|
||||
for page in pages:
|
||||
path = (page.get("path") or "").lstrip("/")
|
||||
if not path:
|
||||
continue
|
||||
parts = path.split("/")
|
||||
current = tree
|
||||
for i, part in enumerate(parts):
|
||||
if i == len(parts) - 1:
|
||||
content = page.get("content") or ""
|
||||
current[part] = {
|
||||
"type": "text/markdown",
|
||||
"size_bytes": len(content.encode("utf-8")),
|
||||
"token_count": page.get("token_count") or 0,
|
||||
}
|
||||
else:
|
||||
current = current.setdefault(part, {})
|
||||
return tree
|
||||
|
||||
|
||||
def rebuild_wiki_directory_structure(
|
||||
conn: Connection, source_id: str, owner_id: str
|
||||
) -> dict:
|
||||
"""Recompute ``sources.directory_structure`` from the source's wiki pages.
|
||||
|
||||
Returns the rebuilt tree. The write is owner-scoped so it matches the
|
||||
``sources`` repo's ``WHERE id AND user_id`` update contract.
|
||||
"""
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
pages = WikiPagesRepository(conn).list_for_source(source_id)
|
||||
tree = build_wiki_directory_structure(pages)
|
||||
total_tokens = sum(int(page.get("token_count") or 0) for page in pages)
|
||||
SourcesRepository(conn).update(
|
||||
source_id, owner_id, {"directory_structure": tree, "tokens": total_tokens}
|
||||
)
|
||||
return tree
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Repository for the ``workflow_edges`` table.
|
||||
|
||||
Covers bulk insert, find by version, and delete operations that the
|
||||
workflow routes perform on ``workflow_edges_collection`` in Mongo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.models import workflow_edges_table
|
||||
|
||||
|
||||
class WorkflowEdgesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
workflow_id: str,
|
||||
graph_version: int,
|
||||
edge_id: str,
|
||||
from_node_id: str,
|
||||
to_node_id: str,
|
||||
*,
|
||||
source_handle: str | None = None,
|
||||
target_handle: str | None = None,
|
||||
config: dict | None = None,
|
||||
) -> dict:
|
||||
"""Create a single edge.
|
||||
|
||||
``from_node_id`` and ``to_node_id`` are the Postgres **UUID PKs**
|
||||
of the workflow_nodes rows (not user-provided node_id strings).
|
||||
"""
|
||||
values: dict = {
|
||||
"workflow_id": workflow_id,
|
||||
"graph_version": graph_version,
|
||||
"edge_id": edge_id,
|
||||
"from_node_id": from_node_id,
|
||||
"to_node_id": to_node_id,
|
||||
}
|
||||
if source_handle is not None:
|
||||
values["source_handle"] = source_handle
|
||||
if target_handle is not None:
|
||||
values["target_handle"] = target_handle
|
||||
if config is not None:
|
||||
values["config"] = config
|
||||
|
||||
stmt = pg_insert(workflow_edges_table).values(**values).returning(workflow_edges_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def bulk_create(
|
||||
self,
|
||||
workflow_id: str,
|
||||
graph_version: int,
|
||||
edges: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Insert multiple edges in one statement.
|
||||
|
||||
Each element must have ``edge_id``, ``from_node_id`` (UUID PK),
|
||||
``to_node_id`` (UUID PK). Optional: ``source_handle``,
|
||||
``target_handle``, ``config``.
|
||||
"""
|
||||
if not edges:
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for e in edges:
|
||||
rows.append({
|
||||
"workflow_id": workflow_id,
|
||||
"graph_version": graph_version,
|
||||
"edge_id": e["edge_id"],
|
||||
"from_node_id": e["from_node_id"],
|
||||
"to_node_id": e["to_node_id"],
|
||||
"source_handle": e.get("source_handle"),
|
||||
"target_handle": e.get("target_handle"),
|
||||
"config": e.get("config", {}),
|
||||
})
|
||||
|
||||
# See ``WorkflowNodesRepository.bulk_create`` for the race
|
||||
# rationale — same pattern for edges, keyed on the unique
|
||||
# index ``workflow_edges_wf_ver_eid_uidx``.
|
||||
stmt = pg_insert(workflow_edges_table).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["workflow_id", "graph_version", "edge_id"],
|
||||
set_={
|
||||
"from_node_id": stmt.excluded.from_node_id,
|
||||
"to_node_id": stmt.excluded.to_node_id,
|
||||
"source_handle": stmt.excluded.source_handle,
|
||||
"target_handle": stmt.excluded.target_handle,
|
||||
"config": stmt.excluded.config,
|
||||
},
|
||||
).returning(workflow_edges_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_by_version(
|
||||
self, workflow_id: str, graph_version: int,
|
||||
) -> list[dict]:
|
||||
"""List edges for a workflow/version, shaped to match the live API.
|
||||
|
||||
Joins ``workflow_nodes`` twice so callers receive the user-provided
|
||||
node-id strings (``source_id``/``target_id``) that the Mongo code
|
||||
and the frontend use, not the internal node UUIDs. The raw UUID
|
||||
columns (``from_node_id``/``to_node_id``) are still included in
|
||||
case a caller needs them.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT e.*,
|
||||
fn.node_id AS source_id,
|
||||
tn.node_id AS target_id
|
||||
FROM workflow_edges e
|
||||
JOIN workflow_nodes fn ON fn.id = e.from_node_id
|
||||
JOIN workflow_nodes tn ON tn.id = e.to_node_id
|
||||
WHERE e.workflow_id = CAST(:wf_id AS uuid)
|
||||
AND e.graph_version = :ver
|
||||
ORDER BY e.edge_id
|
||||
"""
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def resolve_node_id(
|
||||
self, workflow_id: str, graph_version: int, node_id: str,
|
||||
) -> Optional[str]:
|
||||
"""Look up the UUID PK of a node by its user-provided ``node_id``.
|
||||
|
||||
Callers that receive edges in the frontend shape (``source_id`` /
|
||||
``target_id`` are user-provided strings) use this helper to
|
||||
translate to the UUID PK before calling :meth:`create` /
|
||||
:meth:`bulk_create`.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT id FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version = :ver AND node_id = :node_id"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version, "node_id": node_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def delete_by_workflow(self, workflow_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_edges "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid)"
|
||||
),
|
||||
{"wf_id": workflow_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_edges "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version = :ver"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_other_versions(self, workflow_id: str, keep_version: int) -> int:
|
||||
"""Delete all edges for a workflow except the specified version."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_edges "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version != :ver"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": keep_version},
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Repository for the ``workflow_nodes`` table.
|
||||
|
||||
Covers bulk insert, find by version, and delete operations that the
|
||||
workflow routes perform on ``workflow_nodes_collection`` in Mongo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.models import workflow_nodes_table
|
||||
|
||||
|
||||
class WorkflowNodesRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
workflow_id: str,
|
||||
graph_version: int,
|
||||
node_id: str,
|
||||
node_type: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
position: dict | None = None,
|
||||
config: dict | None = None,
|
||||
legacy_mongo_id: str | None = None,
|
||||
) -> dict:
|
||||
values: dict = {
|
||||
"workflow_id": workflow_id,
|
||||
"graph_version": graph_version,
|
||||
"node_id": node_id,
|
||||
"node_type": node_type,
|
||||
}
|
||||
if title is not None:
|
||||
values["title"] = title
|
||||
if description is not None:
|
||||
values["description"] = description
|
||||
if position is not None:
|
||||
values["position"] = position
|
||||
if config is not None:
|
||||
values["config"] = config
|
||||
if legacy_mongo_id is not None:
|
||||
values["legacy_mongo_id"] = legacy_mongo_id
|
||||
|
||||
stmt = pg_insert(workflow_nodes_table).values(**values).returning(workflow_nodes_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def bulk_create(
|
||||
self,
|
||||
workflow_id: str,
|
||||
graph_version: int,
|
||||
nodes: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Insert multiple nodes in one statement.
|
||||
|
||||
Each element of ``nodes`` should have at least ``node_id`` and
|
||||
``node_type``; optional keys: ``title``, ``description``,
|
||||
``position``, ``config``.
|
||||
"""
|
||||
if not nodes:
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for n in nodes:
|
||||
rows.append({
|
||||
"workflow_id": workflow_id,
|
||||
"graph_version": graph_version,
|
||||
"node_id": n["node_id"],
|
||||
"node_type": n["node_type"],
|
||||
"title": n.get("title"),
|
||||
"description": n.get("description"),
|
||||
"position": n.get("position", {"x": 0, "y": 0}),
|
||||
"config": n.get("config", {}),
|
||||
"legacy_mongo_id": n.get("legacy_mongo_id"),
|
||||
})
|
||||
|
||||
# Two concurrent ``PUT /workflows/{id}/graph`` calls at the same
|
||||
# ``next_graph_version`` would race here. Without ON CONFLICT the
|
||||
# unique index ``workflow_nodes_wf_ver_nid_uidx`` would reject the
|
||||
# loser outright; we prefer last-writer-wins semantics so that
|
||||
# "overwrite the whole graph at version N" is idempotent and
|
||||
# resilient. The ON CONFLICT target matches the existing unique
|
||||
# index on (workflow_id, graph_version, node_id).
|
||||
stmt = pg_insert(workflow_nodes_table).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["workflow_id", "graph_version", "node_id"],
|
||||
set_={
|
||||
"node_type": stmt.excluded.node_type,
|
||||
"title": stmt.excluded.title,
|
||||
"description": stmt.excluded.description,
|
||||
"position": stmt.excluded.position,
|
||||
"config": stmt.excluded.config,
|
||||
"legacy_mongo_id": stmt.excluded.legacy_mongo_id,
|
||||
},
|
||||
).returning(workflow_nodes_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_by_version(
|
||||
self, workflow_id: str, graph_version: int,
|
||||
) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version = :ver "
|
||||
"ORDER BY node_id"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def find_node(
|
||||
self, workflow_id: str, graph_version: int, node_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""Find a single node by its user-provided ``node_id``."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version = :ver AND node_id = :nid"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version, "nid": node_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str) -> Optional[dict]:
|
||||
"""Find a node by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM workflow_nodes WHERE legacy_mongo_id = :legacy_id"),
|
||||
{"legacy_id": legacy_mongo_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete_by_workflow(self, workflow_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid)"
|
||||
),
|
||||
{"wf_id": workflow_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version = :ver"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": graph_version},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_other_versions(self, workflow_id: str, keep_version: int) -> int:
|
||||
"""Delete all nodes for a workflow except the specified version."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"AND graph_version != :ver"
|
||||
),
|
||||
{"wf_id": workflow_id, "ver": keep_version},
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Repository for the ``workflow_runs`` table.
|
||||
|
||||
In Mongo, workflow_runs_collection only has ``insert_one`` — runs are
|
||||
written once after workflow execution completes and never updated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, func, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import row_to_dict
|
||||
from application.storage.db.models import workflow_runs_table
|
||||
|
||||
|
||||
class WorkflowRunsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
workflow_id: str,
|
||||
user_id: str,
|
||||
status: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
inputs: dict | None = None,
|
||||
result: dict | None = None,
|
||||
steps: list | None = None,
|
||||
started_at=None,
|
||||
ended_at=None,
|
||||
legacy_mongo_id: str | None = None,
|
||||
) -> dict:
|
||||
values: dict = {
|
||||
"workflow_id": workflow_id,
|
||||
"user_id": user_id,
|
||||
"status": status,
|
||||
}
|
||||
# An explicit id lets the engine bind run-scoped artifacts to this row
|
||||
# before the run is persisted, so artifact authz can resolve the parent.
|
||||
if run_id is not None:
|
||||
values["id"] = run_id
|
||||
if inputs is not None:
|
||||
values["inputs"] = inputs
|
||||
if result is not None:
|
||||
values["result"] = result
|
||||
if steps is not None:
|
||||
values["steps"] = steps
|
||||
if started_at is not None:
|
||||
values["started_at"] = started_at
|
||||
if ended_at is not None:
|
||||
values["ended_at"] = ended_at
|
||||
if legacy_mongo_id is not None:
|
||||
values["legacy_mongo_id"] = legacy_mongo_id
|
||||
|
||||
stmt = pg_insert(workflow_runs_table).values(**values).returning(workflow_runs_table)
|
||||
res = self._conn.execute(stmt)
|
||||
return row_to_dict(res.fetchone())
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
run_id: str,
|
||||
user_id: str,
|
||||
status: str,
|
||||
*,
|
||||
result: dict | None = None,
|
||||
steps: list | None = None,
|
||||
ended_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""Update a pre-created run row with its terminal status/result; owner-scoped."""
|
||||
values: dict = {"status": status}
|
||||
if result is not None:
|
||||
values["result"] = result
|
||||
if steps is not None:
|
||||
values["steps"] = steps
|
||||
if ended_at is not None:
|
||||
values["ended_at"] = ended_at
|
||||
stmt = (
|
||||
workflow_runs_table.update()
|
||||
.where(workflow_runs_table.c.id == run_id)
|
||||
.where(workflow_runs_table.c.user_id == user_id)
|
||||
.values(**values)
|
||||
)
|
||||
res = self._conn.execute(stmt)
|
||||
return res.rowcount > 0
|
||||
|
||||
def mark_stale_running_failed(self, older_than: datetime) -> int:
|
||||
"""Fail runs left in ``running`` (no ``ended_at``) since before ``older_than``.
|
||||
|
||||
The run row is pre-created as ``running`` and finalized when its generator
|
||||
finishes; a client disconnect or a worker crash can strand it in ``running``
|
||||
forever, since nothing else finalizes it. This closes those rows out so they
|
||||
don't linger. Returns the number of rows updated.
|
||||
"""
|
||||
stmt = (
|
||||
workflow_runs_table.update()
|
||||
.where(workflow_runs_table.c.status == "running")
|
||||
.where(workflow_runs_table.c.ended_at.is_(None))
|
||||
.where(workflow_runs_table.c.started_at < older_than)
|
||||
.values(
|
||||
status="failed",
|
||||
ended_at=func.now(),
|
||||
# failed_reason marks the reap so readers know ended_at is the
|
||||
# reap time, not when the run actually died (duration is bogus).
|
||||
result={
|
||||
"error": "Run did not complete (timed out or the client disconnected).",
|
||||
"failed_reason": "stale_reaper",
|
||||
},
|
||||
)
|
||||
)
|
||||
res = self._conn.execute(stmt)
|
||||
return res.rowcount
|
||||
|
||||
def get(self, run_id: str) -> Optional[dict]:
|
||||
res = self._conn.execute(
|
||||
text("SELECT * FROM workflow_runs WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": run_id},
|
||||
)
|
||||
row = res.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(self, legacy_mongo_id: str) -> Optional[dict]:
|
||||
"""Fetch a workflow run by the original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
res = self._conn.execute(
|
||||
text("SELECT * FROM workflow_runs WHERE legacy_mongo_id = :legacy_id"),
|
||||
{"legacy_id": legacy_mongo_id},
|
||||
)
|
||||
row = res.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_workflow(self, workflow_id: str) -> list[dict]:
|
||||
res = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM workflow_runs "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid) "
|
||||
"ORDER BY started_at DESC"
|
||||
),
|
||||
{"wf_id": workflow_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in res.fetchall()]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Repository for the ``workflows`` table.
|
||||
|
||||
Covers CRUD on workflow metadata:
|
||||
|
||||
- create / get / list / update / delete
|
||||
- graph version management
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
from application.storage.db.models import workflows_table
|
||||
|
||||
|
||||
class WorkflowsRepository:
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
legacy_mongo_id: str | None = None,
|
||||
) -> dict:
|
||||
values: dict = {"user_id": user_id, "name": name}
|
||||
if description is not None:
|
||||
values["description"] = description
|
||||
if legacy_mongo_id is not None:
|
||||
values["legacy_mongo_id"] = legacy_mongo_id
|
||||
|
||||
stmt = pg_insert(workflows_table).values(**values).returning(workflows_table)
|
||||
result = self._conn.execute(stmt)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
def get(self, workflow_id: str, user_id: str) -> Optional[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM workflows "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": workflow_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_id(self, workflow_id: str) -> Optional[dict]:
|
||||
"""Fetch a workflow by ID without user check (for internal use)."""
|
||||
result = self._conn.execute(
|
||||
text("SELECT * FROM workflows WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": workflow_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def get_by_legacy_id(
|
||||
self, legacy_mongo_id: str, user_id: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Fetch a workflow by its original Mongo ObjectId string."""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
sql = "SELECT * FROM workflows WHERE legacy_mongo_id = :legacy_id"
|
||||
params: dict[str, str] = {"legacy_id": legacy_mongo_id}
|
||||
if user_id is not None:
|
||||
sql += " AND user_id = :user_id"
|
||||
params["user_id"] = user_id
|
||||
result = self._conn.execute(text(sql), params)
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def list_for_user(self, user_id: str) -> list[dict]:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT * FROM workflows "
|
||||
"WHERE user_id = :user_id ORDER BY created_at DESC"
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
|
||||
allowed = {"name", "description", "current_graph_version"}
|
||||
filtered = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
set_parts = [f"{col} = :{col}" for col in filtered]
|
||||
set_parts.append("updated_at = now()")
|
||||
params = {**filtered, "id": workflow_id, "user_id": user_id}
|
||||
|
||||
sql = (
|
||||
f"UPDATE workflows SET {', '.join(set_parts)} "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
)
|
||||
result = self._conn.execute(text(sql), params)
|
||||
return result.rowcount > 0
|
||||
|
||||
def increment_graph_version(self, workflow_id: str, user_id: str) -> Optional[int]:
|
||||
"""Atomically increment ``current_graph_version`` and return the new value."""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"UPDATE workflows "
|
||||
"SET current_graph_version = current_graph_version + 1, "
|
||||
" updated_at = now() "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id "
|
||||
"RETURNING current_graph_version"
|
||||
),
|
||||
{"id": workflow_id, "user_id": user_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def delete(self, workflow_id: str, user_id: str) -> bool:
|
||||
# Run artifacts parent to ``workflow_runs`` (which cascade-delete with the
|
||||
# workflow), but ``artifacts.workflow_run_id`` is a bare uuid with no FK, so
|
||||
# the rows + bytes + the quota they consume would leak. Reclaim them BEFORE
|
||||
# deleting the workflow, while the run rows still resolve the subquery.
|
||||
# Ownership is confirmed first so this never reaps another user's artifacts.
|
||||
from application.storage.db.repositories.artifacts import ArtifactsRepository
|
||||
|
||||
if not looks_like_uuid(workflow_id):
|
||||
return False
|
||||
owned = (
|
||||
self._conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM workflows "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": workflow_id, "user_id": user_id},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
if not owned:
|
||||
return False
|
||||
|
||||
artifacts = ArtifactsRepository(self._conn)
|
||||
paths = artifacts.storage_paths_for_workflow(workflow_id)
|
||||
artifacts.delete_for_workflow(workflow_id)
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflows "
|
||||
"WHERE id = CAST(:id AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"id": workflow_id, "user_id": user_id},
|
||||
)
|
||||
deleted = result.rowcount > 0
|
||||
if deleted:
|
||||
artifacts.reap_storage(paths)
|
||||
return deleted
|
||||
|
||||
def delete_by_legacy_id(self, legacy_mongo_id: str, user_id: str) -> bool:
|
||||
"""Delete a workflow addressed by the Mongo ObjectId string.
|
||||
|
||||
The ``workflow_nodes`` and ``workflow_edges`` rows are removed
|
||||
automatically via ``ON DELETE CASCADE``.
|
||||
"""
|
||||
legacy_mongo_id = str(legacy_mongo_id) if legacy_mongo_id is not None else None
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflows "
|
||||
"WHERE legacy_mongo_id = :legacy_id AND user_id = :user_id"
|
||||
),
|
||||
{"legacy_id": legacy_mongo_id, "user_id": user_id},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,93 @@
|
||||
"""JSON-safe coercion for PG-native Python types.
|
||||
|
||||
Postgres (via psycopg) returns native Python types — ``uuid.UUID``,
|
||||
``datetime.datetime``/``datetime.date``, ``decimal.Decimal``, ``bytes``
|
||||
— that ``json.dumps`` rejects. This module is the single place those
|
||||
coercion rules live; everywhere else should call into it.
|
||||
|
||||
Two interfaces with identical coverage:
|
||||
|
||||
* :func:`coerce_pg_native` — recursive walk returning a JSON-safe copy.
|
||||
Use when you need to inspect the dict yourself or pass it to a
|
||||
serializer that doesn't accept a custom encoder (e.g. SQLAlchemy
|
||||
parameter binding for a JSONB column).
|
||||
* :class:`PGNativeJSONEncoder` — ``JSONEncoder`` subclass. Use as
|
||||
``json.dumps(obj, cls=PGNativeJSONEncoder)`` for serialise-once flows
|
||||
where the extra recursive walk is wasted work.
|
||||
|
||||
Coercion rules:
|
||||
|
||||
* ``UUID`` → canonical hex string.
|
||||
* ``datetime`` / ``date`` → ISO 8601 string.
|
||||
* ``Decimal`` → numeric string (preserves precision; ``float()`` would not).
|
||||
* ``bytes`` → base64 string. Lossless and universally JSON-safe;
|
||||
prior code used UTF-8 with ``errors="replace"`` which silently
|
||||
corrupted binary payloads (e.g. Gemini's ``thought_signature``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _coerce_scalar(obj: Any) -> Any:
|
||||
if isinstance(obj, UUID):
|
||||
return str(obj)
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return str(obj)
|
||||
if isinstance(obj, bytes):
|
||||
return base64.b64encode(obj).decode("ascii")
|
||||
return obj
|
||||
|
||||
|
||||
def coerce_pg_native(obj: Any) -> Any:
|
||||
"""Recursively coerce PG-native types to JSON-safe equivalents.
|
||||
|
||||
Recurses into ``dict`` (stringifying keys, matching prior helper
|
||||
behavior) and ``list``/``tuple`` (tuples flatten to lists since JSON
|
||||
has no tuple type). Any other type passes through unchanged.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): coerce_pg_native(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [coerce_pg_native(v) for v in obj]
|
||||
return _coerce_scalar(obj)
|
||||
|
||||
|
||||
def decode_base64_bytes(value: Any) -> Any:
|
||||
"""Reverse ``coerce_pg_native``'s bytes-to-base64 step.
|
||||
|
||||
Useful at egress points that need the original bytes back (e.g.
|
||||
sending Gemini's ``thought_signature`` to the SDK on resume). Uses
|
||||
``validate=True`` so plain ASCII strings that happen to be
|
||||
permissively decodable (e.g. ``"abcd"``) are not silently turned
|
||||
into bytes — the original value passes through.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return base64.b64decode(value.encode("ascii"), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
class PGNativeJSONEncoder(json.JSONEncoder):
|
||||
"""``JSONEncoder`` covering UUID / datetime / date / Decimal / bytes.
|
||||
|
||||
Use as ``json.dumps(obj, cls=PGNativeJSONEncoder)``. Equivalent in
|
||||
coverage to :func:`coerce_pg_native` but skips the eager walk.
|
||||
"""
|
||||
|
||||
def default(self, obj: Any) -> Any:
|
||||
coerced = _coerce_scalar(obj)
|
||||
if coerced is obj:
|
||||
return super().default(obj)
|
||||
return coerced
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Per-request connection helpers for route handlers.
|
||||
|
||||
Every route-handler that talks to Postgres opens a short-lived, explicit
|
||||
transaction via the context managers in this module. The pattern is::
|
||||
|
||||
from application.storage.db.session import db_session
|
||||
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get(prompt_id, user_id)
|
||||
|
||||
Why explicit, not ``flask.g``: the lifecycle stays local to each handler,
|
||||
which mirrors how the repository test fixtures already work and keeps
|
||||
error handling obvious. Celery tasks and the seeder use the same helper
|
||||
so there's one pattern to learn.
|
||||
|
||||
Two flavors:
|
||||
|
||||
* ``db_session()`` — opens a transaction (``engine.begin()``). Commits on
|
||||
clean exit, rolls back on exception. Use for any handler that may
|
||||
write.
|
||||
* ``db_readonly()`` — opens a plain connection (``engine.connect()``) for
|
||||
read-only paths. Avoids the commit round-trip on pure reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from application.storage.db.engine import get_engine
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_session() -> Iterator[Connection]:
|
||||
"""Transactional connection. Commits on success, rolls back on error."""
|
||||
with get_engine().begin() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_readonly() -> Iterator[Connection]:
|
||||
"""Read-only connection for handlers that never write.
|
||||
|
||||
The connection is placed into a Postgres ``READ ONLY`` transaction
|
||||
before any caller statement runs, so an accidental ``INSERT`` /
|
||||
``UPDATE`` / ``DELETE`` from inside the block raises
|
||||
``InternalError: cannot execute ... in a read-only transaction``
|
||||
instead of silently mutating data.
|
||||
|
||||
The transaction itself is rolled back on exit — a read-only
|
||||
transaction has nothing meaningful to commit, and rolling back avoids
|
||||
leaving the connection in an open-transaction state when it returns
|
||||
to the pool.
|
||||
"""
|
||||
with get_engine().connect() as conn:
|
||||
trans = conn.begin()
|
||||
try:
|
||||
# Must be the first statement in the txn; psycopg3 + SA both
|
||||
# honor this and Postgres rejects writes for the rest of the
|
||||
# transaction's lifetime.
|
||||
conn.execute(text("SET TRANSACTION READ ONLY"))
|
||||
yield conn
|
||||
finally:
|
||||
trans.rollback()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Pydantic models for the ``sources.config`` per-source behavior contract.
|
||||
|
||||
Validation policy (D7): strict on write (``model_validate`` raises on bad
|
||||
input), lenient on read (``SourceConfig.parse`` falls back to all-defaults for
|
||||
``{}``/``None`` and tolerates partial/legacy dicts so a malformed row never
|
||||
crashes ingest or retrieval).
|
||||
|
||||
The defaults mirror the ingest pipeline's current behavior: ``max_tokens`` /
|
||||
``min_tokens`` match ``application/worker.py`` (1250 / 150), so an empty config
|
||||
reproduces today's chunking byte-for-byte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
|
||||
class PreScreenConfig(BaseModel):
|
||||
"""Map-reduce candidate-filter config (D12); off unless set.
|
||||
|
||||
A base retriever fetches ``candidate_k`` candidates, an LLM screens them in
|
||||
batches of ``batch_size``, and at most ``max_keep`` survivors pass to the
|
||||
answer. ``model`` is optional; when None the stage reuses the request's
|
||||
resolved model. This is a query-time LLM cost, so it stays opt-in.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
candidate_k: int = 40 # candidates to fetch before screening
|
||||
model: Optional[str] = None # None → reuse the resolved request model
|
||||
batch_size: int = 10 # candidates per LLM screening call
|
||||
max_keep: int = 8 # survivors kept after screening
|
||||
|
||||
@field_validator("candidate_k", "batch_size", "max_keep")
|
||||
@classmethod
|
||||
def _positive(cls, value: int) -> int:
|
||||
if value < 1:
|
||||
raise ValueError("must be >= 1")
|
||||
if value > 500:
|
||||
raise ValueError("must be <= 500")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _coherent(self) -> "PreScreenConfig":
|
||||
if self.max_keep > self.candidate_k:
|
||||
raise ValueError("max_keep must be <= candidate_k")
|
||||
return self
|
||||
|
||||
|
||||
class GraphConfig(BaseModel):
|
||||
"""Ingest-time GraphRAG extraction knobs (pgvector-only).
|
||||
|
||||
``extraction_model`` None reuses the instance default model
|
||||
(``LLM_PROVIDER``/``LLM_NAME``); ``max_chunks`` None falls back to the
|
||||
``GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION`` setting. ``gleanings`` is off by
|
||||
default (cost control).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
extraction_model: Optional[str] = None # None → instance default model
|
||||
max_chunks: Optional[int] = None # None → GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION
|
||||
gleanings: int = 0 # extra extraction passes per chunk
|
||||
|
||||
@field_validator("max_chunks")
|
||||
@classmethod
|
||||
def _positive_max_chunks(cls, value: Optional[int]) -> Optional[int]:
|
||||
if value is not None and value < 1:
|
||||
raise ValueError("must be >= 1")
|
||||
return value
|
||||
|
||||
@field_validator("gleanings")
|
||||
@classmethod
|
||||
def _non_negative_gleanings(cls, value: int) -> int:
|
||||
if value < 0:
|
||||
raise ValueError("must be >= 0")
|
||||
return value
|
||||
|
||||
|
||||
class ChunkingConfig(BaseModel):
|
||||
"""Ingest-time chunking knobs (bake-time; change requires re-ingest)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
strategy: str = "classic_chunk" # ChunkerCreator key
|
||||
max_tokens: int = 1250 # matches application/worker.py MAX_TOKENS
|
||||
min_tokens: int = 150 # matches application/worker.py MIN_TOKENS
|
||||
duplicate_headers: bool = False
|
||||
|
||||
|
||||
class RetrievalConfig(BaseModel):
|
||||
"""Query-time retrieval knobs (live; no re-ingest needed)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
retriever: str = "classic" # RetrieverCreator key
|
||||
exposure: str = "prefetch" # prefetch | agentic_tool (D11)
|
||||
chunks: int = 2 # final top-k
|
||||
score_threshold: Optional[float] = None # pgvector/mongo honor it; others ignore
|
||||
rephrase_query: bool = True # toggle ClassicRAG._rephrase_query side-call
|
||||
reranker: Optional[dict] = None # reserved: future cross-encoder/LLM reorder
|
||||
prescreen: Optional[dict] = None # None = off; else PreScreenConfig dict (D12)
|
||||
|
||||
@field_validator("chunks")
|
||||
@classmethod
|
||||
def _bounded_chunks(cls, value: int) -> int:
|
||||
if value < 1:
|
||||
raise ValueError("must be >= 1")
|
||||
if value > 500:
|
||||
raise ValueError("must be <= 500")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_prescreen(self) -> "RetrievalConfig":
|
||||
"""Validate ``prescreen`` through ``PreScreenConfig`` when present.
|
||||
|
||||
Kept as a dict on the model for lenient storage, but parsed strictly
|
||||
here so a bad object is rejected on the API write path; cross-checks
|
||||
``candidate_k >= chunks`` so the final top-k can always be satisfied.
|
||||
"""
|
||||
if self.prescreen is not None:
|
||||
ps = PreScreenConfig.model_validate(self.prescreen)
|
||||
if ps.candidate_k < self.chunks:
|
||||
raise ValueError("prescreen.candidate_k must be >= chunks")
|
||||
# Normalise to the validated dict (drops any extras / fills defaults).
|
||||
self.prescreen = ps.model_dump()
|
||||
return self
|
||||
|
||||
def prescreen_config(self) -> Optional[PreScreenConfig]:
|
||||
"""Return the parsed ``PreScreenConfig`` or None (lenient read)."""
|
||||
if not self.prescreen:
|
||||
return None
|
||||
try:
|
||||
return PreScreenConfig.model_validate(self.prescreen)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class SourceConfig(BaseModel):
|
||||
"""Per-source behavior contract stored in ``sources.config``."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: str = "classic" # behavior selector: classic | wiki | graphrag | ...
|
||||
chunking: ChunkingConfig = ChunkingConfig()
|
||||
retrieval: RetrievalConfig = RetrievalConfig()
|
||||
graph: GraphConfig = GraphConfig()
|
||||
|
||||
def wiki_enabled(self) -> dict:
|
||||
"""Return a config dict flipped to wiki mode + browse-as-you-go exposure.
|
||||
|
||||
Sets ``kind="wiki"`` and defaults ``retrieval.exposure`` to
|
||||
``agentic_tool`` (a wiki is navigated, not bulk-prefetched), preserving
|
||||
any non-default exposure the source already carries.
|
||||
"""
|
||||
new_config = self.model_dump()
|
||||
new_config["kind"] = "wiki"
|
||||
if self.retrieval.exposure == "prefetch":
|
||||
new_config["retrieval"]["exposure"] = "agentic_tool"
|
||||
return new_config
|
||||
|
||||
def graph_enabled(self) -> dict:
|
||||
"""Return a config dict flipped to GraphRAG mode.
|
||||
|
||||
Sets ``kind="graphrag"`` so ingest paths run graph extraction and
|
||||
``retrieval.retriever="graphrag"`` so the Dispatcher routes queries to
|
||||
the graph retriever. All other fields are preserved.
|
||||
"""
|
||||
new_config = self.model_dump()
|
||||
new_config["kind"] = "graphrag"
|
||||
new_config["retrieval"]["retriever"] = "graphrag"
|
||||
return new_config
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Optional[dict]) -> "SourceConfig":
|
||||
"""Lenient read: return all-defaults for ``{}``/``None``.
|
||||
|
||||
Falls back to classic defaults when ``raw`` is empty or cannot be
|
||||
validated, so legacy/bad rows never break the read path (D7).
|
||||
Partial dicts are merged onto the defaults.
|
||||
|
||||
Args:
|
||||
raw: The stored ``sources.config`` value (or ``None``).
|
||||
|
||||
Returns:
|
||||
A fully populated ``SourceConfig``.
|
||||
"""
|
||||
if not raw:
|
||||
return cls()
|
||||
if not isinstance(raw, dict):
|
||||
return cls()
|
||||
try:
|
||||
return cls.model_validate(raw)
|
||||
except Exception:
|
||||
return cls()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Deterministic source-id derivation for idempotent ingest.
|
||||
|
||||
DO NOT CHANGE the pinned UUID namespace — it backs cross-deploy
|
||||
idempotency keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
# DO NOT CHANGE. See module docstring.
|
||||
DOCSGPT_INGEST_NAMESPACE = uuid.UUID("fa25d5d1-398b-46df-ac89-8d1c360b9bea")
|
||||
|
||||
|
||||
def derive_source_id(idempotency_key) -> uuid.UUID:
|
||||
"""``uuid5(NS, key)`` when a key is supplied; ``uuid4()`` otherwise.
|
||||
|
||||
A non-string / empty key falls back to ``uuid4()`` so the caller
|
||||
always gets a fresh id rather than a TypeError mid-route.
|
||||
"""
|
||||
if isinstance(idempotency_key, str) and idempotency_key:
|
||||
return uuid.uuid5(DOCSGPT_INGEST_NAMESPACE, idempotency_key)
|
||||
return uuid.uuid4()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Local file system implementation."""
|
||||
import os
|
||||
import shutil
|
||||
from typing import BinaryIO, List, Callable
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
|
||||
|
||||
class LocalStorage(BaseStorage):
|
||||
"""Local file system storage implementation."""
|
||||
|
||||
def __init__(self, base_dir: str = None):
|
||||
"""
|
||||
Initialize local storage.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for all operations. If None, uses current directory.
|
||||
"""
|
||||
self.base_dir = base_dir or os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
def _get_full_path(self, path: str) -> str:
|
||||
"""Get absolute path by combining base_dir and path.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved path escapes base_dir (path traversal).
|
||||
"""
|
||||
if os.path.isabs(path):
|
||||
resolved = os.path.realpath(path)
|
||||
else:
|
||||
resolved = os.path.realpath(os.path.join(self.base_dir, path))
|
||||
base = os.path.realpath(self.base_dir)
|
||||
if not resolved.startswith(base + os.sep) and resolved != base:
|
||||
raise ValueError(f"Path traversal detected: {path}")
|
||||
return resolved
|
||||
|
||||
def save_file(self, file_data: BinaryIO, path: str, **kwargs) -> dict:
|
||||
"""Save a file to local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
if hasattr(file_data, 'save'):
|
||||
file_data.save(full_path)
|
||||
else:
|
||||
with open(full_path, 'wb') as f:
|
||||
shutil.copyfileobj(file_data, f)
|
||||
|
||||
return {
|
||||
'storage_type': 'local'
|
||||
}
|
||||
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""Get a file from local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"File not found: {full_path}")
|
||||
|
||||
return open(full_path, 'rb')
|
||||
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""Delete a file from local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return False
|
||||
|
||||
os.remove(full_path)
|
||||
return True
|
||||
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
return os.path.exists(full_path)
|
||||
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""List all files in a directory in local storage."""
|
||||
full_path = self._get_full_path(directory)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for root, _, files in os.walk(full_path):
|
||||
for file in files:
|
||||
rel_path = os.path.relpath(os.path.join(root, file), self.base_dir)
|
||||
result.append(rel_path)
|
||||
|
||||
return result
|
||||
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
For local storage, we can directly pass the full path to the processor.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"File not found: {full_path}")
|
||||
|
||||
return processor_func(local_path=full_path, **kwargs)
|
||||
|
||||
def is_directory(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a path is a directory in local storage.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
bool: True if the path is a directory, False otherwise
|
||||
"""
|
||||
full_path = self._get_full_path(path)
|
||||
return os.path.isdir(full_path)
|
||||
|
||||
def remove_directory(self, directory: str) -> bool:
|
||||
"""
|
||||
Remove a directory and all its contents from local storage.
|
||||
|
||||
Args:
|
||||
directory: Directory path to remove
|
||||
|
||||
Returns:
|
||||
bool: True if removal was successful, False otherwise
|
||||
"""
|
||||
full_path = self._get_full_path(directory)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return False
|
||||
|
||||
if not os.path.isdir(full_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
shutil.rmtree(full_path)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
@@ -0,0 +1,272 @@
|
||||
"""S3 storage implementation."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
from typing import BinaryIO, Callable, List, Optional, Tuple
|
||||
|
||||
import boto3
|
||||
from application.core.settings import settings
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class S3Storage(BaseStorage):
|
||||
"""S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, etc.)."""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_credentials() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve S3 credentials, falling back to deprecated SAGEMAKER_* vars.
|
||||
|
||||
Returns:
|
||||
Tuple of (access_key_id, secret_access_key, region).
|
||||
"""
|
||||
access_key = settings.S3_ACCESS_KEY_ID
|
||||
secret_key = settings.S3_SECRET_ACCESS_KEY
|
||||
region = settings.S3_REGION
|
||||
|
||||
legacy_access = getattr(settings, "SAGEMAKER_ACCESS_KEY", None)
|
||||
legacy_secret = getattr(settings, "SAGEMAKER_SECRET_KEY", None)
|
||||
legacy_region = getattr(settings, "SAGEMAKER_REGION", None)
|
||||
|
||||
used_legacy = (
|
||||
(not access_key and legacy_access)
|
||||
or (not secret_key and legacy_secret)
|
||||
or (not region and legacy_region)
|
||||
)
|
||||
if used_legacy:
|
||||
logger.warning(
|
||||
"Using SAGEMAKER_* credentials for S3 storage is deprecated; "
|
||||
"set S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_REGION instead."
|
||||
)
|
||||
|
||||
return (
|
||||
access_key or legacy_access,
|
||||
secret_key or legacy_secret,
|
||||
region or legacy_region,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_path(path: str) -> str:
|
||||
"""Validate and normalize an S3 key to prevent path traversal.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path contains traversal sequences or is absolute.
|
||||
"""
|
||||
if "\x00" in path:
|
||||
raise ValueError(f"Null byte in path: {path}")
|
||||
normalized = posixpath.normpath(path)
|
||||
if normalized.startswith("/") or normalized.startswith(".."):
|
||||
raise ValueError(f"Path traversal detected: {path}")
|
||||
return normalized
|
||||
|
||||
def __init__(self, bucket_name=None):
|
||||
"""
|
||||
Initialize S3 storage.
|
||||
|
||||
Args:
|
||||
bucket_name: S3 bucket name (optional, defaults to settings)
|
||||
"""
|
||||
self.bucket_name = bucket_name or settings.S3_BUCKET_NAME
|
||||
|
||||
aws_access_key_id, aws_secret_access_key, region_name = self._resolve_credentials()
|
||||
self.region = region_name
|
||||
|
||||
client_kwargs = {
|
||||
"aws_access_key_id": aws_access_key_id,
|
||||
"aws_secret_access_key": aws_secret_access_key,
|
||||
"region_name": region_name,
|
||||
}
|
||||
# Custom endpoint for S3-compatible services (MinIO, R2, B2, Spaces, ...).
|
||||
if settings.S3_ENDPOINT_URL:
|
||||
client_kwargs["endpoint_url"] = settings.S3_ENDPOINT_URL
|
||||
# Most non-AWS services require path-style addressing.
|
||||
if settings.S3_PATH_STYLE:
|
||||
client_kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||
|
||||
self.s3 = boto3.client("s3", **client_kwargs)
|
||||
|
||||
def save_file(
|
||||
self,
|
||||
file_data: BinaryIO,
|
||||
path: str,
|
||||
storage_class: str = "INTELLIGENT_TIERING",
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Save a file to S3 storage."""
|
||||
path = self._validate_path(path)
|
||||
self.s3.upload_fileobj(
|
||||
file_data, self.bucket_name, path, ExtraArgs={"StorageClass": storage_class}
|
||||
)
|
||||
|
||||
return {
|
||||
"storage_type": "s3",
|
||||
"bucket_name": self.bucket_name,
|
||||
"uri": f"s3://{self.bucket_name}/{path}",
|
||||
"region": self.region,
|
||||
}
|
||||
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""Get a file from S3 storage."""
|
||||
path = self._validate_path(path)
|
||||
if not self.file_exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
file_obj = io.BytesIO()
|
||||
self.s3.download_fileobj(self.bucket_name, path, file_obj)
|
||||
file_obj.seek(0)
|
||||
return file_obj
|
||||
|
||||
def generate_presigned_url(self, path: str, expires_in: int = 300) -> str:
|
||||
"""Return a short-lived presigned GET URL for a private object (TTL <= 1h)."""
|
||||
path = self._validate_path(path)
|
||||
expires_in = min(expires_in, 3600)
|
||||
return self.s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self.bucket_name, "Key": path},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""Delete a file from S3 storage."""
|
||||
path = self._validate_path(path)
|
||||
try:
|
||||
self.s3.delete_object(Bucket=self.bucket_name, Key=path)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in S3 storage."""
|
||||
path = self._validate_path(path)
|
||||
try:
|
||||
self.s3.head_object(Bucket=self.bucket_name, Key=path)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""List all files in a directory in S3 storage."""
|
||||
# Ensure directory ends with a slash if it's not empty
|
||||
|
||||
if directory and not directory.endswith("/"):
|
||||
directory += "/"
|
||||
result = []
|
||||
paginator = self.s3.get_paginator("list_objects_v2")
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory)
|
||||
|
||||
for page in pages:
|
||||
if "Contents" in page:
|
||||
for obj in page["Contents"]:
|
||||
result.append(obj["Key"])
|
||||
return result
|
||||
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
path = self._validate_path(path)
|
||||
if not self.file_exists(path):
|
||||
raise FileNotFoundError(f"File not found in S3: {path}")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=os.path.splitext(path)[1], delete=True
|
||||
) as temp_file:
|
||||
try:
|
||||
# Download the file from S3 to the temporary file
|
||||
|
||||
self.s3.download_fileobj(self.bucket_name, path, temp_file)
|
||||
temp_file.flush()
|
||||
|
||||
return processor_func(local_path=temp_file.name, **kwargs)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing S3 file {path}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def is_directory(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a path is a directory in S3 storage.
|
||||
|
||||
In S3, directories are virtual concepts. A path is considered a directory
|
||||
if there are objects with the path as a prefix.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
bool: True if the path is a directory, False otherwise
|
||||
"""
|
||||
# Ensure path ends with a slash if not empty
|
||||
if path and not path.endswith('/'):
|
||||
path += '/'
|
||||
|
||||
response = self.s3.list_objects_v2(
|
||||
Bucket=self.bucket_name,
|
||||
Prefix=path,
|
||||
MaxKeys=1
|
||||
)
|
||||
|
||||
return 'Contents' in response
|
||||
|
||||
def remove_directory(self, directory: str) -> bool:
|
||||
"""
|
||||
Remove a directory and all its contents from S3 storage.
|
||||
|
||||
In S3, this removes all objects with the directory path as a prefix.
|
||||
Since S3 doesn't have actual directories, this effectively removes
|
||||
all files within the virtual directory structure.
|
||||
|
||||
Args:
|
||||
directory: Directory path to remove
|
||||
|
||||
Returns:
|
||||
bool: True if removal was successful, False otherwise
|
||||
"""
|
||||
# Ensure directory ends with a slash if not empty
|
||||
if directory and not directory.endswith('/'):
|
||||
directory += '/'
|
||||
|
||||
try:
|
||||
# Get all objects with the directory prefix
|
||||
objects_to_delete = []
|
||||
paginator = self.s3.get_paginator('list_objects_v2')
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory)
|
||||
|
||||
for page in pages:
|
||||
if 'Contents' in page:
|
||||
for obj in page['Contents']:
|
||||
objects_to_delete.append({'Key': obj['Key']})
|
||||
|
||||
if not objects_to_delete:
|
||||
return False
|
||||
|
||||
batch_size = 1000
|
||||
for i in range(0, len(objects_to_delete), batch_size):
|
||||
batch = objects_to_delete[i:i + batch_size]
|
||||
|
||||
response = self.s3.delete_objects(
|
||||
Bucket=self.bucket_name,
|
||||
Delete={'Objects': batch}
|
||||
)
|
||||
|
||||
if 'Errors' in response and response['Errors']:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except ClientError:
|
||||
return False
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Storage factory for creating different storage implementations."""
|
||||
from typing import Dict, Type
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
from application.storage.local import LocalStorage
|
||||
from application.storage.s3 import S3Storage
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
class StorageCreator:
|
||||
storages: Dict[str, Type[BaseStorage]] = {
|
||||
"local": LocalStorage,
|
||||
"s3": S3Storage,
|
||||
}
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_storage(cls) -> BaseStorage:
|
||||
if cls._instance is None:
|
||||
storage_type = getattr(settings, "STORAGE_TYPE", "local")
|
||||
cls._instance = cls.create_storage(storage_type)
|
||||
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def create_storage(cls, type_name: str, *args, **kwargs) -> BaseStorage:
|
||||
storage_class = cls.storages.get(type_name.lower())
|
||||
if not storage_class:
|
||||
raise ValueError(f"No storage implementation found for type {type_name}")
|
||||
|
||||
return storage_class(*args, **kwargs)
|
||||
Reference in New Issue
Block a user