Files
turnstone/turnstone/core/storage/_postgresql.py
T

7071 lines
274 KiB
Python

"""PostgreSQL storage backend."""
from __future__ import annotations
import contextlib
import json
import os
import threading
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from turnstone.core.storage._notify import Notify, NotifyStream
from turnstone.core.trajectory import Turn
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from turnstone.core.log import get_logger
from turnstone.core.storage._protocol import (
FORK_RESERVATION_CONFIG_KEY,
USER_SCOPED_AUTH_TYPES,
AttachmentWrite,
ConversationCommitWorkstreamGoneError,
ForkCloneExpectation,
ForkCloneSnapshot,
MCPOAuthPendingState,
MCPPendingConsentRow,
MCPUserToken,
MCPUserTokenMetadataRow,
OIDCIdentity,
OIDCPendingState,
OIDCUserCredential,
)
from turnstone.core.storage._schema import (
api_tokens,
audit_events,
channel_routes,
channel_users,
conversations,
heuristic_rules,
intent_verdicts,
mcp_oauth_pending,
mcp_pending_consent,
mcp_servers,
mcp_user_tokens,
metadata,
model_definitions,
oidc_identities,
oidc_pending_states,
oidc_user_credentials,
orgs,
output_assessments,
output_guard_patterns,
personas,
project_members,
projects,
prompt_templates,
role_permission_overrides,
roles,
scheduled_task_runs,
scheduled_tasks,
services,
skill_resources,
skill_versions,
structured_memories,
system_settings,
tls_account_keys,
tls_ca,
tls_certificates,
tool_policies,
usage_events,
user_roles,
users,
watches,
workstream_attachments,
workstream_config,
workstream_overrides,
workstreams,
)
from turnstone.core.storage._schema import (
prompt_policies as prompt_policies_t,
)
from turnstone.core.storage._utils import (
CAPS_COMPARE_UNSET as _CAPS_COMPARE_UNSET,
)
from turnstone.core.storage._utils import (
COMPACTION_SOURCE as _COMPACTION_SOURCE,
)
from turnstone.core.storage._utils import (
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
)
from turnstone.core.storage._utils import (
HISTORY_CONTEXT_EXCLUSION_SQL as _HISTORY_EXCL_SQL,
)
from turnstone.core.storage._utils import (
HISTORY_CREATING_EXCLUSION_SQL as _HISTORY_CREATING_EXCL_SQL,
)
from turnstone.core.storage._utils import (
HISTORY_VISIBILITY_SCOPE_SQL as _HISTORY_SCOPE_SQL,
)
from turnstone.core.storage._utils import (
LIKE_ESCAPE as _LIKE_ESCAPE,
)
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
from turnstone.core.storage._utils import (
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
)
from turnstone.core.storage._utils import (
ORG_MUTABLE as _ORG_MUTABLE,
)
from turnstone.core.storage._utils import (
OUTPUT_GUARD_PATTERN_MUTABLE as _OGP_MUTABLE,
)
from turnstone.core.storage._utils import (
PERSONA_MUTABLE as _PERSONA_MUTABLE,
)
from turnstone.core.storage._utils import (
POLICY_MUTABLE as _POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
PROJECT_MUTABLE as _PROJECT_MUTABLE,
)
from turnstone.core.storage._utils import (
PROMPT_POLICY_MUTABLE as _PROMPT_POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
from turnstone.core.storage._utils import (
SKILL_MUTABLE as _SKILL_MUTABLE,
)
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
KeyedAttachmentSaveWrappers as _KeyedAttachmentSaveWrappers,
)
from turnstone.core.storage._utils import (
assert_single_default_persona as _assert_single_default_persona,
)
from turnstone.core.storage._utils import (
build_attachments_by_msg as _build_attachments_by_msg,
)
from turnstone.core.storage._utils import (
clone_workstream_transaction,
find_orphan_conversations,
parse_checkpoint_watermark,
prepare_attachment_commit,
prepare_conversation_row_values,
prepare_provider_data_for_save,
purge_orphan_conversations,
release_attachment_refs,
retain_attachment_refs,
sanitize_text,
save_attachment_commit_transaction,
senders_from_user_meta,
)
from turnstone.core.storage._utils import (
delete_messages_after_core as _delete_messages_after_core,
)
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
get_compaction_floor_on_connection as _get_compaction_floor_shared,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
parse_attachment_refs as _parse_attachment_refs,
)
from turnstone.core.storage._utils import (
persona_row_to_dict as _persona_row_to_dict,
)
from turnstone.core.storage._utils import (
prune_workstreams_shared as _prune_workstreams_shared,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
from turnstone.core.storage._utils import (
reconstruct_turns_checkpointed as _reconstruct_turns_checkpointed,
)
from turnstone.core.storage._utils import (
recover_trajectory as _recover_trajectory,
)
from turnstone.core.storage._utils import (
resolve_keyed_commit_conflict as _resolve_keyed_commit_conflict,
)
from turnstone.core.storage._utils import (
row_to_dict as _row_to_dict,
)
from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content,
)
from turnstone.core.storage._utils import (
serialize_persona_fields as _serialize_persona_fields,
)
from turnstone.core.storage._utils import (
split_perms as _split_perms,
)
from turnstone.core.storage._utils import (
truncate_messages_tail_core as _truncate_messages_tail_core,
)
from turnstone.core.storage._utils import (
validate_and_clear_default_persona as _validate_and_clear_default_persona,
)
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__)
# PostgreSQL rejects any tsvector larger than 1MB ("string is too long for
# tsvector"), and search_history computes tsvectors inline per row — so one
# oversized row would abort the whole scan and every search with it. Worst
# case a tsvector runs ~4x its input (unique short lexemes + position data),
# so 250K chars keeps even pathological rows safely under the limit.
_FTS_INPUT_CAP_CHARS = 250_000
def _resolve_pg_listen_url(override: str, sqlalchemy_url: str) -> str:
"""Resolve the URL used by the dedicated LISTEN connection.
Precedence:
1. ``override`` — explicitly passed via :class:`PostgreSQLBackend`
constructor, typically wired from ``[database] listen_url`` in
``config.toml`` or ``--db-listen-url`` on the CLI.
2. ``TURNSTONE_DB_LISTEN_URL`` environment variable.
3. The engine's main DB URL.
The override is for deployments where the regular ``TURNSTONE_DB_URL``
points at a ``pgbouncer`` running in transaction pooling mode (the
project default per ``docs/pgbouncer.md``). LISTEN holds session
state and is incompatible with transaction pooling; the dispatcher
needs to bypass pgbouncer for that single connection. When neither
override is set and ``TURNSTONE_DB_URL`` already points at Postgres
directly (no pooler in between), the fallback uses the engine URL
as-is.
The SQLAlchemy ``+psycopg`` driver suffix is stripped so the URL is
consumable by ``psycopg.connect`` directly.
"""
raw = override.strip() or os.environ.get("TURNSTONE_DB_LISTEN_URL", "").strip()
raw = raw or sqlalchemy_url
return raw.replace("postgresql+psycopg://", "postgresql://", 1)
class _PostgreSQLNotifyStream:
"""PostgreSQL ``listen`` stream — drains ``conn.notifies`` per poll.
Owns a dedicated psycopg autocommit connection. Each :meth:`poll`
waits up to ``timeout`` seconds for notifications and returns them
as a list — empty on timeout, raises :class:`NotifyConnectionError`
on connection loss (caller reconciles + re-listens).
Closing the stream from another thread is the supported abort path:
``close`` calls ``conn.close()``, which causes the in-flight
:meth:`poll` to wake (the next call returns ``[]`` because
``_closed`` is set).
"""
def __init__(self, conn: Any, channels: list[str]) -> None:
self._conn = conn
self._channels = list(channels)
self._closed = False
self._close_lock = threading.Lock()
def poll(self, timeout: float) -> list[Notify]:
from turnstone.core.storage._notify import Notify, NotifyConnectionError
if self._closed:
return []
out: list[Notify] = []
try:
# psycopg3 generator yields whatever's available within the
# window, then stops — bounded blocking semantics. Per-call
# generator (not a long-lived one) so close() can abort by
# closing the connection without leaving a half-consumed
# generator behind.
for n in self._conn.notifies(timeout=max(0.0, timeout)):
out.append(Notify(channel=n.channel, payload=n.payload, pid=n.pid))
except Exception as exc:
if self._closed:
# Graceful close-from-another-thread surfaced as an
# operational error inside notifies() — swallow it,
# let the caller observe close via the next poll
# returning ``[]``.
return out
raise NotifyConnectionError(str(exc)) from exc
return out
def close(self) -> None:
with self._close_lock:
if self._closed:
return
self._closed = True
conn = self._conn
# Best-effort UNLISTEN + close. An already-broken connection
# raises here; the consumer's reconciliation logic will catch
# the underlying ``NotifyConnectionError`` on the next poll if
# any waiter is still blocked.
with contextlib.suppress(Exception):
conn.execute("UNLISTEN *")
with contextlib.suppress(Exception):
conn.close()
class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
"""PostgreSQL implementation of the StorageBackend protocol."""
def __init__(
self,
url: str,
pool_size: int = 2,
max_overflow: int = 3,
*,
create_tables: bool = True,
listen_url: str = "",
) -> None:
self._engine = sa.create_engine(
url,
pool_size=pool_size,
max_overflow=max_overflow,
pool_pre_ping=True,
)
self._db_unavailable = False
self._db_unavailable_lock = threading.Lock()
# Operator override for the dedicated LISTEN connection's URL.
# Empty string means "fall back through env var, then the main
# engine URL" — see :func:`_resolve_pg_listen_url` for the full
# precedence rules. Threaded through ``init_storage`` from
# ``config.toml [database] listen_url`` / ``--db-listen-url``.
self._listen_url_override = listen_url
if create_tables:
metadata.create_all(self._engine)
@contextlib.contextmanager
def _conn(self) -> Iterator[sa.engine.Connection]:
"""Acquire a DB connection with clean logging on connectivity errors.
On ``OperationalError`` during *connect* (connection refused,
timeout, etc.) this logs a single ``database.unavailable`` line
and raises ``StorageUnavailableError``. Errors that occur
*after* a successful connect (mid-query failures, lock
contention) propagate as-is so callers see the real error.
"""
from turnstone.core.storage._registry import StorageUnavailableError
try:
conn_cm = self._engine.connect()
except sa.exc.OperationalError as exc:
with self._db_unavailable_lock:
if not self._db_unavailable:
self._db_unavailable = True
log.error(
"database.unavailable",
url=self._engine.url.render_as_string(hide_password=True),
)
raise StorageUnavailableError(str(exc)) from exc
with conn_cm as conn:
with self._db_unavailable_lock:
if self._db_unavailable:
self._db_unavailable = False
log.info("database.connection_restored")
yield conn
# -- Core conversation operations ------------------------------------------
def save_message(
self,
ws_id: str,
role: str,
content: str | None,
tool_name: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
source: str | None = None,
event_id: int | None = None,
is_error: bool = False,
producer: str | None = None,
meta: str | None = None,
commit_key: str | None = None,
) -> int:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
values = prepare_conversation_row_values(
ws_id,
role,
content,
tool_name=tool_name,
tool_call_id=tool_call_id,
provider_data=provider_data,
tool_calls=tool_calls,
source=source,
event_id=event_id,
is_error=is_error,
producer=producer,
meta=meta,
commit_key=commit_key,
now=now,
)
with self._conn() as conn:
inserted = True
parent_observed = None
if commit_key is None:
# A non-locking MVCC observation distinguishes a call that
# genuinely began parentless from one that arrived while prune
# held (and would shortly delete) an existing parent. At READ
# COMMITTED an uncommitted delete remains visible here.
parent_observed = conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == ws_id)
).fetchone()
# Hard delete and every conditional delete lock this durable row
# before scanning/deleting conversations. Every writer takes the
# same parent-first order when the row exists. This closes the
# PostgreSQL READ COMMITTED anomaly where prune's earlier
# NOT EXISTS snapshot could delete the parent while an unlocked
# NULL-key insert became visible only afterwards.
#
# A call that genuinely begins without a parent remains a
# deliberate legacy/offline seam for NULL keys. A call that saw a
# parent but wakes after prune deleted it is refused below rather
# than reclassified as a parentless import. Keyed live admission
# always refuses a missing parent.
parent = conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
if commit_key is None and parent_observed is not None and parent is None:
raise RuntimeError("legacy conversation append crossed workstream deletion")
if parent is None and commit_key is not None:
raise ConversationCommitWorkstreamGoneError(
"keyed conversation commit workstream no longer exists"
)
statement = postgresql_insert(conversations).values(**values)
if commit_key is not None:
statement = statement.on_conflict_do_nothing(
index_elements=[conversations.c.ws_id, conversations.c.commit_key],
index_where=conversations.c.commit_key.is_not(None),
)
result = conn.execute(statement.returning(conversations.c.id))
resolved = result.scalar_one_or_none()
if resolved is None:
if commit_key is None:
raise RuntimeError("save_message: row id missing after insert")
inserted = False
rowid = _resolve_keyed_commit_conflict(conn, ws_id, values)
else:
rowid = int(resolved)
if inserted:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
)
conn.commit()
return rowid
def _save_message_with_attachments(
self,
ws_id: str,
role: str,
content: str,
attachments: list[AttachmentWrite] | tuple[AttachmentWrite, ...],
*,
tool_name: str | None = None,
tool_call_id: str | None = None,
source: str | None = None,
event_id: int | None = None,
is_error: bool = False,
meta: str | None = None,
commit_key: str,
origin: str,
exact_blob_metadata: bool,
) -> int:
"""Dialect-local transaction shared by keyed USER and TOOL rows."""
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
attachment_ids, blobs, values = prepare_attachment_commit(
ws_id,
role,
content,
attachments,
tool_name=tool_name,
tool_call_id=tool_call_id,
source=source,
event_id=event_id,
is_error=is_error,
meta=meta,
commit_key=commit_key,
now=now,
)
with self._conn() as conn:
try:
# Match hard-delete's durable-row-first lock order so no
# attachment-bearing insert can commit behind deletion. Holding
# it for the shared body below makes the parent check and every
# row/blob/refcount mutation one indivisible transaction.
parent = conn.execute(
sa.select(workstreams.c.ws_id)
.where(workstreams.c.ws_id == ws_id)
.with_for_update()
).fetchone()
if parent is None:
raise ConversationCommitWorkstreamGoneError(
"keyed conversation commit workstream no longer exists"
)
row_id = save_attachment_commit_transaction(
conn,
postgresql_insert,
values=values,
attachment_ids=attachment_ids,
blobs=blobs,
now=now,
origin=origin,
exact_blob_metadata=exact_blob_metadata,
)
conn.commit()
return row_id
except Exception:
conn.rollback()
raise
def list_message_senders(self, ws_id: str) -> list[str]:
# DISTINCT on the raw meta blob: a user row's meta carries only
# {"sender": ...}, so distinct blobs ≈ distinct senders and the JSON
# parse (shared, backend-neutral) runs on a handful of rows.
with self._conn() as conn:
rows = conn.execute(
sa.select(conversations.c.meta)
.distinct()
.where(
conversations.c.ws_id == ws_id,
conversations.c.role == "user",
conversations.c.meta.is_not(None),
)
).fetchall()
return senders_from_user_meta(meta for (meta,) in rows)
def save_messages_bulk(self, rows: list[dict[str, Any]]) -> None:
if not rows:
return
# Single timestamp for all rows — ordering is preserved by auto-increment id.
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_rows = []
attachment_ids: list[str] = []
ws_ids: set[str] = set()
for row in rows:
ws_ids.add(row["ws_id"])
row_attachment_ids = [
attachment_id
for attachment_id in row.get("attachment_ids", [])
if isinstance(attachment_id, str) and attachment_id
]
attachment_ids.extend(row_attachment_ids)
insert_rows.append(
{
"ws_id": row["ws_id"],
"timestamp": now,
"role": row["role"],
"content": sanitize_text(row["content"]),
"tool_name": row.get("tool_name"),
"tool_call_id": row.get("tool_call_id"),
"provider_data": prepare_provider_data_for_save(
row["role"],
sanitize_text(row.get("provider_data")),
row.get("tool_calls"),
row.get("producer"),
),
"tool_calls": row.get("tool_calls"),
"_source": sanitize_text(row.get("source")),
"is_error": bool(row.get("is_error", False)),
"attachments": (json.dumps(row_attachment_ids) if row_attachment_ids else None),
"meta": row.get("meta"),
}
)
with self._conn() as conn:
self._lock_parents_refusing_crossed_deletion(conn, ws_ids)
retain_attachment_refs(conn, attachment_ids)
conn.execute(sa.insert(conversations), insert_rows)
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id.in_(sorted(ws_ids)))
.values(updated=now)
)
conn.commit()
def _lock_parents_refusing_crossed_deletion(
self,
conn: sa.engine.Connection,
ws_ids: set[str],
) -> None:
"""Batched READ COMMITTED anomaly gate for the bulk import path.
The set-shaped twin of ``save_message``'s single-row
observed→lock→refuse sequence (its NULL-commit-key arm) — keep the
two semantically in lockstep. Matches prune/delete's parent-first
order; the ``ORDER BY ws_id`` on the locking read preserves the
sorted lock order that keeps concurrent bulk writers from
deadlocking one another. Missing parents retain the historical
import behavior and do not abort the batch; a parent OBSERVED but
not lockable crossed a concurrent deletion and refuses.
"""
ordered = sorted(ws_ids)
observed = {
row[0]
for row in conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id.in_(ordered))
).fetchall()
}
locked = {
row[0]
for row in conn.execute(
sa.select(workstreams.c.ws_id)
.where(workstreams.c.ws_id.in_(ordered))
.order_by(workstreams.c.ws_id)
.with_for_update()
).fetchall()
}
if observed - locked:
raise RuntimeError("legacy conversation append crossed workstream deletion")
def _conversation_rows(
self, ws_id: str, limit: int | None
) -> tuple[list[tuple[Any, ...]], dict[int, list[dict[str, Any]]] | None]:
"""Fetch a ws's conversation rows + resolved attachment map (shared by
:meth:`load_messages` and :meth:`load_message_turns`). The trailing
``attachments`` ref-list and ``commit_key`` columns stay internal to
reconstruction."""
_cols = (
conversations.c.id,
conversations.c.role,
conversations.c.content,
conversations.c.tool_name,
conversations.c.tool_call_id,
conversations.c.provider_data,
conversations.c.tool_calls,
conversations.c._source,
conversations.c.event_id,
conversations.c.is_error,
conversations.c.meta,
conversations.c.attachments,
conversations.c.commit_key,
)
with self._conn() as conn:
if limit is not None and limit > 0:
rows = conn.execute(
sa.select(*_cols)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id.desc())
.limit(limit)
).fetchall()
rows = list(reversed(rows))
else:
rows = conn.execute(
sa.select(*_cols)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
).fetchall()
attachments = self._resolve_row_attachments(rows)
# Preserve the raw ref-list for canonical Turn/fork durability; see
# the SQLite twin for the source-delete race this closes.
msg_rows = [tuple(r) for r in rows]
return msg_rows, (attachments or None)
def load_messages(
self,
ws_id: str,
*,
limit: int | None = None,
repair: bool = True,
include_compaction: bool = False,
) -> list[dict[str, Any]]:
msg_rows, attachments = self._conversation_rows(ws_id, limit)
return _reconstruct_messages(
msg_rows, ws_id, attachments, repair=repair, include_compaction=include_compaction
)
def load_message_turns(self, ws_id: str, *, checkpointed: bool = True) -> list[Turn]:
"""Load the conversation as canonical ``Turn``s (unresolved AttachmentRef)
for resume; bytes materialize at each output, never here.
Checkpoint-aware (``checkpointed=True``, resume default): a persisted
compaction marker rehydrates only ``[summary] + [rows after its
watermark]`` instead of the full pre-compaction transcript (which can
overflow the window on reopen). ``checkpointed=False`` returns the full
transcript (markers dropped) for export/audit consumers."""
msg_rows, attachments = self._conversation_rows(ws_id, None)
return _recover_trajectory(
_reconstruct_turns_checkpointed(msg_rows, ws_id, attachments, checkpoint=checkpointed)
)
def clone_workstream(
self,
source_ws_id: str,
destination_ws_id: str,
*,
principal_id: str,
trusted_internal: bool = False,
expected_session: ForkCloneExpectation | None = None,
) -> ForkCloneSnapshot:
"""Clone source state at one serializable PostgreSQL snapshot."""
for attempt in range(3):
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as base_conn:
conn = base_conn.execution_options(isolation_level="SERIALIZABLE")
try:
snapshot = clone_workstream_transaction(
conn,
source_ws_id,
destination_ws_id,
principal_id=principal_id,
trusted_internal=trusted_internal,
expected_session=expected_session,
now=now,
lock_rows=True,
)
conn.commit()
return snapshot
except sa.exc.DBAPIError as exc:
conn.rollback()
original = exc.orig
sqlstate = getattr(original, "sqlstate", None) or getattr(
original, "pgcode", None
)
if sqlstate in {"40001", "40P01"} and attempt < 2:
# Source delete/project mutation and ordinary appends
# may cross the clone lock order. Retry the entire
# authorization + snapshot, never only the write tail.
time.sleep(0.01 * (attempt + 1))
continue
raise
except Exception:
conn.rollback()
raise
raise RuntimeError("clone_workstream: retry loop exhausted")
def _resolve_row_attachments(self, rows: Sequence[Any]) -> dict[int, list[dict[str, Any]]]:
"""Build the ``reconstruct_messages`` attachment map from row ref-lists.
Each row's trailing ``attachments`` column (last element) is the
content-addressed ref-list; collect every referenced id, bulk-fetch
the blobs in one query, and group them back per row id in ref-list
order. No referenced ids → no query.
"""
attachment_refs: dict[int, list[str]] = {}
all_ids: set[str] = set()
for r in rows:
ids = _parse_attachment_refs(r[11])
if ids:
attachment_refs[r[0]] = ids
all_ids.update(ids)
if not all_ids:
return {}
# Preview-pane blobs (kind='preview', see core.preview.PREVIEW_BLOB_KIND)
# ride ref-lists only for GC + the serving gate; reconstruction skips
# them, so don't pull their multi-MB content off disk on every load.
blobs = self.get_attachments(list(all_ids), exclude_kinds=("preview",))
rows_by_id = {str(b["attachment_id"]): b for b in blobs}
return _build_attachments_by_msg(attachment_refs, rows_by_id)
def get_max_event_id(self, ws_id: str) -> int | None:
with self._conn() as conn:
row = conn.execute(
sa.select(sa.func.max(conversations.c.event_id)).where(
conversations.c.ws_id == ws_id
)
).fetchone()
return int(row[0]) if row is not None and row[0] is not None else None
def get_compaction_watermark(self, ws_id: str, preserve_tail: int = 0) -> int | None:
"""Boundary id for a compaction checkpoint: the max conversation ``id``
among the rows a compaction would summarize (see the sqlite twin).
The ``(N+1)``-th newest id counting REAL rows; compaction markers are
excluded (summary artifacts, never part of the preserved tail — counting
them would skew the boundary and drop real tail rows on resume). ``None``
when empty.
"""
with self._conn() as conn:
row = conn.execute(
sa.select(conversations.c.id)
.where(
sa.and_(
conversations.c.ws_id == ws_id,
sa.or_(
conversations.c._source.is_(None),
conversations.c._source != _COMPACTION_SOURCE,
),
)
)
.order_by(conversations.c.id.desc())
.limit(1)
.offset(max(0, preserve_tail))
).fetchone()
return int(row[0]) if row is not None else None
def count_messages(self, ws_id: str) -> int:
"""Total conversation rows for ``ws_id`` (compaction markers included)."""
with self._conn() as conn:
n = conn.execute(
sa.select(sa.func.count())
.select_from(conversations)
.where(conversations.c.ws_id == ws_id)
).scalar()
return int(n or 0)
def get_compaction_floor(self, ws_id: str) -> int:
"""Rows backing the latest compaction summary that must survive rewind/
retry: every row with ``id <= the latest marker's id`` (see the sqlite
twin). ``0`` when the ws never compacted.
"""
with self._conn() as conn:
return _get_compaction_floor_shared(conn, ws_id)
def get_compaction_checkpoint(self, ws_id: str) -> int | None:
"""Latest persisted marker's watermark — see the protocol docstring.
``None`` = never compacted / malformed meta (whole ws is live)."""
with self._conn() as conn:
row = conn.execute(
sa.select(conversations.c.meta)
.where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c._source == _COMPACTION_SOURCE,
)
)
.order_by(conversations.c.id.desc())
.limit(1)
).fetchone()
return parse_checkpoint_watermark(row[0]) if row is not None else None
def _delete_messages_after_on_connection(
self,
conn: sa.engine.Connection,
ws_id: str,
keep_count: int,
) -> int:
"""Delete one conversation tail; caller owns the parent lock."""
return _delete_messages_after_core(conn, ws_id, keep_count)
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
with self._conn() as conn:
# Keyed commits, hard delete, and prune all lock the durable parent
# before touching conversation rows. Take the same lock for a tail
# truncation. A missing legacy parent has no row to lock; continue
# for orphan-truncation compatibility. Fencing same-id recreation
# across that gap requires a separate incarnation boundary.
conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
deleted = self._delete_messages_after_on_connection(conn, ws_id, keep_count)
conn.commit()
return deleted
def truncate_messages_tail(self, ws_id: str, remove_count: int) -> int:
"""Atomically remove a compaction-floored number of newest rows."""
if remove_count < 0:
raise ValueError("remove_count must be non-negative")
with self._conn() as conn:
try:
# A truncation runs on a non-abandonable worker slot: operator
# force-cancel deliberately refuses to supersede it, so an
# unbounded FOR UPDATE wait here would pin the workstream until
# node restart. Bound the wait; timing out surfaces as an
# ordinary persist failure the rewind route reports as
# retryable while the slot is released.
conn.execute(sa.text("SET LOCAL lock_timeout = '10s'"))
# Every keyed conversation commit takes this row lock first.
# Hold it across both count queries and the exact tail delete so
# another process cannot turn ``remove_count`` into an
# over-delete by committing in between them.
parent = conn.execute(
sa.select(workstreams.c.ws_id)
.where(workstreams.c.ws_id == ws_id)
.with_for_update()
).fetchone()
if parent is None:
raise RuntimeError("tail truncation workstream no longer exists")
deleted = _truncate_messages_tail_core(
conn,
ws_id,
remove_count,
delete_after=self._delete_messages_after_on_connection,
)
conn.commit()
return deleted
except Exception:
conn.rollback()
raise
# -- Workstream management -------------------------------------------------
def list_workstreams_with_history(
self,
limit: int = 20,
*,
kind: WorkstreamKind | str | None = None,
user_id: str | None = None,
state: str | None = None,
offset: int = 0,
) -> list[Any]:
# See SQLite sibling for the rationale on the kind / user_id / state filters.
params: dict[str, Any] = {"limit": limit, "offset": max(0, offset)}
kind_clause = ""
user_clause = ""
state_clause = ""
if kind is not None:
params["kind"] = WorkstreamKind(kind).value
kind_clause = "AND w.kind = :kind "
if user_id is not None:
params["user_id"] = user_id
user_clause = "AND w.user_id = :user_id "
if state is not None:
params["state"] = state
state_clause = "AND w.state = :state "
with self._conn() as conn:
return list(
conn.execute(
sa.text(
"SELECT w.ws_id, w.alias, w.title, w.name, w.created, w.updated, "
"(SELECT COUNT(*) FROM conversations c "
" WHERE c.ws_id = w.ws_id), "
"w.node_id, w.state, w.kind, "
"wcm.value, wcs.value, "
"(SELECT COUNT(*) FROM workstreams ch "
" WHERE ch.parent_ws_id = w.ws_id "
" AND ch.state != 'creating'), "
"(SELECT ue.prompt_tokens FROM usage_events ue "
" WHERE ue.ws_id = w.ws_id "
" ORDER BY ue.timestamp DESC LIMIT 1), "
"md.context_window, w.project_id, w.user_id, w.persona "
"FROM workstreams w "
"LEFT JOIN workstream_config wcm "
" ON wcm.ws_id = w.ws_id AND wcm.key = 'model_alias' "
"LEFT JOIN workstream_config wcs "
" ON wcs.ws_id = w.ws_id AND wcs.key = 'skill' "
"LEFT JOIN model_definitions md ON md.alias = wcm.value "
"WHERE EXISTS "
" (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) "
"AND w.state != 'creating' "
f"{kind_clause}"
f"{user_clause}"
f"{state_clause}"
"ORDER BY w.updated DESC LIMIT :limit OFFSET :offset"
),
params,
).fetchall()
)
def _delete_prune_candidate(
self,
ws_id: str,
predicates: tuple[Any, ...],
) -> bool:
"""Recheck and delete one prune candidate in its own transaction.
Candidate admission takes the same durable-row-first lock as all
conversation writers and hard deletion. ``SKIP LOCKED`` leaves a
workstream a writer already owns to the next prune; a writer that
arrives after admission waits for this transaction instead: keyed
admission then fails closed, and a NULL-key writer that observed the
pre-delete parent also refuses rather than becoming an invisible
orphan.
The exact predicate is rechecked as a second statement so it reads a
fresh READ COMMITTED snapshot rather than the lock statement's — a
commit that landed while this transaction waited for the row is
therefore visible, and its workstream is no longer a candidate. One
transaction per candidate keeps that lock and the per-workstream
attachment GC off every unrelated keyed commit for the rest of the run.
"""
with self._conn() as conn:
try:
locked = conn.execute(
sa.select(workstreams.c.ws_id)
.where(workstreams.c.ws_id == ws_id)
.with_for_update(skip_locked=True)
).fetchone()
exact = (
conn.execute(
sa.select(workstreams.c.ws_id).where(
workstreams.c.ws_id == ws_id,
*predicates,
)
).fetchone()
if locked is not None
else None
)
deleted = bool(
exact is not None and self._delete_workstream_on_connection(conn, ws_id)
)
conn.commit()
return deleted
except Exception:
conn.rollback()
raise
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
# Discovery holds no row locks: every candidate is relocked and
# rechecked in its own bounded transaction by
# ``_delete_prune_candidate``, so a long prune never blocks keyed
# commits to workstreams it has not reached yet.
def _select_ids(predicates: tuple[Any, ...]) -> list[str]:
with self._conn() as conn:
return [
str(row[0])
for row in conn.execute(
sa.select(workstreams.c.ws_id).where(*predicates)
).fetchall()
]
return _prune_workstreams_shared(
retention_days,
select_ids=_select_ids,
delete_candidate=self._delete_prune_candidate,
)
def resolve_workstream(self, alias_or_id: str) -> str | None:
with self._conn() as conn:
# 1. Exact alias
row = conn.execute(
sa.select(workstreams.c.ws_id).where(
workstreams.c.alias == alias_or_id,
workstreams.c.state != "creating",
)
).fetchone()
if row:
return str(row[0])
# 2. Exact ws_id
row = conn.execute(
sa.select(workstreams.c.ws_id).where(
workstreams.c.ws_id == alias_or_id,
workstreams.c.state != "creating",
)
).fetchone()
if row:
return str(row[0])
# 3. Prefix match
rows = conn.execute(
sa.select(workstreams.c.ws_id).where(
workstreams.c.ws_id.like(alias_or_id + "%"),
workstreams.c.state != "creating",
)
).fetchall()
if len(rows) == 1:
return str(rows[0][0])
return None
# -- Workstream config -----------------------------------------------------
def save_workstream_config(self, ws_id: str, config: dict[str, str]) -> None:
public_config = {
key: value for key, value in config.items() if key != FORK_RESERVATION_CONFIG_KEY
}
if not public_config:
return
with self._conn() as conn:
conn.execute(
sa.text(
"INSERT INTO workstream_config (ws_id, key, value) "
"VALUES (:ws_id, :key, :value) "
"ON CONFLICT (ws_id, key) DO UPDATE SET value = EXCLUDED.value"
),
[
{"ws_id": ws_id, "key": key, "value": value}
for key, value in public_config.items()
],
)
conn.commit()
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_config.c.key, workstream_config.c.value).where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key != FORK_RESERVATION_CONFIG_KEY,
)
).fetchall()
return {row[0]: row[1] for row in rows}
def finalize_deferred_create(
self,
ws_id: str,
fork_reservation_token: str,
*,
alias: str | None = None,
config: dict[str, str] | None = None,
node_id: str | None = None,
override_reason: str = "local",
) -> bool:
"""Apply private prepublication writes to exactly one fork row."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
if not ws_id or not fork_reservation_token:
return False
public_config = {
key: value
for key, value in (config or {}).items()
if key != FORK_RESERVATION_CONFIG_KEY
}
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.state).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
reservation = conn.execute(
sa.select(workstream_config.c.value)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.with_for_update()
).fetchone()
if (
row is None
or str(row[0] or "") != "creating"
or reservation is None
or str(reservation[0] or "") != fork_reservation_token
):
conn.rollback()
return False
if alias is not None:
incumbent = conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias)
).fetchone()
if incumbent is not None and str(incumbent[0]) != ws_id:
conn.rollback()
return False
try:
if alias is not None:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(alias=alias)
)
if public_config:
config_stmt = pg_insert(workstream_config)
conn.execute(
config_stmt.on_conflict_do_update(
index_elements=["ws_id", "key"],
set_={"value": config_stmt.excluded.value},
),
[
{"ws_id": ws_id, "key": key, "value": value}
for key, value in public_config.items()
],
)
if node_id is not None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
override_stmt = pg_insert(workstream_overrides).values(
ws_id=ws_id,
node_id=node_id,
reason=override_reason,
created=now,
updated=now,
)
conn.execute(
override_stmt.on_conflict_do_update(
index_elements=[workstream_overrides.c.ws_id],
set_={
"node_id": node_id,
"reason": override_reason,
"updated": now,
},
)
)
conn.commit()
return True
except sa.exc.IntegrityError:
conn.rollback()
if alias is not None:
return False
raise
def publish_deferred_create(
self,
ws_id: str,
fork_reservation_token: str,
) -> bool:
"""CAS one exact durable reservation from creating to idle."""
if not ws_id or not fork_reservation_token:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.state).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
reservation = conn.execute(
sa.select(workstream_config.c.value)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.with_for_update()
).fetchone()
if (
row is None
or str(row[0] or "") != "creating"
or reservation is None
or str(reservation[0] or "") != fork_reservation_token
):
conn.rollback()
return False
published = conn.execute(
sa.update(workstreams)
.where(
workstreams.c.ws_id == ws_id,
workstreams.c.state == "creating",
)
.values(state="idle", updated=now)
.returning(workstreams.c.ws_id)
).fetchone()
if published is None:
conn.rollback()
return False
conn.commit()
return True
def get_workstream_reservation_token(self, ws_id: str) -> str:
if not ws_id:
return ""
with self._conn() as conn:
row = conn.execute(
sa.select(workstream_config.c.value).where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
).fetchone()
return str(row[0] or "") if row is not None else ""
# -- Workstream metadata ---------------------------------------------------
def set_workstream_alias(self, ws_id: str, alias: str) -> bool:
with self._conn() as conn:
existing = conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.alias == alias)
).fetchone()
if existing and existing[0] != ws_id:
return False
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(alias=alias)
)
conn.commit()
return True
def get_workstream_display_name(self, ws_id: str) -> str | None:
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.alias, workstreams.c.title, workstreams.c.name).where(
workstreams.c.ws_id == ws_id
)
).fetchone()
if row:
value = row[0] or row[1] or row[2]
return str(value) if value is not None else None
return None
def get_workstream_display_names(self, ws_ids: list[str]) -> dict[str, str | None]:
if not ws_ids:
return {}
with self._conn() as conn:
rows = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.alias,
workstreams.c.title,
workstreams.c.name,
).where(workstreams.c.ws_id.in_(ws_ids))
).fetchall()
result: dict[str, str | None] = dict.fromkeys(ws_ids)
for r in rows:
value = r[1] or r[2] or r[3]
result[r[0]] = str(value) if value is not None else None
return result
def get_workstream_owner(self, ws_id: str) -> str | None:
with self._conn() as conn:
row = conn.execute(
sa.select(workstreams.c.user_id).where(workstreams.c.ws_id == ws_id)
).fetchone()
if row is None:
return None
return row[0] or ""
def get_workstream_metadata(self, ws_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.alias,
workstreams.c.title,
workstreams.c.name,
workstreams.c.node_id,
workstreams.c.skill_id,
workstreams.c.skill_version,
).where(workstreams.c.ws_id == ws_id)
).fetchone()
if row:
return {
"ws_id": row[0],
"alias": row[1],
"title": row[2],
"name": row[3],
"node_id": row[4],
"skill_id": row[5],
"skill_version": row[6],
}
return None
def get_workstream(self, ws_id: str) -> dict[str, Any] | None:
"""Return the full workstreams row as a dict, or None if missing.
Delegates to ``get_workstreams_batch`` so the 13-column projection
+ row→dict mapping live in one place — a future migration
adding/renaming a column only has to be applied once per
backend instead of in two parallel selects that can drift.
"""
return self.get_workstreams_batch([ws_id]).get(ws_id)
def ensure_workstream_incarnation_snapshot(self, ws_id: str) -> dict[str, Any] | None:
"""Read one row and install a legacy incarnation fence atomically."""
if not ws_id:
return None
with self._conn() as conn:
# Lock the durable row before its config fence, matching clone and
# conditional delete. A replacement cannot cross this snapshot.
row = conn.execute(
sa.select(workstreams).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
if row is None:
conn.rollback()
return None
token_row = conn.execute(
sa.select(workstream_config.c.value)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.with_for_update()
).fetchone()
token = str(token_row[0] or "") if token_row is not None else ""
if not token:
token = uuid.uuid4().hex
if token_row is None:
conn.execute(
sa.insert(workstream_config),
{
"ws_id": ws_id,
"key": FORK_RESERVATION_CONFIG_KEY,
"value": token,
},
)
else:
conn.execute(
sa.update(workstream_config)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.values(value=token)
)
conn.commit()
snapshot = dict(row._mapping)
snapshot["fork_reservation_token"] = token
return snapshot
def update_workstream_title(self, ws_id: str, title: str) -> None:
with self._conn() as conn:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title)
)
conn.commit()
# -- Workstream operations -------------------------------------------------
def register_workstream(
self,
ws_id: str,
node_id: str | None = None,
name: str = "",
state: str = "idle",
user_id: str | None = None,
alias: str | None = None,
title: str | None = None,
skill_id: str = "",
skill_version: int = 0,
kind: WorkstreamKind | str = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
project_id: str | None = None,
persona: str | None = None,
fork_reservation_token: str = "",
) -> bool:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
# Validate kind at the storage edge — see the sqlite sibling for rationale.
norm_kind = WorkstreamKind(kind).value
# Normalize empty-string parent to NULL so WHERE parent_ws_id IS NULL
# filters remain correct.
norm_parent = parent_ws_id if parent_ws_id else None
norm_project = project_id if project_id else None
norm_persona = persona if persona else None
# Use ON CONFLICT DO NOTHING to match SQLite's OR IGNORE semantics
# and close the SELECT-then-INSERT TOCTOU window under concurrent
# register_workstream calls for the same ws_id.
stmt = pg_insert(workstreams).values(
ws_id=ws_id,
node_id=node_id,
user_id=user_id,
name=name,
state=state,
alias=alias,
title=title,
skill_id=skill_id,
skill_version=skill_version,
kind=norm_kind,
parent_ws_id=norm_parent,
project_id=norm_project,
persona=norm_persona,
created=now,
updated=now,
)
insert_stmt = stmt.on_conflict_do_nothing(index_elements=["ws_id"]).returning(
workstreams.c.ws_id
)
with self._conn() as conn:
inserted_row = conn.execute(insert_stmt).fetchone()
inserted = inserted_row is not None
if inserted:
if fork_reservation_token:
# Keep the row reservation and its incarnation fence in
# one transaction, replacing any stale orphan key.
token_stmt = pg_insert(workstream_config).values(
ws_id=ws_id,
key=FORK_RESERVATION_CONFIG_KEY,
value=fork_reservation_token,
)
conn.execute(
token_stmt.on_conflict_do_update(
index_elements=["ws_id", "key"],
set_={"value": fork_reservation_token},
)
)
else:
# A non-fork incarnation must not inherit an orphaned
# token that could authorize deletion by its predecessor.
conn.execute(
sa.delete(workstream_config).where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
)
conn.commit()
return inserted
def update_workstream_state(self, ws_id: str, state: str) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(state=state, updated=now)
)
conn.commit()
def bulk_close_stale_orphans(
self,
kind: WorkstreamKind | str,
cutoff: str,
exclude_ws_ids: list[str],
live_node_ids: list[str] | None = None,
) -> list[str]:
norm_kind = WorkstreamKind(kind).value
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = (
sa.update(workstreams)
.where(
workstreams.c.kind == norm_kind,
workstreams.c.state.in_(BULK_CLOSE_STATE_VALUES),
workstreams.c.updated < cutoff,
)
.values(state="closed", updated=now)
.returning(workstreams.c.ws_id)
)
# Protect rows whose owning process is still heartbeating in the
# services table (rendezvous router's liveness primitive). NULL
# node_id rows have no owner identity — always eligible. The
# ``and live_node_ids`` short-circuits both ``None`` (skip the
# filter entirely — single-process / operator backfill) and ``[]``
# (no nodes alive — every row unprotected, no extra predicate
# needed since absence equals match-all).
if live_node_ids is not None and live_node_ids:
stmt = stmt.where(
sa.or_(
workstreams.c.node_id.is_(None),
~workstreams.c.node_id.in_(live_node_ids),
)
)
if exclude_ws_ids:
# Skip ``NOT IN ()`` when nothing to exclude — keeps the SQL clean
# and avoids SQLAlchemy's empty-collection warning.
stmt = stmt.where(~workstreams.c.ws_id.in_(exclude_ws_ids))
with self._conn() as conn:
result = conn.execute(stmt)
ids = [row[0] for row in result]
conn.commit()
return ids
def delete_stale_creating_reservations(
self,
kind: WorkstreamKind | str,
cutoff: str,
exclude_ws_ids: list[str],
*,
live_node_ids: list[str],
local_node_id: str | None,
) -> list[str]:
"""Hard-delete stale hidden creates under exact row/token locks."""
if live_node_ids is None:
# Liveness uncertainty is never permission to reap.
return []
norm_kind = WorkstreamKind(kind).value
local_owner = local_node_id or None
protected_live_nodes = {
node_id for node_id in live_node_ids if node_id and node_id != local_owner
}
conditions: list[Any] = [
workstreams.c.kind == norm_kind,
workstreams.c.state == "creating",
workstreams.c.updated < cutoff,
]
if exclude_ws_ids:
conditions.append(~workstreams.c.ws_id.in_(exclude_ws_ids))
if protected_live_nodes:
conditions.append(
sa.or_(
workstreams.c.node_id.is_(None),
~workstreams.c.node_id.in_(protected_live_nodes),
)
)
deleted: list[str] = []
tokenless_deleted = 0
with self._conn() as conn:
# Lock only the durable row at candidate admission. Publication
# takes the same row-first order, so it cannot retain the token,
# flip to idle, and then be erased by a token-only cleanup race.
candidates = conn.execute(
sa.select(workstreams.c.ws_id).where(*conditions).with_for_update(skip_locked=True)
).fetchall()
for (candidate_id,) in candidates:
ws_id = str(candidate_id)
reservation = conn.execute(
sa.select(workstream_config.c.value)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.with_for_update()
).fetchone()
token = str(reservation[0] or "") if reservation is not None else ""
exact_conditions: list[Any] = [
workstreams.c.ws_id == ws_id,
workstreams.c.kind == norm_kind,
workstreams.c.state == "creating",
workstreams.c.updated < cutoff,
]
if token:
exact_conditions.append(
sa.exists(
sa.select(workstream_config.c.ws_id).where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
workstream_config.c.value == token,
)
)
)
exact_incarnation = conn.execute(
sa.select(workstreams.c.ws_id).where(*exact_conditions)
).fetchone()
if exact_incarnation is None:
continue
if self._delete_workstream_on_connection(conn, ws_id):
deleted.append(ws_id)
if not token:
tokenless_deleted += 1
conn.commit()
if tokenless_deleted:
log.warning(
"storage.stale_create_tokenless_reaped backend=postgresql count=%d",
tokenless_deleted,
)
return deleted
def touch_workstream(self, ws_id: str) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
)
conn.commit()
def update_workstream_name(self, ws_id: str, name: str) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(name=name, updated=now)
)
conn.commit()
def _delete_workstream_on_connection(self, conn: Any, ws_id: str) -> bool:
"""Delete one row and dependents inside the caller's transaction."""
# Refcount GC over every referenced blob (content-addressed ids are
# global, so a deduped blob may be shared with another workstream —
# decrement, don't blanket-delete by ws_id). Blobs that hit 0 are
# pruned; any still referenced elsewhere survive.
referenced = conn.execute(
sa.select(conversations.c.attachments).where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c.attachments.is_not(None),
)
)
).fetchall()
ref_ids: list[str] = []
for (refs,) in referenced:
ref_ids.extend(_parse_attachment_refs(refs))
release_attachment_refs(conn, ref_ids)
conn.execute(sa.delete(conversations).where(conversations.c.ws_id == ws_id))
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id == ws_id))
conn.execute(sa.delete(workstream_overrides).where(workstream_overrides.c.ws_id == ws_id))
# Null-out parent_ws_id on children — see sqlite sibling for rationale.
conn.execute(
sa.update(workstreams)
.where(workstreams.c.parent_ws_id == ws_id)
.values(parent_ws_id=None)
)
deleted = conn.execute(
sa.delete(workstreams)
.where(workstreams.c.ws_id == ws_id)
.returning(workstreams.c.ws_id)
).fetchone()
return deleted is not None
def delete_workstream(self, ws_id: str) -> bool:
with self._conn() as conn:
# Match clone/conditional-delete lock ordering: durable row first,
# then conversations/config/dependents.
conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
deleted = self._delete_workstream_on_connection(conn, ws_id)
conn.commit()
return deleted
def delete_workstream_if_fork_reserved(
self,
ws_id: str,
fork_reservation_token: str,
) -> bool:
if not fork_reservation_token:
return False
with self._conn() as conn:
# Lock the durable incarnation before its config fence. This is
# the same ordering as clone_workstream_transaction.
row = conn.execute(
sa.select(workstreams.c.ws_id).where(workstreams.c.ws_id == ws_id).with_for_update()
).fetchone()
if row is None:
conn.rollback()
return False
reservation = conn.execute(
sa.select(workstream_config.c.value)
.where(
workstream_config.c.ws_id == ws_id,
workstream_config.c.key == FORK_RESERVATION_CONFIG_KEY,
)
.with_for_update()
).fetchone()
if reservation is None or str(reservation[0] or "") != fork_reservation_token:
conn.rollback()
return False
deleted = self._delete_workstream_on_connection(conn, ws_id)
conn.commit()
return deleted
def list_orphan_conversations(self) -> list[dict[str, Any]]:
with self._conn() as conn:
return find_orphan_conversations(conn)
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
with self._conn() as conn:
result = purge_orphan_conversations(conn, ws_ids)
conn.commit()
return result
# -- Workstream attachments (content-addressed, refcounted) ----------------
def save_attachment(
self,
attachment_id: str,
filename: str,
mime_type: str,
size_bytes: int,
kind: str,
content: bytes,
origin: str = "upload",
) -> None:
"""Write a content-addressed blob (INSERT-OR-IGNORE) and ``refcount += 1``.
Symmetric with the SQLite backend (see its docstring): the content
hash is the PK, the first reference writes at ``refcount = 1`` and
subsequent references only bump the count, so a stored blob is always
referenced and dedupes across messages / workstreams.
"""
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
stmt = pg_insert(workstream_attachments).values(
attachment_id=attachment_id,
filename=filename,
mime_type=mime_type,
size_bytes=size_bytes,
kind=kind,
content=content,
created=now,
refcount=0,
origin=origin,
)
conn.execute(stmt.on_conflict_do_nothing(index_elements=["attachment_id"]))
conn.execute(
sa.update(workstream_attachments)
.where(workstream_attachments.c.attachment_id == attachment_id)
.values(refcount=workstream_attachments.c.refcount + 1)
)
conn.commit()
def set_message_attachments(
self, ws_id: str, message_id: int, attachment_ids: list[str]
) -> None:
"""Record a turn's ordered content-addressed ref-list on its row.
Symmetric with the SQLite backend: writes the JSON id-list onto
``conversations.attachments`` for the ``(ws_id, message_id)`` row.
Empty input is a no-op.
"""
if not attachment_ids or not message_id:
return
with self._conn() as conn:
conn.execute(
sa.update(conversations)
.where(
sa.and_(
conversations.c.id == message_id,
conversations.c.ws_id == ws_id,
)
)
.values(attachments=json.dumps(list(attachment_ids)))
)
conn.commit()
def get_attachments(
self, attachment_ids: list[str], exclude_kinds: tuple[str, ...] = ()
) -> list[dict[str, Any]]:
if not attachment_ids:
return []
with self._conn() as conn:
stmt = sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
if exclude_kinds:
stmt = stmt.where(workstream_attachments.c.kind.notin_(exclude_kinds))
rows = conn.execute(stmt).fetchall()
return [dict(r._mapping) for r in rows]
def get_attachment(self, attachment_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id == attachment_id
)
).fetchone()
return dict(row._mapping) if row else None
def attachment_referenced_in_ws(self, attachment_id: str, ws_id: str) -> bool:
"""True iff some conversations row in ``ws_id`` references ``attachment_id``.
The committed-attachment ownership gate (see the SQLite sibling for
the full rationale): a quoted-id JSON-array substring match on the
``attachments`` column; 64-char sha256 ids cannot collide.
"""
needle = f'%"{_escape_like(attachment_id)}"%'
with self._conn() as conn:
row = conn.execute(
sa.select(conversations.c.id)
.where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c.attachments.is_not(None),
conversations.c.attachments.like(needle, escape=_LIKE_ESCAPE),
)
)
.limit(1)
).fetchone()
return row is not None
def list_workstreams(
self,
node_id: str | None = None,
limit: int = 100,
*,
parent_ws_id: str | None = None,
kind: WorkstreamKind | str | None = None,
user_id: str | None = None,
) -> list[Any]:
with self._conn() as conn:
q = (
sa.select(
workstreams.c.ws_id,
workstreams.c.node_id,
workstreams.c.name,
workstreams.c.state,
workstreams.c.created,
workstreams.c.updated,
workstreams.c.kind,
workstreams.c.parent_ws_id,
workstreams.c.skill_id,
workstreams.c.skill_version,
workstreams.c.user_id,
# Appended after ``user_id`` so positional fallbacks in
# consumers (``_coord_children_row`` et al.) that index
# up to row[9] stay valid; ``_coordinator_rows`` reads
# these by name to surface the persisted display title.
workstreams.c.title,
workstreams.c.alias,
# project_id + persona ride at the tail (read by name) so
# the persisted coordinator lane can carry its project
# group and persona label.
workstreams.c.project_id,
workstreams.c.persona,
)
.order_by(workstreams.c.updated.desc())
.limit(limit)
)
if node_id is not None:
q = q.where(workstreams.c.node_id == node_id)
if parent_ws_id is not None:
q = q.where(workstreams.c.parent_ws_id == parent_ws_id)
if kind is not None:
q = q.where(workstreams.c.kind == WorkstreamKind(kind).value)
if user_id is not None:
q = q.where(workstreams.c.user_id == user_id)
q = q.where(workstreams.c.state != "creating")
return list(conn.execute(q).fetchall())
def count_workstreams_by_state(
self,
*,
parent_ws_id: str | None = None,
user_id: str | None = None,
) -> dict[str, int]:
"""Return ``{state: count}`` for workstreams matching the filters.
See the SQLite backend's docstring (#perf-1).
"""
with self._conn() as conn:
q = (
sa.select(workstreams.c.state, sa.func.count())
.where(workstreams.c.state != "creating")
.group_by(workstreams.c.state)
)
if parent_ws_id is not None:
q = q.where(workstreams.c.parent_ws_id == parent_ws_id)
if user_id is not None:
q = q.where(workstreams.c.user_id == user_id)
rows = conn.execute(q).fetchall()
return {str(state or ""): int(count) for state, count in rows}
def count_workstreams_since(
self,
since: str,
*,
parent_ws_id: str | None = None,
user_id: str | None = None,
) -> int:
"""Return the count of workstream rows whose ``created`` is >= ``since``."""
with self._conn() as conn:
q = (
sa.select(sa.func.count())
.select_from(workstreams)
.where(workstreams.c.created >= since)
.where(workstreams.c.state != "creating")
)
if parent_ws_id is not None:
q = q.where(workstreams.c.parent_ws_id == parent_ws_id)
if user_id is not None:
q = q.where(workstreams.c.user_id == user_id)
row = conn.execute(q).fetchone()
return int(row[0]) if row else 0
# -- Conversation search ---------------------------------------------------
def search_history(
self,
query: str,
limit: int = 20,
offset: int = 0,
*,
user_id: str | None = None,
exclude_ws_id: str | None = None,
exclude_after: int | None = None,
) -> list[Any]:
if not query or not query.strip():
return []
capped = min(int(limit), 100)
capped_offset = max(0, int(offset))
# Project-tenancy scope (see HISTORY_VISIBILITY_SCOPE_SQL) and the
# live-context exclusion (HISTORY_CONTEXT_EXCLUSION_SQL): applied in
# SQL, not post-filtered in Python, so limit/offset pagination stays
# honest — a page never silently shrinks because hidden rows were
# fetched then dropped.
scope_sql = _HISTORY_CREATING_EXCL_SQL
if user_id is not None:
scope_sql += _HISTORY_SCOPE_SQL
scope_params: dict[str, Any] = {"scope_user": user_id} if user_id is not None else {}
if exclude_ws_id is not None:
scope_sql += _HISTORY_EXCL_SQL
# exclude_after=None → never compacted → the whole ws is live
# context; ids start at 1, so -1 excludes every row.
scope_params["excl_ws"] = exclude_ws_id
scope_params["excl_after"] = -1 if exclude_after is None else exclude_after
with self._conn() as conn:
# Full-text search over an inline tsvector (there is no indexed
# search_vector column). The input is capped — see
# _FTS_INPUT_CAP_CHARS — so a single giant row (multi-MB tool
# dumps exist) cannot trip PostgreSQL's 1MB tsvector limit and
# abort every search; oversized rows stay findable by their head.
try:
return list(
conn.execute(
sa.text(
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
"FROM conversations c "
"WHERE to_tsvector('english', left(COALESCE(c.content, ''), :fts_cap)) "
" @@ plainto_tsquery('english', :query) "
# Exclude compaction-checkpoint markers (resume-only
# summary artifacts); IS DISTINCT FROM is NULL-safe so
# normal rows (_source NULL) are not dropped.
"AND c._source IS DISTINCT FROM :compaction_source "
+ scope_sql
+ "ORDER BY ts_rank(to_tsvector('english', left(COALESCE(c.content, ''), :fts_cap)), "
" plainto_tsquery('english', :query)) DESC "
"LIMIT :limit OFFSET :offset"
),
{
"query": query,
"fts_cap": _FTS_INPUT_CAP_CHARS,
"compaction_source": _COMPACTION_SOURCE,
"limit": capped,
"offset": capped_offset,
**scope_params,
},
).fetchall()
)
except Exception:
# The failed statement aborted the connection's autobegun
# transaction; PostgreSQL then refuses every command until a
# rollback, so without this the fallback can never run
# (InFailedSqlTransaction).
conn.rollback()
# Fallback to ILIKE
return list(
conn.execute(
sa.text(
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
"FROM conversations c WHERE c.content ILIKE :pattern "
"AND c._source IS DISTINCT FROM :compaction_source "
+ scope_sql
+ "ORDER BY c.timestamp DESC LIMIT :limit OFFSET :offset"
),
{
"pattern": f"%{query}%",
"compaction_source": _COMPACTION_SOURCE,
"limit": capped,
"offset": capped_offset,
**scope_params,
},
).fetchall()
)
def search_history_recent(self, limit: int = 20, *, user_id: str | None = None) -> list[Any]:
capped = min(limit, 100)
scope_sql = _HISTORY_CREATING_EXCL_SQL
if user_id is not None:
scope_sql += _HISTORY_SCOPE_SQL
scope_params = {"scope_user": user_id} if user_id is not None else {}
with self._conn() as conn:
return list(
conn.execute(
sa.text(
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
"FROM conversations c "
"WHERE c._source IS DISTINCT FROM :compaction_source "
+ scope_sql
+ "ORDER BY c.timestamp DESC LIMIT :limit"
),
{"limit": capped, "compaction_source": _COMPACTION_SOURCE, **scope_params},
).fetchall()
)
# -- User identity operations -----------------------------------------------
def create_user(
self, user_id: str, username: str, display_name: str, password_hash: str
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(users.c.user_id).where(users.c.user_id == user_id)
).fetchone()
if not existing:
conn.execute(
sa.insert(users),
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": password_hash,
"created": now,
},
)
conn.commit()
def create_first_user(
self, user_id: str, username: str, display_name: str, password_hash: str
) -> bool:
"""Atomically create a user only if no users exist. Returns True if created."""
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.text(
"INSERT INTO users (user_id, username, display_name, password_hash, created) "
"SELECT :user_id, :username, :display_name, :password_hash, :created "
"WHERE NOT EXISTS (SELECT 1 FROM users)"
),
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": password_hash,
"created": now,
},
)
conn.commit()
return result.rowcount > 0
def get_user(self, user_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
users.c.user_id,
users.c.username,
users.c.display_name,
users.c.password_hash,
users.c.created,
).where(users.c.user_id == user_id)
).fetchone()
if row:
return {
"user_id": row[0],
"username": row[1],
"display_name": row[2],
"password_hash": row[3],
"created": row[4],
}
return None
def get_user_by_username(self, username: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
users.c.user_id,
users.c.username,
users.c.display_name,
users.c.password_hash,
users.c.created,
).where(users.c.username == username)
).fetchone()
if row:
return {
"user_id": row[0],
"username": row[1],
"display_name": row[2],
"password_hash": row[3],
"created": row[4],
}
return None
def list_users(self) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
users.c.user_id,
users.c.username,
users.c.display_name,
users.c.created,
).order_by(users.c.created.desc())
).fetchall()
return [
{"user_id": r[0], "username": r[1], "display_name": r[2], "created": r[3]}
for r in rows
]
def count_users(self) -> int:
with self._conn() as conn:
n = conn.execute(sa.select(sa.func.count()).select_from(users)).scalar()
return int(n or 0)
def find_existing_usernames(self, candidates: list[str]) -> set[str]:
if not candidates:
return set()
with self._conn() as conn:
rows = conn.execute(
sa.select(users.c.username).where(users.c.username.in_(candidates))
).fetchall()
return {r[0] for r in rows}
def delete_user(self, user_id: str) -> bool:
with self._conn() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
conn.execute(
sa.delete(oidc_user_credentials).where(oidc_user_credentials.c.user_id == user_id)
)
conn.execute(sa.delete(mcp_user_tokens).where(mcp_user_tokens.c.user_id == user_id))
conn.execute(sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.user_id == user_id))
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
conn.commit()
return result.rowcount > 0
def create_api_token(
self,
token_id: str,
token_hash: str,
token_prefix: str,
user_id: str,
name: str,
scopes: str,
expires: str | None = None,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(api_tokens),
{
"token_id": token_id,
"token_hash": token_hash,
"token_prefix": token_prefix,
"user_id": user_id,
"name": name,
"scopes": scopes,
"created": now,
"expires": expires,
},
)
conn.commit()
def get_api_token_by_hash(self, token_hash: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
api_tokens.c.token_id,
api_tokens.c.token_prefix,
api_tokens.c.user_id,
api_tokens.c.name,
api_tokens.c.scopes,
api_tokens.c.created,
api_tokens.c.expires,
).where(api_tokens.c.token_hash == token_hash)
).fetchone()
if row:
result: dict[str, str] = {
"token_id": row[0],
"token_prefix": row[1],
"user_id": row[2],
"name": row[3],
"scopes": row[4],
"created": row[5],
}
if row[6] is not None:
result["expires"] = row[6]
return result
return None
def list_api_tokens(self, user_id: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
api_tokens.c.token_id,
api_tokens.c.token_prefix,
api_tokens.c.user_id,
api_tokens.c.name,
api_tokens.c.scopes,
api_tokens.c.created,
api_tokens.c.expires,
)
.where(api_tokens.c.user_id == user_id)
.order_by(api_tokens.c.created.desc())
).fetchall()
result = []
for r in rows:
entry: dict[str, str] = {
"token_id": r[0],
"token_prefix": r[1],
"user_id": r[2],
"name": r[3],
"scopes": r[4],
"created": r[5],
}
if r[6] is not None:
entry["expires"] = r[6]
result.append(entry)
return result
def delete_api_token(self, token_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(sa.delete(api_tokens).where(api_tokens.c.token_id == token_id))
conn.commit()
return result.rowcount > 0
# -- Channel user mapping ---------------------------------------------------
def create_channel_user(self, channel_type: str, channel_user_id: str, user_id: str) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(channel_users)
.values(
channel_type=channel_type,
channel_user_id=channel_user_id,
user_id=user_id,
created=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_channel_user(self, channel_type: str, channel_user_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
channel_users.c.channel_type,
channel_users.c.channel_user_id,
channel_users.c.user_id,
channel_users.c.created,
).where(
(channel_users.c.channel_type == channel_type)
& (channel_users.c.channel_user_id == channel_user_id)
)
).fetchone()
if row:
return {
"channel_type": row[0],
"channel_user_id": row[1],
"user_id": row[2],
"created": row[3],
}
return None
def list_channel_users_by_user(self, user_id: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
channel_users.c.channel_type,
channel_users.c.channel_user_id,
channel_users.c.user_id,
channel_users.c.created,
)
.where(channel_users.c.user_id == user_id)
.order_by(channel_users.c.created.desc())
).fetchall()
return [
{
"channel_type": r[0],
"channel_user_id": r[1],
"user_id": r[2],
"created": r[3],
}
for r in rows
]
def delete_channel_user(self, channel_type: str, channel_user_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(channel_users).where(
(channel_users.c.channel_type == channel_type)
& (channel_users.c.channel_user_id == channel_user_id)
)
)
conn.commit()
return result.rowcount > 0
# -- Channel routing -------------------------------------------------------
def create_channel_route(
self, channel_type: str, channel_id: str, ws_id: str, node_id: str = ""
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(channel_routes)
.values(
channel_type=channel_type,
channel_id=channel_id,
ws_id=ws_id,
node_id=node_id,
created=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_channel_route(self, channel_type: str, channel_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
channel_routes.c.channel_type,
channel_routes.c.channel_id,
channel_routes.c.ws_id,
channel_routes.c.node_id,
channel_routes.c.created,
).where(
(channel_routes.c.channel_type == channel_type)
& (channel_routes.c.channel_id == channel_id)
)
).fetchone()
if row:
return {
"channel_type": row[0],
"channel_id": row[1],
"ws_id": row[2],
"node_id": row[3],
"created": row[4],
}
return None
def get_channel_route_by_ws(self, ws_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
channel_routes.c.channel_type,
channel_routes.c.channel_id,
channel_routes.c.ws_id,
channel_routes.c.node_id,
channel_routes.c.created,
).where(channel_routes.c.ws_id == ws_id)
).fetchone()
if row:
return {
"channel_type": row[0],
"channel_id": row[1],
"ws_id": row[2],
"node_id": row[3],
"created": row[4],
}
return None
def list_channel_routes_by_type(self, channel_type: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
channel_routes.c.channel_type,
channel_routes.c.channel_id,
channel_routes.c.ws_id,
channel_routes.c.node_id,
channel_routes.c.created,
)
.where(channel_routes.c.channel_type == channel_type)
.order_by(channel_routes.c.created.desc())
).fetchall()
return [
{
"channel_type": r[0],
"channel_id": r[1],
"ws_id": r[2],
"node_id": r[3],
"created": r[4],
}
for r in rows
]
def delete_channel_route(self, channel_type: str, channel_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(channel_routes).where(
(channel_routes.c.channel_type == channel_type)
& (channel_routes.c.channel_id == channel_id)
)
)
conn.commit()
return result.rowcount > 0
# -- Scheduled tasks -------------------------------------------------------
def create_scheduled_task(
self,
task_id: str,
name: str,
description: str,
schedule_type: str,
cron_expr: str,
at_time: str,
target_mode: str,
model: str,
initial_message: str,
auto_approve: bool,
auto_approve_tools: list[str],
created_by: str,
next_run: str,
skill: str = "",
notify_targets: str = "[]",
persona: str = "",
project_id: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(scheduled_tasks)
.values(
task_id=task_id,
name=name,
description=description,
schedule_type=schedule_type,
cron_expr=cron_expr,
at_time=at_time,
target_mode=target_mode,
model=model,
initial_message=initial_message,
auto_approve=1 if auto_approve else 0,
auto_approve_tools=",".join(auto_approve_tools),
skill=skill,
persona=persona,
project_id=project_id,
notify_targets=notify_targets,
enabled=1,
created_by=created_by,
next_run=next_run,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_scheduled_task(self, task_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(scheduled_tasks).where(scheduled_tasks.c.task_id == task_id)
).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_scheduled_tasks(self) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(scheduled_tasks).order_by(scheduled_tasks.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
_UPDATABLE_TASK_FIELDS = frozenset(
{
"name",
"description",
"schedule_type",
"cron_expr",
"at_time",
"target_mode",
"model",
"initial_message",
"auto_approve",
"auto_approve_tools",
"skill",
"persona",
"project_id",
"notify_targets",
"enabled",
# created_by is only ever set by the update handler adopting an
# orphaned (pre-fix "") schedule's owner from auth_result — never
# sourced from the request body, so this is not a spoofing surface.
"created_by",
"last_run",
"next_run",
"updated",
}
)
def update_scheduled_task(self, task_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in self._UPDATABLE_TASK_FIELDS}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "auto_approve" in fields:
fields["auto_approve"] = 1 if fields["auto_approve"] else 0
if "auto_approve_tools" in fields and isinstance(fields["auto_approve_tools"], list):
fields["auto_approve_tools"] = ",".join(fields["auto_approve_tools"])
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
with self._conn() as conn:
result = conn.execute(
sa.update(scheduled_tasks)
.where(scheduled_tasks.c.task_id == task_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_scheduled_task(self, task_id: str) -> bool:
with self._conn() as conn:
conn.execute(
sa.delete(scheduled_task_runs).where(scheduled_task_runs.c.task_id == task_id)
)
result = conn.execute(
sa.delete(scheduled_tasks).where(scheduled_tasks.c.task_id == task_id)
)
conn.commit()
return result.rowcount > 0
def list_due_tasks(self, now: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(scheduled_tasks)
.where(
(scheduled_tasks.c.enabled == 1)
& (scheduled_tasks.c.next_run <= now)
& (scheduled_tasks.c.next_run != "")
)
.order_by(scheduled_tasks.c.next_run)
.limit(100)
).fetchall()
return [dict(r._mapping) for r in rows]
def record_task_run(
self,
run_id: str,
task_id: str,
node_id: str,
ws_id: str,
correlation_id: str,
started: str,
status: str,
error: str,
) -> None:
with self._conn() as conn:
conn.execute(
sa.insert(scheduled_task_runs),
{
"run_id": run_id,
"task_id": task_id,
"node_id": node_id,
"ws_id": ws_id,
"correlation_id": correlation_id,
"started": started,
"status": status,
"error": error,
},
)
conn.commit()
def list_task_runs(self, task_id: str, limit: int = 50) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(scheduled_task_runs)
.where(scheduled_task_runs.c.task_id == task_id)
.order_by(scheduled_task_runs.c.started.desc())
.limit(limit)
).fetchall()
return [dict(r._mapping) for r in rows]
def prune_task_runs(self, retention_days: int = 90) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.delete(scheduled_task_runs).where(scheduled_task_runs.c.started < cutoff)
)
conn.commit()
return result.rowcount
# -- Watches ---------------------------------------------------------------
def create_watch(
self,
watch_id: str,
ws_id: str,
node_id: str,
name: str,
command: str,
interval_secs: float,
stop_on: str | None,
max_polls: int,
created_by: str,
next_poll: str,
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(watches)
.values(
watch_id=watch_id,
ws_id=ws_id,
node_id=node_id,
name=name,
command=command,
interval_secs=interval_secs,
stop_on=stop_on,
max_polls=max_polls,
poll_count=0,
active=1,
created_by=created_by,
next_poll=next_poll,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_watch(self, watch_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(watches).where(watches.c.watch_id == watch_id)).fetchone()
if row is None:
return None
return dict(row._mapping)
def is_watch_active(self, watch_id: str) -> bool:
with self._conn() as conn:
row = conn.execute(
sa.select(watches.c.active).where(watches.c.watch_id == watch_id)
).fetchone()
if row is None:
return False
return bool(row[0])
def list_watches_for_ws(self, ws_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.ws_id == ws_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
if not name_or_prefix:
return None
like_pattern = _escape_like(name_or_prefix) + "%"
with self._conn() as conn:
row = conn.execute(
sa.select(watches)
.where(
(watches.c.ws_id == ws_id)
& (
(watches.c.name == name_or_prefix)
| watches.c.watch_id.like(like_pattern, escape=_LIKE_ESCAPE)
)
)
# Active rows win over inactive ones with the same name.
# _prepare_watch's duplicate-name guard filters active=1,
# so a model can recreate a name after the previous one
# auto-cancelled; a cancel-by-name request on the live
# row must not be shadowed by the older completed row.
.order_by(watches.c.active.desc(), watches.c.created.desc())
.limit(1)
).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(watches)
.where((watches.c.node_id == node_id) & (watches.c.active == 1))
.order_by(watches.c.created.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def list_due_watches(self, now: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(watches)
.where(
(watches.c.active == 1)
& (watches.c.next_poll <= now)
& (watches.c.next_poll != "")
)
.order_by(watches.c.next_poll)
.limit(100)
).fetchall()
return [dict(r._mapping) for r in rows]
_UPDATABLE_WATCH_FIELDS = frozenset(
{
"name",
"poll_count",
"last_output",
"last_exit_code",
"last_poll",
"next_poll",
"active",
"updated",
}
)
def update_watch(self, watch_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in self._UPDATABLE_WATCH_FIELDS}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "active" in fields:
fields["active"] = 1 if fields["active"] else 0
with self._conn() as conn:
result = conn.execute(
sa.update(watches).where(watches.c.watch_id == watch_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_watch(self, watch_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.watch_id == watch_id))
conn.commit()
return result.rowcount > 0
def delete_watches_for_ws(self, ws_id: str) -> int:
with self._conn() as conn:
result = conn.execute(sa.delete(watches).where(watches.c.ws_id == ws_id))
conn.commit()
return result.rowcount
# -- Service registry ------------------------------------------------------
def register_service(
self, service_type: str, service_id: str, url: str, metadata: str = "{}"
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(services).values(
service_type=service_type,
service_id=service_id,
url=url,
metadata=metadata,
last_heartbeat=now,
created=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[services.c.service_type, services.c.service_id],
set_={"url": url, "metadata": metadata, "last_heartbeat": now},
)
conn.execute(stmt)
conn.commit()
def heartbeat_service(self, service_type: str, service_id: str) -> bool:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(services)
.where(
(services.c.service_type == service_type)
& (services.c.service_id == service_id)
)
.values(last_heartbeat=now)
)
conn.commit()
return result.rowcount > 0
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
rows = conn.execute(
sa.select(services)
.where(
(services.c.service_type == service_type)
& (services.c.last_heartbeat >= cutoff)
)
.order_by(services.c.last_heartbeat.desc())
).fetchall()
return [dict(r._mapping) for r in rows]
def deregister_service(self, service_type: str, service_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(services).where(
(services.c.service_type == service_type)
& (services.c.service_id == service_id)
)
)
conn.commit()
return result.rowcount > 0
# -- Cross-process notifications -------------------------------------------
def notify(self, channel: str, payload: str = "") -> None:
"""Broadcast a wake-up via ``pg_notify`` on a pooled connection.
``channel`` and ``payload`` are bound as parameters so this is
safe to call with operator-supplied strings without quoting
gymnastics. Postgres caps the payload at 8 KiB — keep payloads
signal-only (a JSON id list, an op name) and let consumers
re-read the underlying rows on wake-up.
"""
with self._conn() as conn:
conn.execute(
sa.text("SELECT pg_notify(:channel, :payload)"),
{"channel": channel, "payload": payload},
)
conn.commit()
@contextlib.contextmanager
def listen(self, channels: Iterable[str]) -> Iterator[NotifyStream]:
"""Subscribe to channels on a dedicated session-mode connection.
Opens a fresh ``psycopg`` connection in autocommit mode (the
SQLAlchemy pool is incompatible with LISTEN — it recycles
connections back into a pool that may be transaction-pooled by
pgbouncer). Channel names are interpolated via
``psycopg.sql.Identifier`` so caller-supplied channel strings
can't inject SQL.
``TURNSTONE_DB_LISTEN_URL`` overrides the engine URL — see
:func:`_resolve_pg_listen_url` for the bypass-URL rationale.
Yields a :class:`_PostgreSQLNotifyStream`; the connection is
closed on context exit.
"""
import psycopg
from psycopg import sql
ch_list = [str(c) for c in channels if c]
sqlalchemy_url = self._engine.url.render_as_string(hide_password=False)
listen_url = _resolve_pg_listen_url(self._listen_url_override, sqlalchemy_url)
conn = psycopg.connect(listen_url, autocommit=True)
stream: _PostgreSQLNotifyStream | None = None
try:
for ch in ch_list:
conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(ch)))
stream = _PostgreSQLNotifyStream(conn, ch_list)
yield stream
finally:
if stream is not None:
stream.close()
else:
with contextlib.suppress(Exception):
conn.close()
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata)
.where(node_metadata.c.node_id == node_id)
.order_by(node_metadata.c.key)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_all_node_metadata(self) -> dict[str, list[dict[str, Any]]]:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
rows = conn.execute(
sa.select(node_metadata).order_by(node_metadata.c.node_id, node_metadata.c.key)
).fetchall()
result: dict[str, list[dict[str, Any]]] = {}
for r in rows:
d = dict(r._mapping)
result.setdefault(d["node_id"], []).append(d)
return result
def set_node_metadata(self, node_id: str, key: str, value: str, source: str = "user") -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[node_metadata.c.node_id, node_metadata.c.key],
set_={"value": value, "source": source, "updated": now},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def set_node_metadata_bulk(self, node_id: str, entries: list[tuple[str, str, str]]) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
from turnstone.core.storage._schema import node_metadata
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
for key, value, source in entries:
stmt = pg_insert(node_metadata).values(
node_id=node_id,
key=key,
value=value,
source=source,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[node_metadata.c.node_id, node_metadata.c.key],
set_={"value": value, "source": source, "updated": now},
)
conn.execute(stmt)
conn.commit()
def delete_node_metadata(self, node_id: str, key: str) -> bool:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.key == key)
)
)
conn.commit()
return result.rowcount > 0
def delete_node_metadata_by_source(self, node_id: str, source: str) -> int:
from turnstone.core.storage._schema import node_metadata
with self._conn() as conn:
result = conn.execute(
sa.delete(node_metadata).where(
(node_metadata.c.node_id == node_id) & (node_metadata.c.source == source)
)
)
conn.commit()
return result.rowcount
def filter_nodes_by_metadata(self, filters: dict[str, str]) -> set[str]:
from turnstone.core.storage._schema import node_metadata
if not filters:
return set()
conditions = [
sa.and_(node_metadata.c.key == k, node_metadata.c.value == v)
for k, v in filters.items()
]
stmt = (
sa.select(node_metadata.c.node_id)
.where(sa.or_(*conditions))
.group_by(node_metadata.c.node_id)
.having(sa.func.count() == len(filters))
)
with self._conn() as conn:
rows = conn.execute(stmt).fetchall()
return {r[0] for r in rows}
# -- Routing overrides -----------------------------------------------------
def set_workstream_override(self, ws_id: str, node_id: str, reason: str = "targeted") -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(workstream_overrides).values(
ws_id=ws_id, node_id=node_id, reason=reason, created=now, updated=now
)
stmt = stmt.on_conflict_do_update(
index_elements=[workstream_overrides.c.ws_id],
set_={"node_id": node_id, "reason": reason, "updated": now},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def delete_workstream_override(self, ws_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(workstream_overrides).where(workstream_overrides.c.ws_id == ws_id)
)
conn.commit()
return result.rowcount > 0
def list_workstream_overrides(self) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_overrides).order_by(workstream_overrides.c.ws_id)
).fetchall()
return [dict(r._mapping) for r in rows]
# -- Roles -----------------------------------------------------------------
def create_role(
self,
role_id: str,
name: str,
display_name: str,
permissions: str,
builtin: bool,
org_id: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(roles.c.role_id).where(roles.c.role_id == role_id)
).fetchone()
if not existing:
conn.execute(
sa.insert(roles),
{
"role_id": role_id,
"name": name,
"display_name": display_name,
"permissions": permissions,
"builtin": 1 if builtin else 0,
"org_id": org_id,
"created": now,
"updated": now,
},
)
conn.commit()
def get_role(self, role_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
if row:
return _row_to_dict(row, "builtin")
return None
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(roles).order_by(roles.c.name.asc())
if org_id:
q = q.where(roles.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "builtin") for r in rows]
def update_role(self, role_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ROLE_MUTABLE
if dropped:
log.warning("update_role: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_role(self, role_id: str) -> bool:
with self._conn() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
# No FK on role_permission_overrides (migration 057 omitted
# to match the rest of the governance schema), so clean up
# by hand. Orphan rows would otherwise apply silently if
# a role_id were ever reused — deterministic for builtins
# on schema reseed.
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
conn.commit()
return result.rowcount > 0
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(user_roles.c.user_id).where(
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
)
).fetchone()
if not existing:
conn.execute(
sa.insert(user_roles),
{
"user_id": user_id,
"role_id": role_id,
"assigned_by": assigned_by,
"created": now,
},
)
conn.commit()
def unassign_role(self, user_id: str, role_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(user_roles).where(
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
)
)
conn.commit()
return result.rowcount > 0
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
roles.c.role_id,
roles.c.name,
roles.c.display_name,
roles.c.permissions,
roles.c.builtin,
roles.c.org_id,
roles.c.created,
roles.c.updated,
user_roles.c.assigned_by,
user_roles.c.created.label("assignment_created"),
)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
return [_row_to_dict(r, "builtin") for r in rows]
def replace_oidc_roles(
self, user_id: str, desired_role_ids: set[str]
) -> tuple[set[str], set[str]]:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
# `with_for_update()` takes per-row locks on this user's
# `user_roles` rows for the duration of the transaction.
# Two concurrent OIDC callbacks for the same `user_id` (e.g.
# racing token refreshes with differing claim sets) would
# otherwise both read the same baseline under READ COMMITTED
# and produce a final role state matching neither caller's
# intent. The lock is per-`user_id`, so unrelated user writes
# are unaffected.
#
# Note: FOR UPDATE on an empty result set acquires no locks,
# so on a brand-new user with no rows yet, two concurrent
# callers can proceed in parallel; their inserts merge via
# ON CONFLICT DO NOTHING (final state is the union of the
# two desired sets). The next single-caller reconciliation
# cycle self-heals.
existing_rows = conn.execute(
sa.select(user_roles.c.role_id, user_roles.c.assigned_by)
.where(user_roles.c.user_id == user_id)
.with_for_update()
).fetchall()
current_oidc: set[str] = {r[0] for r in existing_rows if r[1] == "oidc"}
# Roles assigned by any other source (admin-ui, oidc-default, etc.)
# are off-limits to OIDC reconciliation per apply_role_mapping's contract.
blocked: set[str] = {r[0] for r in existing_rows if r[1] != "oidc"}
effective_desired = desired_role_ids - blocked
added = effective_desired - current_oidc
removed = current_oidc - effective_desired
if added:
conn.execute(
pg_insert(user_roles).on_conflict_do_nothing(
index_elements=["user_id", "role_id"]
),
[
{
"user_id": user_id,
"role_id": role_id,
"assigned_by": "oidc",
"created": now,
}
for role_id in added
],
)
if removed:
conn.execute(
sa.delete(user_roles).where(
(user_roles.c.user_id == user_id)
& (user_roles.c.assigned_by == "oidc")
& (user_roles.c.role_id.in_(removed))
)
)
conn.commit()
return added, removed
def get_user_permissions(self, user_id: str) -> set[str]:
with self._conn() as conn:
role_rows = conn.execute(
sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
if not role_rows:
return set()
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
perms: set[str] = set()
for rid, perms_str, builtin in role_rows:
role_perms = _split_perms(perms_str)
if builtin:
role_perms = (role_perms | grants.get(rid, set())) - revokes.get(rid, set())
perms |= role_perms
return perms
def users_with_permission(
self,
permission: str,
*,
exclude_role_id: str | None = None,
) -> set[str]:
with self._conn() as conn:
q = sa.select(
user_roles.c.user_id,
user_roles.c.role_id,
roles.c.permissions,
roles.c.builtin,
).select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
if exclude_role_id:
q = q.where(user_roles.c.role_id != exclude_role_id)
rows = conn.execute(q).fetchall()
if not rows:
return set()
builtin_role_ids = {r[1] for r in rows if r[3]}
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
holders: set[str] = set()
for user_id, role_id, perms_str, builtin in rows:
eff = _split_perms(perms_str)
if builtin:
eff = (eff | grants.get(role_id, set())) - revokes.get(role_id, set())
if permission in eff:
holders.add(user_id)
return holders
def list_role_overrides(self, role_id: str) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(role_permission_overrides)
.where(role_permission_overrides.c.role_id == role_id)
.order_by(
role_permission_overrides.c.action,
role_permission_overrides.c.permission,
)
).fetchall()
return [dict(r._mapping) for r in rows]
def set_role_overrides(
self,
role_id: str,
grants: set[str],
revokes: set[str],
created_by: str = "",
) -> None:
if grants & revokes:
raise ValueError("grants and revokes must be disjoint")
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
rows = [
{
"role_id": role_id,
"permission": p,
"action": "grant",
"created": now,
"created_by": created_by,
}
for p in sorted(grants)
] + [
{
"role_id": role_id,
"permission": p,
"action": "revoke",
"created": now,
"created_by": created_by,
}
for p in sorted(revokes)
]
if rows:
conn.execute(sa.insert(role_permission_overrides), rows)
conn.commit()
def clear_role_overrides(self, role_id: str) -> None:
with self._conn() as conn:
conn.execute(
sa.delete(role_permission_overrides).where(
role_permission_overrides.c.role_id == role_id
)
)
conn.commit()
def effective_role_permissions(self, role_id: str) -> dict[str, list[str]]:
with self._conn() as conn:
role_row = conn.execute(
sa.select(roles.c.permissions, roles.c.builtin).where(roles.c.role_id == role_id)
).fetchone()
if role_row is None:
return {"baseline": [], "grants": [], "revokes": [], "effective": []}
baseline = _split_perms(role_row[0])
grants: set[str] = set()
revokes: set[str] = set()
if role_row[1]:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id == role_id)
).fetchall()
for perm, action in ov_rows:
if action == "grant":
grants.add(perm)
elif action == "revoke":
revokes.add(perm)
effective = (baseline | grants) - revokes
return {
"baseline": sorted(baseline),
"grants": sorted(grants),
"revokes": sorted(revokes),
"effective": sorted(effective),
}
def effective_role_permissions_bulk(
self, role_ids: list[str]
) -> dict[str, dict[str, list[str]]]:
if not role_ids:
return {}
with self._conn() as conn:
role_rows = conn.execute(
sa.select(roles.c.role_id, roles.c.permissions, roles.c.builtin).where(
roles.c.role_id.in_(role_ids)
)
).fetchall()
if not role_rows:
return {}
builtin_role_ids = [r[0] for r in role_rows if r[2]]
grants: dict[str, set[str]] = {}
revokes: dict[str, set[str]] = {}
if builtin_role_ids:
ov_rows = conn.execute(
sa.select(
role_permission_overrides.c.role_id,
role_permission_overrides.c.permission,
role_permission_overrides.c.action,
).where(role_permission_overrides.c.role_id.in_(builtin_role_ids))
).fetchall()
for rid, perm, action in ov_rows:
if action == "grant":
grants.setdefault(rid, set()).add(perm)
elif action == "revoke":
revokes.setdefault(rid, set()).add(perm)
out: dict[str, dict[str, list[str]]] = {}
for rid, perms_str, builtin in role_rows:
baseline = _split_perms(perms_str)
role_grants = grants.get(rid, set()) if builtin else set()
role_revokes = revokes.get(rid, set()) if builtin else set()
effective = (baseline | role_grants) - role_revokes
out[rid] = {
"baseline": sorted(baseline),
"grants": sorted(role_grants),
"revokes": sorted(role_revokes),
"effective": sorted(effective),
}
return out
# -- Organizations ---------------------------------------------------------
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(orgs.c.org_id).where(orgs.c.org_id == org_id)
).fetchone()
if not existing:
conn.execute(
sa.insert(orgs),
{
"org_id": org_id,
"name": name,
"display_name": display_name,
"settings": settings,
"created": now,
"updated": now,
},
)
conn.commit()
def get_org(self, org_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
if row:
return _row_to_dict(row)
return None
def list_orgs(self) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
return [_row_to_dict(r) for r in rows]
def update_org(self, org_id: str, **fields: Any) -> bool:
dropped = set(fields) - _ORG_MUTABLE
if dropped:
log.warning("update_org: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
conn.commit()
return result.rowcount > 0
# -- Tool policies ---------------------------------------------------------
def create_tool_policy(
self,
policy_id: str,
name: str,
tool_pattern: str,
action: str,
priority: int,
org_id: str = "",
enabled: bool = True,
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(tool_policies),
{
"policy_id": policy_id,
"name": name,
"tool_pattern": tool_pattern,
"action": action,
"priority": priority,
"org_id": org_id,
"enabled": 1 if enabled else 0,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
# Drop both the org-specific slot AND the default ``""`` slot.
# ``list_tool_policies("")`` returns rows for every org_id (no
# WHERE filter when org_id is falsy), and the default
# evaluators (``SessionUIBase.approve_tools`` / ``cli.py``) use
# ``org_id=""``, so an org-scoped insert that only invalidated
# the org slot would leave the default slot serving stale data
# until the TTL window expired.
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache(org_id)
if org_id != "":
invalidate_policy_cache("")
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
).fetchone()
if row:
return _row_to_dict(row, "enabled")
return None
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
if org_id:
q = q.where(tool_policies.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
dropped = set(fields) - _POLICY_MUTABLE
if dropped:
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
with self._conn() as conn:
result = conn.execute(
sa.update(tool_policies)
.where(tool_policies.c.policy_id == policy_id)
.values(**fields)
)
conn.commit()
updated = result.rowcount > 0
if updated:
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache()
return updated
def delete_tool_policy(self, policy_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
)
conn.commit()
deleted = result.rowcount > 0
if deleted:
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache()
return deleted
# -- Prompt templates ------------------------------------------------------
def create_prompt_template(
self,
template_id: str,
name: str,
category: str,
content: str,
variables: str = "[]",
is_default: bool = False,
org_id: str = "",
created_by: str = "",
origin: str = "manual",
mcp_server: str = "",
readonly: bool = False,
description: str = "",
tags: str = "[]",
source_url: str = "",
version: str = "1.0.0",
author: str = "",
activation: str = "named",
token_estimate: int = 0,
model: str = "",
auto_approve: bool = False,
temperature: float | None = None,
reasoning_effort: str = "",
max_tokens: int | None = None,
token_budget: int = 0,
agent_max_turns: int | None = None,
notify_on_complete: str = "[]",
enabled: bool = True,
allowed_tools: str = "[]",
skill_license: str = "",
compatibility: str = "",
priority: int = 0,
kind: str = "any",
paths: str = "[]",
hidden_from_menu: bool = False,
arguments: str = "[]",
argument_hint: str = "",
) -> None:
# Sync is_default from activation when activation is explicitly set
if activation == "default":
is_default = True
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
# Scan skill content for risk signals
risk_level, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
from turnstone.core.storage._protocol import StorageConflictError
with self._conn() as conn:
try:
conn.execute(
sa.insert(prompt_templates),
{
"template_id": template_id,
"name": name,
"category": category,
"content": content,
"variables": variables,
"is_default": 1 if is_default else 0,
"org_id": org_id,
"created_by": created_by,
"origin": origin,
"mcp_server": mcp_server,
"readonly": 1 if readonly else 0,
"description": description,
"tags": tags,
"source_url": source_url,
"version": version,
"author": author,
"activation": activation,
"token_estimate": token_estimate,
"allowed_tools": allowed_tools,
"license": skill_license,
"compatibility": compatibility,
"kind": kind,
"risk_level": risk_level,
"scan_report": scan_report,
"scan_version": scan_version,
"model": model,
"auto_approve": 1 if auto_approve else 0,
"temperature": temperature,
"reasoning_effort": reasoning_effort,
"max_tokens": max_tokens,
"token_budget": token_budget,
"agent_max_turns": agent_max_turns,
"notify_on_complete": notify_on_complete,
"enabled": 1 if enabled else 0,
"priority": priority,
"paths": paths,
"hidden_from_menu": 1 if hidden_from_menu else 0,
"arguments": arguments,
"argument_hint": argument_hint,
"created": now,
"updated": now,
},
)
except sa.exc.IntegrityError as exc:
conn.rollback()
msg = str(exc.orig) if exc.orig is not None else str(exc)
raise StorageConflictError(
f"prompt_template conflict ({template_id}/{name}): {msg}"
) from exc
conn.commit()
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
).fetchone()
if row:
return _row_to_dict(
row, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
return None
def get_prompt_template_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.name == name)
).fetchone()
if row:
return _row_to_dict(
row, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
return None
def list_prompt_templates(
self, org_id: str = "", limit: int = 0, offset: int = 0
) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
if org_id:
q = q.where(prompt_templates.c.org_id == org_id)
if offset > 0:
q = q.offset(offset)
if limit > 0:
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(
r, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
for r in rows
]
def count_prompt_templates(self, org_id: str = "") -> int:
with self._conn() as conn:
q = sa.select(sa.func.count()).select_from(prompt_templates)
if org_id:
q = q.where(prompt_templates.c.org_id == org_id)
return conn.execute(q).scalar() or 0
def list_default_templates(self, org_id: str = "") -> list[dict[str, Any]]:
with self._conn() as conn:
q = (
sa.select(prompt_templates)
.where(prompt_templates.c.is_default == 1)
.where(prompt_templates.c.enabled == 1)
.order_by(prompt_templates.c.priority, prompt_templates.c.name)
)
if org_id:
q = q.where(prompt_templates.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(
r, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
for r in rows
]
def list_prompt_templates_by_origin(self, origin: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(prompt_templates)
.where(prompt_templates.c.origin == origin)
.order_by(prompt_templates.c.name)
).fetchall()
return [
_row_to_dict(
r, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
for r in rows
]
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
dropped = set(fields) - _SKILL_MUTABLE
if dropped:
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _SKILL_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "is_default" in fields:
fields["is_default"] = int(fields["is_default"])
# Keep activation and is_default in sync
if "activation" in fields and "is_default" not in fields:
fields["is_default"] = 1 if fields["activation"] == "default" else 0
if "is_default" in fields and "activation" not in fields:
fields["activation"] = "default" if fields["is_default"] else "named"
if "auto_approve" in fields:
fields["auto_approve"] = int(fields["auto_approve"])
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
if "hidden_from_menu" in fields:
fields["hidden_from_menu"] = int(fields["hidden_from_menu"])
# Re-scan if content or allowed_tools changed
if "content" in fields or "allowed_tools" in fields:
content = fields.get("content")
allowed_tools = fields.get("allowed_tools")
if content is None or allowed_tools is None:
existing = self.get_prompt_template(template_id)
if existing is None:
pass # template not found — skip scan, update will be no-op
else:
if content is None:
content = existing.get("content", "")
if allowed_tools is None:
allowed_tools = existing.get("allowed_tools", "[]")
if content is not None:
risk_level, scan_report, scan_version = _scan_skill_content(
content, allowed_tools or "[]"
)
fields["risk_level"] = risk_level
fields["scan_report"] = scan_report
fields["scan_version"] = scan_version
with self._conn() as conn:
result = conn.execute(
sa.update(prompt_templates)
.where(prompt_templates.c.template_id == template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def unlock_skill(self, template_id: str, snapshot: str, changed_by: str) -> int | None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
row = conn.execute(
sa.select(prompt_templates.c.template_id).where(
prompt_templates.c.template_id == template_id
)
).first()
if row is None:
return None
current_max = conn.execute(
sa.select(sa.func.coalesce(sa.func.max(skill_versions.c.version), 0)).where(
skill_versions.c.skill_id == template_id
)
).scalar()
next_version = int(current_max or 0) + 1
conn.execute(
sa.insert(skill_versions),
{
"skill_id": template_id,
"version": next_version,
"snapshot": snapshot,
"changed_by": changed_by,
"created": now,
},
)
conn.execute(
sa.update(prompt_templates)
.where(prompt_templates.c.template_id == template_id)
# readonly is an Integer column (see _schema.py); match the
# 0/1 idiom create_prompt_template uses for the same flag.
.values(readonly=0, updated=now)
)
conn.commit()
return next_version
def delete_prompt_template(self, template_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
)
conn.commit()
return result.rowcount > 0
def list_skills_by_activation(
self,
activation: str,
*,
enabled_only: bool = False,
limit: int = 0,
) -> list[dict[str, Any]]:
with self._conn() as conn:
q = (
sa.select(prompt_templates)
.where(prompt_templates.c.activation == activation)
.order_by(prompt_templates.c.priority, prompt_templates.c.name)
)
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if limit > 0:
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(
r, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
for r in rows
]
def list_skills_filtered(
self,
*,
category: str | None = None,
tag: str | None = None,
risk_level: str | None = None,
kinds: list[str] | None = None,
enabled_only: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(prompt_templates).order_by(
prompt_templates.c.priority, prompt_templates.c.name
)
if category:
q = q.where(prompt_templates.c.category == category)
if risk_level:
q = q.where(prompt_templates.c.risk_level == risk_level)
if kinds:
q = q.where(prompt_templates.c.kind.in_(kinds))
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if tag:
# True JSON-array containment via Postgres'
# ``jsonb_array_elements_text`` lateral expansion.
# Replaces the earlier quote-bracketed ILIKE pattern,
# which broke as soon as a tag value contained a ``"``
# character (or any value the JSON encoder escaped) and
# could be subverted by carefully-crafted neighbouring
# tags. Lateral expansion (vs. the ``?`` operator or
# ``@> '["<tag>"]'::jsonb`` containment) so
# ``lower(jat.elem) = :tag_lower`` runs case-insensitively
# without case-folding the JSON literal at the call
# site. ``tags`` is a TEXT column written as JSON
# text, so cast to JSONB at query time.
q = q.where(
sa.text(
"EXISTS ("
"SELECT 1 FROM jsonb_array_elements_text("
"prompt_templates.tags::jsonb) AS jat(elem) "
"WHERE lower(jat.elem) = :tag_lower)"
).bindparams(tag_lower=tag.lower())
)
if limit > 0:
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(
r, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
for r in rows
]
def get_skill_by_name(self, name: str) -> dict[str, Any] | None:
return self.get_prompt_template_by_name(name)
def get_skill_by_source_url(self, source_url: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.source_url == source_url)
).fetchone()
if row:
return _row_to_dict(
row, "is_default", "readonly", "auto_approve", "enabled", "hidden_from_menu"
)
return None
def list_installed_skill_urls(self) -> list[dict[str, str]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
prompt_templates.c.source_url,
prompt_templates.c.template_id,
prompt_templates.c.risk_level,
).where(prompt_templates.c.source_url != "")
).fetchall()
return [
{
"source_url": r._mapping["source_url"],
"template_id": r._mapping["template_id"],
"risk_level": r._mapping["risk_level"] or "",
}
for r in rows
]
# -- Skill resources -------------------------------------------------------
def create_skill_resource(
self,
resource_id: str,
skill_id: str,
path: str,
content: str,
content_type: str = "text/plain",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(skill_resources),
{
"resource_id": resource_id,
"skill_id": skill_id,
"path": path,
"content": content,
"content_type": content_type,
"created": now,
},
)
conn.commit()
def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(skill_resources)
.where(skill_resources.c.skill_id == skill_id)
.order_by(skill_resources.c.path)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_skill_resource(self, skill_id: str, path: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(skill_resources)
.where(skill_resources.c.skill_id == skill_id)
.where(skill_resources.c.path == path)
).fetchone()
if row:
return dict(row._mapping)
return None
def delete_skill_resources(self, skill_id: str) -> int:
with self._conn() as conn:
result = conn.execute(
sa.delete(skill_resources).where(skill_resources.c.skill_id == skill_id)
)
conn.commit()
return result.rowcount
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(skill_resources).where(
sa.and_(
skill_resources.c.skill_id == skill_id,
skill_resources.c.path == path,
)
)
)
conn.commit()
return result.rowcount > 0
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
if not skill_ids:
return {}
chunk_size = 10_000
result: dict[str, int] = {}
with self._conn() as conn:
for i in range(0, len(skill_ids), chunk_size):
chunk = skill_ids[i : i + chunk_size]
rows = conn.execute(
sa.select(
skill_resources.c.skill_id,
sa.func.count().label("cnt"),
)
.where(skill_resources.c.skill_id.in_(chunk))
.group_by(skill_resources.c.skill_id)
).fetchall()
for r in rows:
result[r[0]] = r[1]
return result
# -- Skill versions --------------------------------------------------------
def create_skill_version(
self,
skill_id: str,
version: int,
snapshot: str,
changed_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(skill_versions),
{
"skill_id": skill_id,
"version": version,
"snapshot": snapshot,
"changed_by": changed_by,
"created": now,
},
)
conn.commit()
def list_skill_versions(self, skill_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(skill_versions)
.where(skill_versions.c.skill_id == skill_id)
.order_by(skill_versions.c.version.desc())
).fetchall()
return [_row_to_dict(r) for r in rows]
def count_skill_versions(self, skill_id: str) -> int:
"""Return the count of skill-version rows for ``skill_id``.
See the SQLite backend's docstring for context (#perf-2).
"""
with self._conn() as conn:
row = conn.execute(
sa.select(sa.func.count())
.select_from(skill_versions)
.where(skill_versions.c.skill_id == skill_id)
).fetchone()
return int(row[0]) if row else 0
def delete_skill_versions(self, skill_id: str) -> int:
with self._conn() as conn:
result = conn.execute(
sa.delete(skill_versions).where(skill_versions.c.skill_id == skill_id)
)
conn.commit()
return result.rowcount
# -- Usage events ----------------------------------------------------------
def record_usage_event(
self,
event_id: str,
user_id: str = "",
ws_id: str = "",
node_id: str = "",
model: str = "",
prompt_tokens: int = 0,
completion_tokens: int = 0,
tool_calls_count: int = 0,
cache_creation_tokens: int = 0,
cache_read_tokens: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(usage_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"ws_id": ws_id,
"node_id": node_id,
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"tool_calls_count": tool_calls_count,
"cache_creation_tokens": cache_creation_tokens,
"cache_read_tokens": cache_read_tokens,
"created": now,
},
)
conn.commit()
def query_usage(
self,
since: str,
until: str = "",
user_id: str = "",
model: str = "",
group_by: str = "",
) -> list[dict[str, Any]]:
clauses = ["timestamp >= :since"]
params: dict[str, Any] = {"since": since}
if until:
clauses.append("timestamp <= :until")
params["until"] = until
if user_id:
clauses.append("user_id = :user_id")
params["user_id"] = user_id
if model:
clauses.append("model = :model")
params["model"] = model
where = " AND ".join(clauses)
if group_by == "day":
key_expr = "substring(timestamp from 1 for 10)"
elif group_by == "hour":
key_expr = "substring(timestamp from 1 for 13)"
elif group_by == "model":
key_expr = "model"
elif group_by == "user":
key_expr = "user_id"
else:
# No grouping — single summary row
sql = (
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count), SUM(cache_creation_tokens), "
f"SUM(cache_read_tokens) FROM usage_events WHERE {where}"
)
with self._conn() as conn:
row = conn.execute(sa.text(sql), params).fetchone()
if row:
return [
{
"prompt_tokens": row[0] or 0,
"completion_tokens": row[1] or 0,
"tool_calls_count": row[2] or 0,
"cache_creation_tokens": row[3] or 0,
"cache_read_tokens": row[4] or 0,
}
]
return [
{
"prompt_tokens": 0,
"completion_tokens": 0,
"tool_calls_count": 0,
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
}
]
sql = (
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count), SUM(cache_creation_tokens), "
f"SUM(cache_read_tokens) FROM usage_events WHERE {where} "
f"GROUP BY {key_expr} ORDER BY key ASC"
)
with self._conn() as conn:
rows = conn.execute(sa.text(sql), params).fetchall()
return [
{
"key": r[0],
"prompt_tokens": r[1] or 0,
"completion_tokens": r[2] or 0,
"tool_calls_count": r[3] or 0,
"cache_creation_tokens": r[4] or 0,
"cache_read_tokens": r[5] or 0,
}
for r in rows
]
def prune_usage_events(self, retention_days: int = 90) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
def sum_workstream_tokens(self, ws_id: str) -> int:
if not ws_id:
return 0
with self._conn() as conn:
row = conn.execute(
sa.select(
sa.func.coalesce(
sa.func.sum(
usage_events.c.prompt_tokens + usage_events.c.completion_tokens
),
0,
)
).where(usage_events.c.ws_id == ws_id)
).fetchone()
return int(row[0]) if row and row[0] is not None else 0
def sum_workstream_tokens_batch(self, ws_ids: list[str]) -> dict[str, int]:
if not ws_ids:
return {}
clean = [w for w in ws_ids if isinstance(w, str) and w]
out: dict[str, int] = {w: 0 for w in clean}
if not clean:
return out
with self._conn() as conn:
rows = conn.execute(
sa.select(
usage_events.c.ws_id,
sa.func.sum(usage_events.c.prompt_tokens + usage_events.c.completion_tokens),
)
.where(usage_events.c.ws_id.in_(clean))
.group_by(usage_events.c.ws_id)
).fetchall()
for r in rows:
if r[0] is not None and r[1] is not None:
out[r[0]] = int(r[1])
return out
def get_workstreams_batch(
self,
ws_ids: list[str],
) -> dict[str, dict[str, Any] | None]:
if not ws_ids:
return {}
clean = [w for w in ws_ids if isinstance(w, str) and w]
out: dict[str, dict[str, Any] | None] = {w: None for w in clean}
if not clean:
return out
with self._conn() as conn:
rows = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.node_id,
workstreams.c.user_id,
workstreams.c.alias,
workstreams.c.title,
workstreams.c.name,
workstreams.c.state,
workstreams.c.skill_id,
workstreams.c.skill_version,
workstreams.c.kind,
workstreams.c.parent_ws_id,
workstreams.c.created,
workstreams.c.updated,
workstreams.c.project_id,
workstreams.c.persona,
).where(workstreams.c.ws_id.in_(clean))
).fetchall()
for r in rows:
item = {
"ws_id": r[0],
"node_id": r[1],
"user_id": r[2],
"alias": r[3],
"title": r[4],
"name": r[5],
"state": r[6],
"skill_id": r[7],
"skill_version": r[8],
"kind": r[9],
"parent_ws_id": r[10],
"created": r[11],
"updated": r[12],
"project_id": r[13],
"persona": r[14],
}
out[r[0]] = item
return out
# -- Audit events ----------------------------------------------------------
def record_audit_event(
self,
event_id: str,
user_id: str = "",
action: str = "",
resource_type: str = "",
resource_id: str = "",
detail: str = "{}",
ip_address: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(audit_events),
{
"event_id": event_id,
"timestamp": now,
"user_id": user_id,
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"detail": detail,
"ip_address": ip_address,
"created": now,
},
)
conn.commit()
def list_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
limit: int = 100,
offset: int = 0,
resource_id: str = "",
) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(
audit_events.c.event_id,
audit_events.c.timestamp,
audit_events.c.user_id,
audit_events.c.action,
audit_events.c.resource_type,
audit_events.c.resource_id,
audit_events.c.detail,
audit_events.c.ip_address,
audit_events.c.created,
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
if resource_id:
q = q.where(audit_events.c.resource_id == resource_id)
q = q.limit(limit).offset(offset)
rows = conn.execute(q).fetchall()
return [
{
"event_id": r[0],
"timestamp": r[1],
"user_id": r[2],
"action": r[3],
"resource_type": r[4],
"resource_id": r[5],
"detail": r[6],
"ip_address": r[7],
"created": r[8],
}
for r in rows
]
def count_audit_events(
self,
action: str = "",
user_id: str = "",
since: str = "",
until: str = "",
) -> int:
with self._conn() as conn:
q = sa.select(sa.func.count()).select_from(audit_events)
if action:
q = q.where(audit_events.c.action == action)
if user_id:
q = q.where(audit_events.c.user_id == user_id)
if since:
q = q.where(audit_events.c.timestamp >= since)
if until:
q = q.where(audit_events.c.timestamp <= until)
row = conn.execute(q).fetchone()
return row[0] if row else 0
def prune_audit_events(self, retention_days: int = 365) -> int:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Intent verdicts -------------------------------------------------------
def create_intent_verdict(
self,
verdict_id: str,
ws_id: str,
call_id: str,
func_name: str,
func_args: str,
intent_summary: str,
risk_level: str,
confidence: float,
recommendation: str,
reasoning: str,
evidence: str,
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(intent_verdicts),
{
"verdict_id": verdict_id,
"ws_id": ws_id,
"call_id": call_id,
"func_name": func_name,
"func_args": func_args,
"intent_summary": intent_summary,
"risk_level": risk_level,
"confidence": confidence,
"recommendation": recommendation,
"reasoning": reasoning,
"evidence": evidence,
"tier": tier,
"judge_model": judge_model,
"latency_ms": latency_ms,
"user_decision": user_decision,
"created": now,
},
)
conn.commit()
def upsert_intent_verdict(
self,
verdict_id: str,
ws_id: str,
call_id: str,
func_name: str,
func_args: str,
intent_summary: str,
risk_level: str,
confidence: float,
recommendation: str,
reasoning: str,
evidence: str,
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(intent_verdicts).values(
verdict_id=verdict_id,
ws_id=ws_id,
call_id=call_id,
func_name=func_name,
func_args=func_args,
intent_summary=intent_summary,
risk_level=risk_level,
confidence=confidence,
recommendation=recommendation,
reasoning=reasoning,
evidence=evidence,
tier=tier,
judge_model=judge_model,
latency_ms=latency_ms,
user_decision=user_decision,
created=now,
)
# On verdict_id conflict, update only the three fields that
# genuinely change between heuristic and llm_fallback. See the
# protocol docstring for the full exclusion rationale —
# ``user_decision`` exclusion in particular is load-bearing.
stmt = stmt.on_conflict_do_update(
index_elements=[intent_verdicts.c.verdict_id],
set_={
"tier": tier,
"reasoning": reasoning,
"judge_model": judge_model,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
# ON CONFLICT DO NOTHING — see the protocol docstring for the
# daemon-races-the-bulk-write rationale.
from sqlalchemy.dialects.postgresql import insert as pg_insert
if not verdicts:
return
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
rows = [
{
"verdict_id": v.get("verdict_id", ""),
"ws_id": v.get("ws_id", ""),
"call_id": v.get("call_id", ""),
"func_name": v.get("func_name", ""),
"func_args": v.get("func_args", ""),
"intent_summary": v.get("intent_summary", ""),
"risk_level": v.get("risk_level", "medium"),
"confidence": v.get("confidence", 0.5),
"recommendation": v.get("recommendation", "review"),
"reasoning": v.get("reasoning", ""),
"evidence": v.get("evidence", ""),
"tier": v.get("tier", "heuristic"),
"judge_model": v.get("judge_model", ""),
"latency_ms": v.get("latency_ms", 0),
"user_decision": v.get("user_decision", "pending"),
"created": now,
}
for v in verdicts
]
with self._conn() as conn:
conn.execute(
pg_insert(intent_verdicts).on_conflict_do_nothing(index_elements=["verdict_id"]),
rows,
)
conn.commit()
def get_intent_verdict(self, verdict_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(intent_verdicts).where(intent_verdicts.c.verdict_id == verdict_id)
).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_intent_verdicts(
self,
ws_id: str = "",
since: str = "",
until: str = "",
risk_level: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(intent_verdicts).order_by(
intent_verdicts.c.created.desc(), intent_verdicts.c.verdict_id.desc()
)
if ws_id:
q = q.where(intent_verdicts.c.ws_id == ws_id)
if since:
q = q.where(intent_verdicts.c.created >= since)
if until:
q = q.where(intent_verdicts.c.created <= until)
if risk_level:
q = q.where(intent_verdicts.c.risk_level == risk_level)
q = q.limit(limit).offset(offset)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def update_intent_verdict(self, verdict_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _VERDICT_MUTABLE}
if not fields:
return False
with self._conn() as conn:
result = conn.execute(
sa.update(intent_verdicts)
.where(intent_verdicts.c.verdict_id == verdict_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def count_intent_verdicts(
self,
ws_id: str = "",
since: str = "",
until: str = "",
risk_level: str = "",
) -> int:
with self._conn() as conn:
q = sa.select(sa.func.count()).select_from(intent_verdicts)
if ws_id:
q = q.where(intent_verdicts.c.ws_id == ws_id)
if since:
q = q.where(intent_verdicts.c.created >= since)
if until:
q = q.where(intent_verdicts.c.created <= until)
if risk_level:
q = q.where(intent_verdicts.c.risk_level == risk_level)
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Output assessments ----------------------------------------------------
def record_output_assessment(
self,
assessment_id: str,
ws_id: str,
call_id: str,
func_name: str,
flags: str,
risk_level: str,
annotations: str,
output_length: int,
redacted: bool,
*,
tier: str = "heuristic",
reasoning: str = "",
judge_model: str = "",
latency_ms: int = 0,
confidence: float = 0.0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(output_assessments),
{
"assessment_id": assessment_id,
"ws_id": ws_id,
"call_id": call_id,
"func_name": func_name,
"flags": flags,
"risk_level": risk_level,
"annotations": annotations,
"output_length": output_length,
"redacted": int(redacted),
"created": now,
"tier": tier,
"reasoning": reasoning,
"judge_model": judge_model,
"latency_ms": latency_ms,
"confidence": confidence,
},
)
conn.commit()
def list_output_assessments(
self,
ws_id: str = "",
risk_level: str = "",
since: str = "",
until: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
with self._conn() as conn:
# ``created`` is second-resolution, so the heuristic and llm rows
# for the same call_id (written within ms of each other) commonly
# tie. The ``tier`` tie-breaker encodes the design intent — LLM
# wins when it ran — so downstream consumers like history
# decoration see the acted verdict first on identical timestamps.
q = sa.select(output_assessments).order_by(
output_assessments.c.created.desc(),
sa.case((output_assessments.c.tier == "llm", 0), else_=1),
output_assessments.c.assessment_id.desc(),
)
if ws_id:
q = q.where(output_assessments.c.ws_id == ws_id)
if risk_level:
q = q.where(output_assessments.c.risk_level == risk_level)
if since:
q = q.where(output_assessments.c.created >= since)
if until:
q = q.where(output_assessments.c.created <= until)
q = q.limit(limit).offset(offset)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def count_output_assessments(
self,
ws_id: str = "",
risk_level: str = "",
since: str = "",
until: str = "",
) -> int:
with self._conn() as conn:
q = sa.select(sa.func.count()).select_from(output_assessments)
if ws_id:
q = q.where(output_assessments.c.ws_id == ws_id)
if risk_level:
q = q.where(output_assessments.c.risk_level == risk_level)
if since:
q = q.where(output_assessments.c.created >= since)
if until:
q = q.where(output_assessments.c.created <= until)
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Structured memories ---------------------------------------------------
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(structured_memories),
{
"memory_id": memory_id,
"name": name,
"description": description,
"type": mem_type,
"scope": scope,
"scope_id": scope_id,
"content": content,
"created": now,
"updated": now,
"last_accessed": now,
"access_count": 0,
},
)
conn.commit()
def upsert_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str | None,
scope: str,
scope_id: str,
content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]:
from sqlalchemy.dialects.postgresql import insert as pg_insert
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_stmt = pg_insert(structured_memories).values(
memory_id=memory_id,
name=name,
description=description,
type="general" if mem_type is None else mem_type,
scope=scope,
scope_id=scope_id,
content=content,
created=now,
updated=now,
last_accessed=now,
access_count=0,
)
# On conflict, refresh content, description, and timestamps. A None type means
# "unset" -> keep the stored value. created/access_count stay untouched.
set_: dict[str, Any] = {
"content": insert_stmt.excluded.content,
"updated": now,
"last_accessed": now,
}
set_["description"] = insert_stmt.excluded.description
if mem_type is not None:
set_["type"] = insert_stmt.excluded.type
stmt = insert_stmt.on_conflict_do_update(
index_elements=["name", "scope", "scope_id"],
set_=set_,
).returning(structured_memories)
with self._conn() as conn:
if require_active_project:
if scope != "project" or not scope_id:
raise ValueError("active-project guard requires project scope")
project = conn.execute(
sa.select(projects.c.project_id)
.where(
sa.and_(
projects.c.project_id == scope_id,
projects.c.state == "active",
)
)
.with_for_update(read=True)
).fetchone()
if project is None:
conn.rollback()
raise ValueError("project is missing, archived, or no longer writable")
row = conn.execute(stmt).fetchone()
conn.commit()
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
return {}, False
result = dict(row._mapping)
return result, result["memory_id"] != memory_id
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(structured_memories).where(structured_memories.c.memory_id == memory_id)
).fetchone()
return dict(row._mapping) if row else None
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
return self.delete_structured_memory_returning(name, scope, scope_id) is not None
def delete_structured_memory_returning(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
stmt = (
sa.delete(structured_memories)
.where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
.returning(structured_memories)
)
with self._conn() as conn:
row = conn.execute(stmt).fetchone()
conn.commit()
return dict(row._mapping) if row is not None else None
def delete_structured_memory_by_id(self, memory_id: str) -> bool:
return self.delete_structured_memory_by_id_returning(memory_id) is not None
def delete_structured_memory_by_id_returning(self, memory_id: str) -> dict[str, str] | None:
with self._conn() as conn:
row = conn.execute(
sa.delete(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.returning(structured_memories)
).fetchone()
conn.commit()
return dict(row._mapping) if row is not None else None
def find_structured_memory_scopes(
self,
name: str,
scopes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
rows = conn.execute(
sa.text(
"SELECT scope, scope_id FROM structured_memories "
f"WHERE name = :name AND ({scope_clauses}) "
"ORDER BY scope, scope_id"
),
{**params, "name": name},
).fetchall()
return [(str(row.scope), str(row.scope_id)) for row in rows]
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
with self._conn() as conn:
q = sa.select(structured_memories).order_by(
structured_memories.c.updated.desc(),
structured_memories.c.memory_id.asc(),
)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id and scope:
q = q.where(structured_memories.c.scope_id == scope_id)
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms ILIKE search; ranking is the caller's job (BM25 downstream)."""
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = _normalize_search_terms(query)
if not terms:
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
with self._conn() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
f"OR content ILIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
term_clause = " OR ".join(clauses)
scope_filters = ""
if mem_type:
scope_filters += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
scope_filters += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id and scope:
scope_filters += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE ({term_clause}){scope_filters} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def list_visible_structured_memories(
self,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Single-query union across visible (scope, scope_id) pairs.
Replaces the per-scope fan-out (one query per visible scope) so the
composition path issues 1 round-trip instead of 3.
"""
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE ({scope_clauses}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def search_visible_structured_memories(
self,
query: str,
scopes: list[tuple[str, str]],
mem_type: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""OR-of-terms search joined with a single visibility OR-group.
Replaces the per-scope search fan-out; ranking is the caller's job.
"""
if not scopes:
return []
if not query or not query.strip():
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
terms = _normalize_search_terms(query)
if not terms:
return self.list_visible_structured_memories(scopes, mem_type=mem_type, limit=limit)
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
term_clauses = []
for i, t in enumerate(terms):
escaped = _escape_like(t)
term_clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
f"OR content ILIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
term_clause = " OR ".join(term_clauses)
extra = ""
if mem_type:
extra = " AND type = :type_filter"
params["type_filter"] = mem_type
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories "
f"WHERE ({scope_clauses}) AND ({term_clause}){extra} "
f"ORDER BY updated DESC, memory_id ASC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
@staticmethod
def _build_scope_or_clause(
scopes: list[tuple[str, str]],
) -> tuple[str, dict[str, str]]:
"""Build a parameterized OR-group of (scope[, scope_id]) predicates."""
params: dict[str, str] = {}
clauses: list[str] = []
for i, (s, sid) in enumerate(scopes):
params[f"sc{i}"] = s
if sid:
params[f"sid{i}"] = sid
clauses.append(f"(scope = :sc{i} AND scope_id = :sid{i})")
else:
clauses.append(f"scope = :sc{i}")
return " OR ".join(clauses), params
def touch_structured_memories(self, keys: list[tuple[str, str, str]]) -> int:
"""Batch-touch multiple memories by (name, scope, scope_id)."""
if not keys:
return 0
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
total = 0
with self._conn() as conn:
for name, scope, scope_id in keys:
result = conn.execute(
sa.update(structured_memories)
.where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
.values(
last_accessed=now,
access_count=structured_memories.c.access_count + 1,
)
)
total += result.rowcount
conn.commit()
return total
def count_structured_memories(
self, mem_type: str = "", scope: str = "", scope_id: str = ""
) -> int:
with self._conn() as conn:
q = sa.select(sa.func.count()).select_from(structured_memories)
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id and scope:
q = q.where(structured_memories.c.scope_id == scope_id)
result = conn.execute(q).scalar()
return int(result or 0)
# -- System settings -------------------------------------------------------
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def list_system_settings(self, node_id: str = "") -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(system_settings).order_by(system_settings.c.key)
if node_id:
# Return both global and node-specific
q = q.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
return [dict(r._mapping) for r in conn.execute(q).fetchall()]
def upsert_system_setting(
self,
key: str,
value: str,
node_id: str = "",
is_secret: bool = False,
changed_by: str = "",
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
secret_val = 1 if is_secret else 0
stmt = pg_insert(system_settings).values(
key=key,
value=value,
node_id=node_id,
is_secret=secret_val,
changed_by=changed_by,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["key", "node_id"],
set_={
"value": value,
"is_secret": secret_val,
"changed_by": changed_by,
"updated": now,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def delete_system_setting(self, key: str, node_id: str = "") -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(system_settings).where(
sa.and_(
system_settings.c.key == key,
system_settings.c.node_id == node_id,
)
)
)
conn.commit()
return result.rowcount > 0
def get_system_settings_bulk(self, node_id: str = "") -> dict[str, str]:
with self._conn() as conn:
if not node_id:
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value).where(
system_settings.c.node_id == ""
)
).fetchall()
return {r.key: r.value for r in rows}
# Global + node overrides in one query; node_id sorts after ""
# so node-specific values overwrite globals in the dict
rows = conn.execute(
sa.select(system_settings.c.key, system_settings.c.value)
.where(
sa.or_(
system_settings.c.node_id == "",
system_settings.c.node_id == node_id,
)
)
.order_by(system_settings.c.node_id)
).fetchall()
return {r.key: r.value for r in rows}
# -- MCP server definitions ------------------------------------------------
def create_mcp_server(
self,
server_id: str,
name: str,
transport: str,
command: str = "",
args: str = "[]",
url: str = "",
headers: str = "{}",
env: str = "{}",
auto_approve: bool = False,
enabled: bool = True,
created_by: str = "",
registry_name: str | None = None,
registry_version: str = "",
registry_meta: str = "{}",
auth_type: str = "static",
oauth_client_id: str | None = None,
oauth_client_secret_ct: bytes | None = None,
oauth_scopes: str | None = None,
oauth_audience: str | None = None,
oauth_registration_mode: str | None = None,
oauth_authorization_server_url: str | None = None,
oauth_as_issuer_cached: str | None = None,
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(mcp_servers)
.values(
server_id=server_id,
name=name,
transport=transport,
command=command,
args=args,
url=url,
headers=headers,
env=env,
auto_approve=1 if auto_approve else 0,
enabled=1 if enabled else 0,
created_by=created_by,
registry_name=registry_name,
registry_version=registry_version,
registry_meta=registry_meta,
auth_type=auth_type,
oauth_client_id=oauth_client_id,
oauth_client_secret_ct=oauth_client_secret_ct,
oauth_scopes=oauth_scopes,
oauth_audience=oauth_audience,
oauth_registration_mode=oauth_registration_mode,
oauth_authorization_server_url=oauth_authorization_server_url,
oauth_as_issuer_cached=oauth_as_issuer_cached,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_mcp_server(self, server_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(mcp_servers).where(mcp_servers.c.server_id == server_id)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "auto_approve", "enabled")
def get_mcp_server_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(mcp_servers).where(mcp_servers.c.name == name)).fetchone()
if row is None:
return None
return _row_to_dict(row, "auto_approve", "enabled")
def get_mcp_server_by_registry_name(self, registry_name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(mcp_servers).where(mcp_servers.c.registry_name == registry_name)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "auto_approve", "enabled")
def list_mcp_servers(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(mcp_servers).order_by(mcp_servers.c.name)
if enabled_only:
q = q.where(mcp_servers.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "auto_approve", "enabled") for r in rows]
def update_mcp_server(self, server_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _MCP_SERVER_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "auto_approve" in fields:
fields["auto_approve"] = 1 if fields["auto_approve"] else 0
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
with self._conn() as conn:
result = conn.execute(
sa.update(mcp_servers).where(mcp_servers.c.server_id == server_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_mcp_server(self, server_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_servers).where(mcp_servers.c.server_id == server_id)
)
conn.commit()
return result.rowcount > 0
# -- MCP OAuth: client-secret + per-(user, server) tokens ------------------
def set_mcp_oauth_client_secret_ct(self, server_id: str, secret_ct: bytes | None) -> bool:
"""Update only the encrypted OAuth client-secret column.
Returns True when a row was updated. ``None`` clears the column.
Bypasses ``MCP_SERVER_MUTABLE`` deliberately.
"""
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(mcp_servers)
.where(mcp_servers.c.server_id == server_id)
.values(oauth_client_secret_ct=secret_ct, updated=now)
)
conn.commit()
return result.rowcount > 0
def create_mcp_user_token(
self,
user_id: str,
server_name: str,
*,
access_token_ct: bytes,
refresh_token_ct: bytes | None,
expires_at: str | None,
scopes: str | None,
as_issuer: str,
audience: str,
) -> None:
"""Insert a new per-(user, server) token row. No-op on conflict."""
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(mcp_user_tokens)
.values(
user_id=user_id,
server_name=server_name,
access_token_ct=access_token_ct,
refresh_token_ct=refresh_token_ct,
expires_at=expires_at,
scopes=scopes,
as_issuer=as_issuer,
audience=audience,
created=now,
last_refreshed=None,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_mcp_user_token(self, user_id: str, server_name: str) -> MCPUserToken | None:
"""Return the per-(user, server) token row or None."""
with self._conn() as conn:
row = conn.execute(
sa.select(mcp_user_tokens).where(
(mcp_user_tokens.c.user_id == user_id)
& (mcp_user_tokens.c.server_name == server_name)
)
).fetchone()
if row is None:
return None
m = row._mapping
return MCPUserToken(
user_id=m["user_id"],
server_name=m["server_name"],
access_token_ct=bytes(m["access_token_ct"]),
refresh_token_ct=(
bytes(m["refresh_token_ct"]) if m["refresh_token_ct"] is not None else None
),
expires_at=m["expires_at"],
scopes=m["scopes"],
as_issuer=m["as_issuer"],
audience=m["audience"],
created=m["created"],
last_refreshed=m["last_refreshed"],
)
def update_mcp_user_token_after_refresh(
self,
user_id: str,
server_name: str,
*,
access_token_ct: bytes,
refresh_token_ct: bytes | None,
expires_at: str | None,
) -> bool:
"""Rewrite token columns + ``last_refreshed`` after an AS refresh.
Preserves columns this method does not rewrite (``scopes``,
``as_issuer``, ``audience``, ``created``). Returns True when a
row was updated.
"""
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(mcp_user_tokens)
.where(
(mcp_user_tokens.c.user_id == user_id)
& (mcp_user_tokens.c.server_name == server_name)
)
.values(
access_token_ct=access_token_ct,
refresh_token_ct=refresh_token_ct,
expires_at=expires_at,
last_refreshed=now,
)
)
conn.commit()
return result.rowcount > 0
def delete_mcp_user_token(self, user_id: str, server_name: str) -> bool:
"""Delete the per-(user, server) token row. Returns True if existed."""
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_user_tokens).where(
(mcp_user_tokens.c.user_id == user_id)
& (mcp_user_tokens.c.server_name == server_name)
)
)
conn.commit()
return result.rowcount > 0
def list_mcp_user_token_metadata_by_user(self, user_id: str) -> list[MCPUserTokenMetadataRow]:
"""Return non-secret metadata rows for ``user_id``, ordered by ``created`` ASC.
Projects metadata columns at the SQL boundary so ciphertext
blobs (``access_token_ct`` / ``refresh_token_ct``) never cross
the wire on the settings-list path.
"""
with self._conn() as conn:
rows = conn.execute(
sa.select(
mcp_user_tokens.c.user_id,
mcp_user_tokens.c.server_name,
mcp_user_tokens.c.expires_at,
mcp_user_tokens.c.scopes,
mcp_user_tokens.c.as_issuer,
mcp_user_tokens.c.audience,
mcp_user_tokens.c.created,
mcp_user_tokens.c.last_refreshed,
)
.where(mcp_user_tokens.c.user_id == user_id)
.order_by(mcp_user_tokens.c.created)
).fetchall()
out: list[MCPUserTokenMetadataRow] = []
for row in rows:
m = row._mapping
out.append(
MCPUserTokenMetadataRow(
user_id=m["user_id"],
server_name=m["server_name"],
expires_at=m["expires_at"],
scopes=m["scopes"],
as_issuer=m["as_issuer"],
audience=m["audience"],
created=m["created"],
last_refreshed=m["last_refreshed"],
)
)
return out
def list_mcp_user_token_reconcile_targets(self) -> list[tuple[str, str, str | None]]:
"""Return ``(user_id, server_name, COALESCE(last_refreshed, created))`` per
token row — the freshness sweep's drive set + keepalive-refresh signal.
Unfiltered by expiry: an expired access token with a live refresh token
is still a consented grant the sweep must keep hot. Joined to
``mcp_servers`` so only ``oauth_user`` grants drive the sweep; synthetic
model mint-cache rows are excluded. No ciphertext is projected.
"""
with self._conn() as conn:
rows = conn.execute(
sa.select(
mcp_user_tokens.c.user_id,
mcp_user_tokens.c.server_name,
sa.func.coalesce(mcp_user_tokens.c.last_refreshed, mcp_user_tokens.c.created),
)
.select_from(
mcp_user_tokens.join(
mcp_servers,
mcp_servers.c.name == mcp_user_tokens.c.server_name,
)
)
.where(mcp_servers.c.auth_type == "oauth_user")
).fetchall()
return [(row[0], row[1], row[2]) for row in rows]
def delete_mcp_oauth_rows_by_server_name(self, server_name: str) -> int:
"""Purge user tokens + pending OAuth state for *server_name*."""
with self._conn() as conn:
tokens_result = conn.execute(
sa.delete(mcp_user_tokens).where(mcp_user_tokens.c.server_name == server_name)
)
pending_result = conn.execute(
sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.server_name == server_name)
)
conn.commit()
return int(tokens_result.rowcount or 0) + int(pending_result.rowcount or 0)
def get_mcp_oauth_client_secret_ct(self, server_id: str) -> bytes | None:
"""Return the encrypted OAuth client secret column or None."""
with self._conn() as conn:
row = conn.execute(
sa.select(mcp_servers.c.oauth_client_secret_ct).where(
mcp_servers.c.server_id == server_id
)
).fetchone()
if row is None or row[0] is None:
return None
return bytes(row[0])
# -- MCP OAuth pending state (per-(user, server) flow) ---------------------
def create_mcp_oauth_pending_state(
self,
state: str,
user_id: str,
server_name: str,
code_verifier: str,
return_url: str,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(mcp_oauth_pending),
{
"state": state,
"user_id": user_id,
"server_name": server_name,
"code_verifier": code_verifier,
"return_url": return_url,
"created_at": now,
},
)
conn.commit()
def pop_mcp_oauth_pending_state(
self, state: str, max_age_seconds: int = 600
) -> MCPOAuthPendingState | None:
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
# Atomic DELETE...RETURNING for true one-time consumption
row = conn.execute(
sa.text(
"DELETE FROM mcp_oauth_pending "
"WHERE state = :state AND created_at > :cutoff "
"RETURNING state, user_id, server_name, code_verifier, "
"return_url, created_at"
),
{"state": state, "cutoff": cutoff},
).fetchone()
# Also clean up the row if it existed but was expired
if not row:
conn.execute(sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.state == state))
conn.commit()
if not row:
return None
return MCPOAuthPendingState(
state=row[0],
user_id=row[1],
server_name=row[2],
code_verifier=row[3],
return_url=row[4],
created_at=row[5],
)
def cleanup_expired_mcp_oauth_pending_states(self, max_age_seconds: int = 600) -> int:
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_oauth_pending).where(mcp_oauth_pending.c.created_at < cutoff)
)
conn.commit()
return result.rowcount
# -- MCP pending-consent (Phase 9) ----------------------------------------
def upsert_mcp_pending_consent(
self,
user_id: str,
server_name: str,
error_code: str,
scopes_required: str | None,
now_iso: str,
) -> None:
from sqlalchemy.dialects import postgresql
stmt = postgresql.insert(mcp_pending_consent).values(
user_id=user_id,
server_name=server_name,
error_code=error_code,
scopes_required=scopes_required,
first_seen_at=now_iso,
last_seen_at=now_iso,
occurrence_count=1,
)
stmt = stmt.on_conflict_do_update(
index_elements=["user_id", "server_name"],
set_={
"error_code": stmt.excluded.error_code,
"scopes_required": stmt.excluded.scopes_required,
"last_seen_at": stmt.excluded.last_seen_at,
"occurrence_count": mcp_pending_consent.c.occurrence_count + 1,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def list_mcp_pending_consent_by_user(self, user_id: str) -> list[MCPPendingConsentRow]:
with self._conn() as conn:
rows = conn.execute(
sa.select(mcp_pending_consent)
.where(mcp_pending_consent.c.user_id == user_id)
.order_by(mcp_pending_consent.c.last_seen_at.desc())
).fetchall()
out: list[MCPPendingConsentRow] = []
for r in rows:
m = r._mapping
out.append(
MCPPendingConsentRow(
user_id=m["user_id"],
server_name=m["server_name"],
error_code=m["error_code"],
scopes_required=m["scopes_required"],
first_seen_at=m["first_seen_at"],
last_seen_at=m["last_seen_at"],
occurrence_count=m["occurrence_count"],
)
)
return out
def delete_mcp_pending_consent(self, user_id: str, server_name: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(
(mcp_pending_consent.c.user_id == user_id)
& (mcp_pending_consent.c.server_name == server_name)
)
)
conn.commit()
return bool(result.rowcount)
def delete_all_mcp_pending_consent_by_user(self, user_id: str) -> int:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(mcp_pending_consent.c.user_id == user_id)
)
conn.commit()
return int(result.rowcount or 0)
def count_mcp_consented_users_by_server(self, server_name: str) -> int:
# ``expires_at IS NULL`` => non-expired (refresh-only tokens with no
# advertised expiry). Compare lexically against ISO-8601 strings,
# mirroring the convention in ``mcp_user_tokens.expires_at``.
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.select(sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)))
.where(mcp_user_tokens.c.server_name == server_name)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
).scalar()
return int(result or 0)
def count_mcp_consented_users_grouped_by_server(self) -> dict[str, int]:
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
rows = conn.execute(
sa.select(
mcp_user_tokens.c.server_name,
sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)),
)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
.group_by(mcp_user_tokens.c.server_name)
).fetchall()
return {row[0]: int(row[1] or 0) for row in rows}
def any_user_scoped_mcp_servers(self) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.select(sa.literal(1))
.select_from(mcp_servers)
.where(mcp_servers.c.auth_type.in_(sorted(USER_SCOPED_AUTH_TYPES)))
.limit(1)
).scalar()
return result is not None
# -- Model definitions -----------------------------------------------------
def create_model_definition(
self,
definition_id: str,
alias: str,
model: str,
provider: str = "openai",
base_url: str = "",
api_key: str = "",
context_window: int = 32768,
capabilities: str = "{}",
enabled: bool = True,
created_by: str = "",
temperature: float | None = None,
max_tokens: int | None = None,
reasoning_effort: str | None = None,
surface_persisted_reasoning: bool = True,
replay_reasoning_to_model: bool = False,
auth_mode: str = "static",
obo_audience: str = "",
obo_scopes: str = "",
max_concurrency: int = 0,
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(model_definitions)
.values(
definition_id=definition_id,
alias=alias,
model=model,
provider=provider,
base_url=base_url,
api_key=api_key,
context_window=context_window,
capabilities=capabilities,
enabled=1 if enabled else 0,
temperature=temperature,
max_tokens=max_tokens,
reasoning_effort=reasoning_effort,
surface_persisted_reasoning=1 if surface_persisted_reasoning else 0,
replay_reasoning_to_model=1 if replay_reasoning_to_model else 0,
auth_mode=auth_mode,
obo_audience=obo_audience,
obo_scopes=obo_scopes,
max_concurrency=max_concurrency,
created_by=created_by,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
).fetchone()
if row is None:
return None
return _row_to_dict(
row, "enabled", "surface_persisted_reasoning", "replay_reasoning_to_model"
)
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(model_definitions).where(model_definitions.c.alias == alias)
).fetchone()
if row is None:
return None
return _row_to_dict(
row, "enabled", "surface_persisted_reasoning", "replay_reasoning_to_model"
)
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
if enabled_only:
q = q.where(model_definitions.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [
_row_to_dict(
r, "enabled", "surface_persisted_reasoning", "replay_reasoning_to_model"
)
for r in rows
]
def update_model_definition(
self,
definition_id: str,
*,
expected_capabilities: Any = _CAPS_COMPARE_UNSET,
**fields: Any,
) -> bool:
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
if "surface_persisted_reasoning" in fields:
fields["surface_persisted_reasoning"] = (
1 if fields["surface_persisted_reasoning"] else 0
)
if "replay_reasoning_to_model" in fields:
fields["replay_reasoning_to_model"] = 1 if fields["replay_reasoning_to_model"] else 0
with self._conn() as conn:
stmt = sa.update(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
if expected_capabilities is not _CAPS_COMPARE_UNSET:
# Conditional write: apply only while capabilities still
# equal the caller's re-read value, so a concurrent write is
# a rowcount-0 miss to re-merge onto, not a silent revert.
if expected_capabilities is None:
stmt = stmt.where(model_definitions.c.capabilities.is_(None))
else:
stmt = stmt.where(model_definitions.c.capabilities == expected_capabilities)
result = conn.execute(stmt.values(**fields))
conn.commit()
return result.rowcount > 0
def delete_model_definition(self, definition_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
)
conn.commit()
return result.rowcount > 0
# -- Projects --------------------------------------------------------------
def create_project(
self,
project_id: str,
name: str,
owner_id: str,
visibility: str = "private",
state: str = "active",
parent_project_id: str | None = None,
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
pg_insert(projects)
.values(
project_id=project_id,
name=name,
owner_id=owner_id,
visibility=visibility,
state=state,
parent_project_id=parent_project_id,
created=now,
updated=now,
)
.on_conflict_do_nothing(index_elements=["project_id"])
)
conn.commit()
def get_project(self, project_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(projects).where(projects.c.project_id == project_id)
).fetchone()
return _row_to_dict(row) if row is not None else None
def list_projects_for_user(
self, user_id: str, include_archived: bool = False
) -> list[dict[str, Any]]:
with self._conn() as conn:
member_subq = sa.select(project_members.c.project_id).where(
project_members.c.user_id == user_id
)
cond = sa.or_(
projects.c.owner_id == user_id,
projects.c.visibility == "public",
projects.c.project_id.in_(member_subq),
)
q = sa.select(projects).where(cond)
if not include_archived:
q = q.where(projects.c.state == "active")
rows = conn.execute(q.order_by(projects.c.name)).fetchall()
return [_row_to_dict(r) for r in rows]
def update_project(self, project_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _PROJECT_MUTABLE}
if not fields:
return False
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(projects).where(projects.c.project_id == project_id).values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_project(self, project_id: str) -> bool:
with self._conn() as conn:
# Serialize with guarded project-memory upserts. If a writer got
# the row first, its memory is committed before our purge; if this
# delete wins, the later writer's active-project check finds no row.
project = conn.execute(
sa.select(projects.c.project_id)
.where(projects.c.project_id == project_id)
.with_for_update()
).fetchone()
if project is None:
conn.rollback()
return False
# No FK cascade in the schema family, so purge the project's scoped
# memory + member rows explicitly (same transaction) before the
# project row — honouring the "destroys the container AND its scoped
# memory" contract the endpoint + UI promise.
conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.scope == "project",
structured_memories.c.scope_id == project_id,
)
)
)
conn.execute(
sa.delete(project_members).where(project_members.c.project_id == project_id)
)
result = conn.execute(sa.delete(projects).where(projects.c.project_id == project_id))
conn.commit()
return result.rowcount > 0
def add_project_member(self, project_id: str, user_id: str) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
pg_insert(project_members)
.values(project_id=project_id, user_id=user_id, created=now)
.on_conflict_do_nothing(index_elements=["project_id", "user_id"])
)
conn.commit()
def remove_project_member(self, project_id: str, user_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(project_members).where(
project_members.c.project_id == project_id,
project_members.c.user_id == user_id,
)
)
conn.commit()
return result.rowcount > 0
def list_project_members(self, project_id: str) -> list[str]:
with self._conn() as conn:
rows = conn.execute(
sa.select(project_members.c.user_id)
.where(project_members.c.project_id == project_id)
.order_by(project_members.c.user_id)
).fetchall()
return [str(r[0]) for r in rows]
def is_project_member(self, project_id: str, user_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.select(sa.literal(1))
.select_from(project_members)
.where(
project_members.c.project_id == project_id,
project_members.c.user_id == user_id,
)
.limit(1)
).scalar()
return result is not None
def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]:
# See SQLite sibling for the projection rationale.
with self._conn() as conn:
rows = conn.execute(
sa.select(
workstreams.c.ws_id,
workstreams.c.name,
workstreams.c.title,
workstreams.c.state,
workstreams.c.kind,
workstreams.c.updated,
workstreams.c.node_id,
workstreams.c.user_id,
)
.where(
workstreams.c.project_id == project_id,
workstreams.c.state != "creating",
)
.order_by(workstreams.c.updated.desc())
).fetchall()
return [
{
"ws_id": r[0],
"name": r[1],
"title": r[2],
"state": r[3],
"kind": r[4],
"updated": r[5],
"node_id": r[6],
"user_id": r[7],
}
for r in rows
]
def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]:
# See SQLite sibling: metadata-only (never the content blob), each
# id paired with its first referencing ws_id for URL construction.
with self._conn() as conn:
ref_rows = conn.execute(
sa.select(conversations.c.ws_id, conversations.c.attachments)
.select_from(
conversations.join(workstreams, workstreams.c.ws_id == conversations.c.ws_id)
)
.where(
workstreams.c.project_id == project_id,
conversations.c.attachments.is_not(None),
)
.order_by(conversations.c.id)
).fetchall()
first_ws: dict[str, str] = {}
for ws_id, raw in ref_rows:
try:
ids = json.loads(raw) if raw else []
except (TypeError, ValueError):
continue
if not isinstance(ids, list):
continue
for aid in ids:
if isinstance(aid, str) and aid and aid not in first_ws:
first_ws[aid] = ws_id
if not first_ws:
return []
# Chunk the IN() — a project can reference more distinct
# blobs than the driver's bind-parameter cap.
meta: dict[str, Any] = {}
ids = list(first_ws)
for i in range(0, len(ids), 500):
meta_rows = conn.execute(
sa.select(
workstream_attachments.c.attachment_id,
workstream_attachments.c.filename,
workstream_attachments.c.mime_type,
workstream_attachments.c.size_bytes,
workstream_attachments.c.kind,
workstream_attachments.c.created,
).where(workstream_attachments.c.attachment_id.in_(ids[i : i + 500]))
).fetchall()
for r in meta_rows:
meta[r[0]] = r
out: list[dict[str, Any]] = []
for aid, ws_id in first_ws.items():
m = meta.get(aid)
if m is None:
# Ref-list names a pruned blob (refcount GC) — skip.
continue
out.append(
{
"attachment_id": m[0],
"filename": m[1],
"mime_type": m[2],
"size_bytes": m[3],
"kind": m[4],
"created": m[5],
"ws_id": ws_id,
}
)
return out
# -- OIDC identity ---------------------------------------------------------
def create_oidc_user(
self,
user_id: str,
username: str,
display_name: str,
password_hash: str,
issuer: str,
subject: str,
email: str,
oid: str = "",
tid: str = "",
) -> None:
from turnstone.core.storage._protocol import StorageConflictError
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
try:
conn.execute(
sa.insert(users),
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": password_hash,
"created": now,
},
)
conn.execute(
sa.insert(oidc_identities),
{
"issuer": issuer,
"subject": subject,
"user_id": user_id,
"email": email,
"created": now,
"last_login": now,
"oid": oid,
"tid": tid,
},
)
except sa.exc.IntegrityError as exc:
conn.rollback()
constraint = ""
orig = exc.orig
diag = getattr(orig, "diag", None)
if diag is not None:
constraint = getattr(diag, "constraint_name", "") or ""
msg = str(orig) if orig is not None else str(exc)
if "username" in constraint or "users_username" in msg:
raise StorageConflictError(f"username already taken: {username}") from exc
if "oidc_identities" in constraint or "oidc_identities" in msg:
raise StorageConflictError(
f"OIDC identity already linked: ({issuer}, {subject})"
) from exc
raise StorageConflictError(f"OIDC user provisioning conflict: {msg}") from exc
conn.commit()
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(oidc_identities)
.values(
issuer=issuer,
subject=subject,
user_id=user_id,
email=email,
created=now,
last_login=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_oidc_identity(self, issuer: str, subject: str) -> OIDCIdentity | None:
with self._conn() as conn:
row = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
oidc_identities.c.oid,
oidc_identities.c.tid,
).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
).fetchone()
if row:
return OIDCIdentity(
issuer=row[0],
subject=row[1],
user_id=row[2],
email=row[3],
created=row[4],
last_login=row[5],
oid=row[6],
tid=row[7],
)
return None
def update_oidc_identity_login(
self, issuer: str, subject: str, oid: str = "", tid: str = ""
) -> bool:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
# last_login always; oid/tid only when supplied, so a login that omits
# them can't wipe a value captured on an earlier login.
values: dict[str, str] = {"last_login": now}
if oid:
values["oid"] = oid
if tid:
values["tid"] = tid
with self._conn() as conn:
result = conn.execute(
sa.update(oidc_identities)
.where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
.values(**values)
)
conn.commit()
return result.rowcount > 0
def list_oidc_identities_for_user(self, user_id: str) -> list[OIDCIdentity]:
with self._conn() as conn:
rows = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
oidc_identities.c.oid,
oidc_identities.c.tid,
)
.where(oidc_identities.c.user_id == user_id)
.order_by(oidc_identities.c.created.desc())
).fetchall()
return [
OIDCIdentity(
issuer=r[0],
subject=r[1],
user_id=r[2],
email=r[3],
created=r[4],
last_login=r[5],
oid=r[6],
tid=r[7],
)
for r in rows
]
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(oidc_identities).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC user credential (single-credential MCP minting, #551) -------------
def upsert_oidc_user_credential(
self, user_id: str, issuer: str, *, refresh_token_ct: bytes
) -> None:
"""Create or replace the user's captured IdP refresh token."""
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
stmt = postgresql.insert(oidc_user_credentials).values(
user_id=user_id,
issuer=issuer,
refresh_token_ct=refresh_token_ct,
created=now,
last_refreshed=now,
)
conn.execute(
stmt.on_conflict_do_update(
index_elements=["user_id", "issuer"],
set_={"refresh_token_ct": refresh_token_ct, "last_refreshed": now},
)
)
conn.commit()
def get_oidc_user_credential(self, user_id: str, issuer: str) -> OIDCUserCredential | None:
"""Return the captured credential row or None."""
with self._conn() as conn:
row = conn.execute(
sa.select(oidc_user_credentials).where(
(oidc_user_credentials.c.user_id == user_id)
& (oidc_user_credentials.c.issuer == issuer)
)
).fetchone()
if row is None:
return None
m = row._mapping
return OIDCUserCredential(
user_id=m["user_id"],
issuer=m["issuer"],
refresh_token_ct=bytes(m["refresh_token_ct"]),
created=m["created"],
last_refreshed=m["last_refreshed"],
)
def update_oidc_user_credential_refresh(
self, user_id: str, issuer: str, *, refresh_token_ct: bytes
) -> bool:
"""Persist the newest refresh token after a rotating redemption."""
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(oidc_user_credentials)
.where(
(oidc_user_credentials.c.user_id == user_id)
& (oidc_user_credentials.c.issuer == issuer)
)
.values(refresh_token_ct=refresh_token_ct, last_refreshed=now)
)
conn.commit()
return result.rowcount > 0
def delete_oidc_user_credential(self, user_id: str, issuer: str) -> bool:
"""Remove the captured credential. Returns True if existed."""
with self._conn() as conn:
result = conn.execute(
sa.delete(oidc_user_credentials).where(
(oidc_user_credentials.c.user_id == user_id)
& (oidc_user_credentials.c.issuer == issuer)
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC pending state ----------------------------------------------------
def create_oidc_pending_state(
self, state: str, nonce: str, code_verifier: str, audience: str
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
"audience": audience,
"created_at": now,
},
)
conn.commit()
def pop_oidc_pending_state(
self, state: str, max_age_seconds: int = 300
) -> OIDCPendingState | None:
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
# Atomic DELETE...RETURNING for true one-time consumption
row = conn.execute(
sa.text(
"DELETE FROM oidc_pending_states "
"WHERE state = :state AND created_at > :cutoff "
"RETURNING state, nonce, code_verifier, audience, created_at"
),
{"state": state, "cutoff": cutoff},
).fetchone()
# Also clean up the row if it existed but was expired
if not row:
conn.execute(
sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state)
)
conn.commit()
if not row:
return None
return OIDCPendingState(
state=row[0],
nonce=row[1],
code_verifier=row[2],
audience=row[3],
created_at=row[4],
)
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
result = conn.execute(
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
)
conn.commit()
return result.rowcount
# -- Personas ---------------------------------------------------------------
def list_personas(self, include_disabled: bool = False) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(personas).order_by(personas.c.name)
if not include_disabled:
q = q.where(personas.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_persona_row_to_dict(r) for r in rows]
def get_persona(self, persona_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(personas).where(personas.c.persona_id == persona_id)
).fetchone()
return _persona_row_to_dict(row) if row is not None else None
def get_persona_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(personas).where(personas.c.name == name)).fetchone()
return _persona_row_to_dict(row) if row is not None else None
def get_default_persona(self, kind: str) -> dict[str, Any] | None:
with self._conn() as conn:
rows = conn.execute(
sa.select(personas).where(
sa.and_(personas.c.is_default == 1, personas.c.enabled == 1)
)
).fetchall()
for row in rows:
d = _persona_row_to_dict(row)
if kind in d["applies_to_kinds"]:
return d
return None
def create_persona(self, persona: dict[str, Any]) -> None:
values = _serialize_persona_fields(persona)
if not values.get("persona_id") or not values.get("name"):
raise ValueError("persona requires persona_id and name")
# base_prompt_file is code-only — set only by the migration seeds, never
# via this operator-facing path. Drop it so a caller can't smuggle a
# file ref past the guard: the INSERT omits the column, so a supplied
# base_prompt_file would otherwise satisfy this check yet trip the CHECK,
# surfaced as a misleading name-collision. Operators supply base_prompt.
values.pop("base_prompt_file", None)
if not values.get("base_prompt"):
raise ValueError("persona requires a base_prompt")
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(personas.c.persona_id).where(personas.c.name == values["name"])
).fetchone()
if existing is not None:
raise ValueError(f"persona name already exists: {values['name']}")
default_kinds = persona.get("applies_to_kinds") or ["interactive"]
if values.get("is_default"):
# Serialize default promotions cluster-wide: under READ
# COMMITTED two concurrent promotions can each miss the
# other's uncommitted flag and commit two defaults. The
# xact-scoped advisory lock releases on commit/rollback.
conn.execute(
sa.text("SELECT pg_advisory_xact_lock(hashtext('turnstone_personas_default'))")
)
_validate_and_clear_default_persona(
conn,
personas,
persona_id=values["persona_id"],
kinds=default_kinds,
enabled=persona.get("enabled", True),
now=now,
)
try:
conn.execute(
sa.insert(personas),
{
"persona_id": values["persona_id"],
"name": values["name"],
"display_name": values.get("display_name", ""),
"description": values.get("description", ""),
"base_prompt": values.get("base_prompt"),
"tool_allowlist": values.get("tool_allowlist"),
"mcp_enabled": values.get("mcp_enabled", 1),
"memory_enabled": values.get("memory_enabled", 1),
"applies_to_kinds": values.get("applies_to_kinds", '["interactive"]'),
"is_default": values.get("is_default", 0),
"enabled": values.get("enabled", 1),
"org_id": values.get("org_id", ""),
"created_by": values.get("created_by", ""),
"created": now,
"updated": now,
},
)
except sa.exc.IntegrityError as exc:
# SELECT-then-INSERT loser on unique(name): surface the
# same ValueError the pre-check raises so callers map one
# error shape (400), not an opaque 500.
raise ValueError(f"persona name already exists: {values['name']}") from exc
if values.get("is_default"):
_assert_single_default_persona(conn, personas, default_kinds[0])
conn.commit()
def update_persona(self, persona_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _PERSONA_MUTABLE}
if not fields:
return False
# Validate/serialize BEFORE the invariant checks so malformed input
# (explicit-None kinds, wrong types) surfaces as the serializer's
# precise ValueError instead of a TypeError escaping the routes'
# 400 mapping as a 500.
values = _serialize_persona_fields(fields)
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
row = conn.execute(
sa.select(personas).where(personas.c.persona_id == persona_id)
).fetchone()
if row is None:
return False
current = _persona_row_to_dict(row)
builtin = bool(current.get("base_prompt_file"))
# Built-ins are code-owned: their base_prompt (override) is editable,
# but the origin marker blocks archiving them. Operator personas
# have no file to fall back on, so their only source can't be cleared.
if builtin and "enabled" in fields and not fields["enabled"]:
raise ValueError("cannot archive a built-in persona")
if not builtin and "base_prompt" in values and not values.get("base_prompt"):
raise ValueError("cannot clear base_prompt on an operator persona")
if current["is_default"]:
if "enabled" in fields and not fields["enabled"]:
raise ValueError("the default persona cannot be archived")
if "is_default" in fields and not fields["is_default"]:
raise ValueError(
"cannot unset is_default directly; set it on the successor persona instead"
)
if "applies_to_kinds" in fields and sorted(
fields["applies_to_kinds"] or []
) != sorted(current["applies_to_kinds"]):
raise ValueError("cannot change applies_to_kinds of the default persona")
promote = bool(fields.get("is_default")) and not current["is_default"]
promote_kinds = fields.get("applies_to_kinds", current["applies_to_kinds"])
if promote:
# Serialize default promotions cluster-wide (see
# create_persona for the READ COMMITTED rationale).
conn.execute(
sa.text("SELECT pg_advisory_xact_lock(hashtext('turnstone_personas_default'))")
)
_validate_and_clear_default_persona(
conn,
personas,
persona_id=persona_id,
kinds=promote_kinds,
enabled=fields.get("enabled", current["enabled"]),
now=now,
)
values["updated"] = now
conn.execute(
sa.update(personas).where(personas.c.persona_id == persona_id).values(**values)
)
if promote:
_assert_single_default_persona(conn, personas, promote_kinds[0])
conn.commit()
return True
# -- Prompt policies -------------------------------------------------------
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(prompt_policies_t).order_by(prompt_policies_t.c.priority)
if org_id:
q = q.where(prompt_policies_t.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
existing = conn.execute(
sa.select(prompt_policies_t).where(
prompt_policies_t.c.policy_id == policy["policy_id"]
)
).fetchone()
if existing:
fields = {k: v for k, v in policy.items() if k in _PROMPT_POLICY_MUTABLE}
fields["updated"] = now
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
conn.execute(
sa.update(prompt_policies_t)
.where(prompt_policies_t.c.policy_id == policy["policy_id"])
.values(**fields)
)
else:
conn.execute(
sa.insert(prompt_policies_t),
{
"policy_id": policy["policy_id"],
"name": policy["name"],
"content": policy["content"],
"tool_gate": policy.get("tool_gate", ""),
"priority": policy.get("priority", 0),
"enabled": 1 if policy.get("enabled", True) else 0,
"org_id": policy.get("org_id", ""),
"created_by": policy.get("created_by", ""),
"created": now,
"updated": now,
},
)
conn.commit()
def delete_prompt_policy(self, policy_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- Heuristic rules -------------------------------------------------------
def create_heuristic_rule(
self,
rule_id: str,
name: str,
risk_level: str,
confidence: float,
recommendation: str,
tool_pattern: str,
arg_patterns: str = "[]",
intent_template: str = "",
reasoning_template: str = "",
tier: str = "medium",
priority: int = 0,
builtin: bool = False,
enabled: bool = True,
created_by: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(heuristic_rules)
.values(
rule_id=rule_id,
name=name,
risk_level=risk_level,
confidence=confidence,
recommendation=recommendation,
tool_pattern=tool_pattern,
arg_patterns=arg_patterns,
intent_template=intent_template,
reasoning_template=reasoning_template,
tier=tier,
priority=priority,
builtin=1 if builtin else 0,
enabled=1 if enabled else 0,
created_by=created_by,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_heuristic_rule(self, rule_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(heuristic_rules).where(heuristic_rules.c.rule_id == rule_id)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled", "builtin")
def get_heuristic_rule_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(heuristic_rules).where(heuristic_rules.c.name == name)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled", "builtin")
def list_heuristic_rules(self, enabled_only: bool = False) -> list[dict[str, Any]]:
tier_order = sa.case(
(heuristic_rules.c.tier == "critical", 0),
(heuristic_rules.c.tier == "high", 1),
(heuristic_rules.c.tier == "medium", 2),
(heuristic_rules.c.tier == "low", 3),
else_=4,
)
with self._conn() as conn:
q = sa.select(heuristic_rules).order_by(tier_order, heuristic_rules.c.priority.desc())
if enabled_only:
q = q.where(heuristic_rules.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled", "builtin") for r in rows]
def update_heuristic_rule(self, rule_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _HEURISTIC_RULE_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
if "builtin" in fields:
fields["builtin"] = 1 if fields["builtin"] else 0
with self._conn() as conn:
result = conn.execute(
sa.update(heuristic_rules)
.where(heuristic_rules.c.rule_id == rule_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_heuristic_rule(self, rule_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(heuristic_rules).where(heuristic_rules.c.rule_id == rule_id)
)
conn.commit()
return result.rowcount > 0
# -- Output guard patterns -------------------------------------------------
def create_output_guard_pattern(
self,
pattern_id: str,
name: str,
category: str,
risk_level: str,
pattern: str,
flag_name: str,
annotation: str,
pattern_flags: str = "",
is_credential: bool = False,
redact_label: str = "",
priority: int = 0,
builtin: bool = False,
enabled: bool = True,
created_by: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
postgresql.insert(output_guard_patterns)
.values(
pattern_id=pattern_id,
name=name,
category=category,
risk_level=risk_level,
pattern=pattern,
pattern_flags=pattern_flags,
flag_name=flag_name,
annotation=annotation,
is_credential=1 if is_credential else 0,
redact_label=redact_label,
priority=priority,
builtin=1 if builtin else 0,
enabled=1 if enabled else 0,
created_by=created_by,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_output_guard_pattern(self, pattern_id: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(output_guard_patterns).where(
output_guard_patterns.c.pattern_id == pattern_id
)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled", "builtin", "is_credential")
def get_output_guard_pattern_by_name(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(output_guard_patterns).where(output_guard_patterns.c.name == name)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled", "builtin", "is_credential")
def list_output_guard_patterns(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._conn() as conn:
q = sa.select(output_guard_patterns).order_by(
output_guard_patterns.c.category, output_guard_patterns.c.priority.desc()
)
if enabled_only:
q = q.where(output_guard_patterns.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled", "builtin", "is_credential") for r in rows]
def update_output_guard_pattern(self, pattern_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _OGP_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
if "builtin" in fields:
fields["builtin"] = 1 if fields["builtin"] else 0
if "is_credential" in fields:
fields["is_credential"] = 1 if fields["is_credential"] else 0
with self._conn() as conn:
result = conn.execute(
sa.update(output_guard_patterns)
.where(output_guard_patterns.c.pattern_id == pattern_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_output_guard_pattern(self, pattern_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(output_guard_patterns).where(
output_guard_patterns.c.pattern_id == pattern_id
)
)
conn.commit()
return result.rowcount > 0
# -- TLS / ACME ------------------------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
stmt = stmt.on_conflict_do_update(
index_elements=["id"],
set_={"key_pem": key_pem},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_account_key(self, key_id: str) -> str | None:
with self._conn() as conn:
row = conn.execute(
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
).first()
return row[0] if row else None
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(tls_ca).values(name=name, cert_pem=cert_pem, key_pem=key_pem, created=now)
stmt = stmt.on_conflict_do_update(
index_elements=["name"],
set_={"cert_pem": cert_pem, "key_pem": key_pem},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
if not row:
return None
return _row_to_dict(row)
def save_tls_cert(
self,
domain: str,
cert_pem: str,
fullchain_pem: str,
key_pem: str,
issued_at: str,
expires_at: str,
meta: str | None = None,
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(tls_certificates).values(
domain=domain,
cert_pem=cert_pem,
fullchain_pem=fullchain_pem,
key_pem=key_pem,
issued_at=issued_at,
expires_at=expires_at,
meta=meta,
)
stmt = stmt.on_conflict_do_update(
index_elements=["domain"],
set_={
"cert_pem": cert_pem,
"fullchain_pem": fullchain_pem,
"key_pem": key_pem,
"issued_at": issued_at,
"expires_at": expires_at,
"meta": meta,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
with self._conn() as conn:
row = conn.execute(
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
).first()
if not row:
return None
return _row_to_dict(row)
def list_tls_certs(self) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
).fetchall()
return [_row_to_dict(r) for r in rows]
def delete_tls_cert(self, domain: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
)
conn.commit()
return result.rowcount > 0
# -- Cross-node serialization ----------------------------------------------
_ADVISORY_LOCK_TRY_INTERVAL_S = 0.05
_ADVISORY_LOCK_TRY_TIMEOUT_S = 30.0
def acquire_advisory_lock_sync(self, key_text: str) -> contextlib.AbstractContextManager[None]:
"""Take a Postgres advisory lock keyed on ``hashtext(key_text)``.
perf-4: previous implementation called ``pg_advisory_xact_lock``
and held its connection for the full ``with`` body, so a burst
of concurrent waiters (e.g. 50 user-token refreshes hitting the
AS within a clock-skew window) starved the small SQLAlchemy
engine pool — every waiting backend held one of the 5 max
connections.
New shape: spin on ``pg_try_advisory_xact_lock`` with a fresh
connection per probe. Waiters return the connection to the pool
between attempts; only the actual lock holder keeps a connection
for the body. The lock auto-releases on transaction end.
"""
engine = self._engine
try_interval = self._ADVISORY_LOCK_TRY_INTERVAL_S
try_timeout = self._ADVISORY_LOCK_TRY_TIMEOUT_S
@contextlib.contextmanager
def _ctx() -> Iterator[None]:
deadline = time.monotonic() + try_timeout
while True:
conn = engine.connect()
trans: Any = None
try:
trans = conn.begin()
acquired_row = conn.execute(
sa.text("SELECT pg_try_advisory_xact_lock(hashtext(:k))"),
{"k": key_text},
).scalar()
if acquired_row:
try:
yield
finally:
# COMMIT releases the xact-scoped lock; if it
# raises (e.g., transaction rolled back by a
# network blip mid-body), the outer except
# below does a best-effort rollback so the
# lock isn't left held when conn returns to
# the pool.
if trans.is_active:
trans.commit()
return
# Didn't acquire — release tx, fall through to backoff.
trans.rollback()
except BaseException:
# Any failure in begin / execute / yield / commit —
# ensure the transaction is closed before the
# outer finally returns the connection to the pool.
if trans is not None and trans.is_active:
with contextlib.suppress(Exception):
trans.rollback()
raise
finally:
conn.close()
if time.monotonic() >= deadline:
raise TimeoutError(
f"pg_try_advisory_xact_lock timed out after {try_timeout:.1f}s "
f"for key={key_text!r}"
)
time.sleep(try_interval)
return _ctx()
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
self._engine.dispose()