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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
View File
+141
View File
@@ -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
+10
View File
@@ -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.
"""
+67
View File
@@ -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
+320
View File
@@ -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.")
+98
View File
@@ -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
+999
View File
@@ -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"),
)
+64
View File
@@ -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
+93
View File
@@ -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
+67
View File
@@ -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()
+197
View File
@@ -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()
+23
View File
@@ -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()
+149
View File
@@ -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
+272
View File
@@ -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
+32
View File
@@ -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)