Files
turnstone/turnstone/core/storage/_postgresql.py
T
Patrick Buckley d068366a61 rbac: builtin-role override editor + tighten under-enforced perm gates (#585)
* feat(rbac): editable builtin role permissions via overlay layer

Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.

Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.

Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
  rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
  audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
  targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
  zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
  seeded into builtin-admin by migration 042 but never registered with
  the validator, so the very first round-trip through the editor 400'd
  on it. Drift-detection test guards future migrations from recreating
  the same gap

Frontend
- Roles tab redesign: chevron + permission-count chip replace the
  "..." truncation; expand-on-click drawer groups perms by namespace
  with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
  toggles show baseline-default vs override state; submit diffs against
  the rendered toggle universe (not raw baseline) so future taxonomy
  drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
  drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
  perms (admin.coordinator, admin.cluster.inspect, admin.models,
  admin.nodes, admin.prompt_policies, conversation.modify,
  coordinator.trust.send were missing)

Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
  ``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
  lockout guard, builtin-only restriction, no-op normalization, list
  enrichment

* feat(rbac): enforce workstreams.{create,close} + tools.approve gates

These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.

Behaviour change for callers without the perms:

- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
  now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
  now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
  now 403 without ``tools.approve``

The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).

Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
  ``require_permission`` with per-conditional comments documenting the
  security policy at the choke point. 403 body names every accepted
  perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
  accept ``fallback_permissions: tuple[str, ...]`` — checked only when
  ``cfg.permission_gate is None`` (interactive case). Coord's
  ``permission_gate=_require_admin_coordinator`` continues to take
  precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
  same OR check before proxying — fail fast on a forbidden request
  without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
  only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
  authenticated-only (pre-existing, out of scope for this audit)

Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
  pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
  test_console_routing_proxy covering 403 paths, OR fallback via
  ``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
  persistence, test_server_attachments_on_create updated to embed
  operator-shaped perms by default so existing tests continue to
  exercise the post-gate logic rather than 403'ing on the new check

Docs
- ``bootstrap.py`` operator role line corrected to list every perm
  it actually carries (was missing ``tools.approve`` and
  ``conversation.modify``)

* fix(rbac): close lockout + escalation gaps in role-overrides editor

Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.

**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it.  The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.

The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.

**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler.  200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.

Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process.  Two queries total, independent of cluster size.  The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.

**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold.  After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee.  The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay.  Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.

Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check).  The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.

**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema).  Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.

Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
  helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
  the F-1 reproduction — operator-overlay grants admin.roles, builtin-
  admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
  pins the F-2 reframed fix — overlay-poisoned operator can't be
  assigned by a caller missing the overlay perms

* refactor(rbac): cleanup batch from /review (#584)

Five non-security findings folded into one commit so the security
batch stays focused.  All consistent with the existing intent of
``feat/builtin-role-overrides``.

**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty.  For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do.  ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.

**F-5: JS-side drift detector.**  Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught.  A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path).  Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.

**F-6: bulk enrich for ``admin_list_roles``.**  Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``.  Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.

**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything.  The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist.  Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.

**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms.  The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate.  Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.

Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``).  ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase.  Filed as a separate concern if the Roles tab grows past
the scale where it bites.

* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)

Two Copilot review findings on governance.js:

- Expand button was missing aria-expanded — screen readers couldn't
  announce drawer state.  Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
  wired.  Added data-expand-role to the row element too so the
  existing handler loop (querySelectorAll on the attribute) picks up
  both — clicking anywhere in the role row toggles the drawer.
  Edit/Delete handlers already stopPropagation so they aren't
  triggered by the row-level click.

* fix(migrations): rebase role_permission_overrides to 058

PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres.  Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.

No behaviour change — same DDL.  Full sweep clean (6730 passed).

* fix(migrations): update 058 revision strings to match filename

Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057.  Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.

Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
2026-05-24 18:31:23 -07:00

5584 lines
210 KiB
Python

"""PostgreSQL storage backend."""
from __future__ import annotations
import contextlib
import os
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from turnstone.core.storage._notify import Notify, NotifyStream
import sqlalchemy as sa
from turnstone.core.log import get_logger
from turnstone.core.storage._protocol import (
MCPOAuthPendingState,
MCPPendingConsentRow,
MCPUserToken,
MCPUserTokenMetadataRow,
OIDCIdentity,
OIDCPendingState,
)
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,
orgs,
output_assessments,
output_guard_patterns,
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 (
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
)
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 (
POLICY_MUTABLE as _POLICY_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 (
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
)
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
from turnstone.core.storage._utils import (
row_to_dict as _row_to_dict,
)
from turnstone.core.storage._utils import sanitize_text
from turnstone.core.storage._utils import (
scan_skill_content as _scan_skill_content,
)
from turnstone.core.storage._utils import (
split_perms as _split_perms,
)
from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__)
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:
"""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,
reminders: str | None = None,
) -> int:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
content = sanitize_text(content)
provider_data = sanitize_text(provider_data)
source = sanitize_text(source)
reminders = sanitize_text(reminders)
with self._conn() as conn:
result = conn.execute(
sa.insert(conversations)
.values(
ws_id=ws_id,
timestamp=now,
role=role,
content=content,
tool_name=tool_name,
tool_call_id=tool_call_id,
provider_data=provider_data,
tool_calls=tool_calls,
_source=source,
_reminders=reminders,
)
.returning(conversations.c.id)
)
rowid = int(result.scalar_one())
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
)
conn.commit()
return rowid
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 = []
ws_ids: set[str] = set()
for row in rows:
ws_ids.add(row["ws_id"])
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": sanitize_text(row.get("provider_data")),
"tool_calls": row.get("tool_calls"),
"_source": sanitize_text(row.get("source")),
"_reminders": sanitize_text(row.get("reminders")),
}
)
with self._conn() as conn:
conn.execute(sa.insert(conversations), insert_rows)
for wid in ws_ids:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == wid).values(updated=now)
)
conn.commit()
def load_messages(
self, ws_id: str, *, limit: int | None = None, repair: bool = True
) -> list[dict[str, Any]]:
with self._conn() as conn:
if limit is not None and limit > 0:
rows = conn.execute(
sa.select(
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._reminders,
)
.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(
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._reminders,
)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
).fetchall()
# Bound the attachment scan to the fetched message ids when
# tail-N was requested — otherwise the attachments query
# still scans every row for the workstream and partially
# defeats the conversations-table LIMIT.
message_ids: list[int] | None = None
if limit is not None and limit > 0:
message_ids = [r[0] for r in rows]
attachments = self.load_attachments_for_messages(ws_id, message_ids=message_ids)
return _reconstruct_messages(list(rows), ws_id, attachments or None, repair=repair)
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
with self._conn() as conn:
cutoff_row = conn.execute(
sa.select(conversations.c.id)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
.limit(1)
.offset(keep_count)
).fetchone()
if cutoff_row is None:
return 0
cutoff_id = cutoff_row[0]
# Cascade-delete attachments linked to doomed messages so
# rewind/retry flows don't leak orphan BLOBs.
conn.execute(
sa.delete(workstream_attachments).where(
sa.and_(
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.message_id >= cutoff_id,
)
)
)
result = conn.execute(
sa.delete(conversations).where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c.id >= cutoff_id,
)
)
)
conn.commit()
return result.rowcount
# -- 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,
) -> list[Any]:
# See SQLite sibling for the rationale on the kind / user_id / state filters.
params: dict[str, Any] = {"limit": limit}
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 "
"FROM workstreams w "
"WHERE EXISTS "
" (SELECT 1 FROM conversations c WHERE c.ws_id = w.ws_id) "
f"{kind_clause}"
f"{user_clause}"
f"{state_clause}"
"ORDER BY w.updated DESC LIMIT :limit"
),
params,
).fetchall()
)
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
orphans = stale = 0
with self._conn() as conn:
# 1. Remove workstreams with no messages
orphan_rows = conn.execute(
sa.text(
"SELECT ws_id FROM workstreams "
"WHERE NOT EXISTS "
" (SELECT 1 FROM conversations c "
" WHERE c.ws_id = workstreams.ws_id)"
)
).fetchall()
orphan_ids = [r[0] for r in orphan_rows]
if orphan_ids:
chunk_size = 10_000
for i in range(0, len(orphan_ids), chunk_size):
chunk = orphan_ids[i : i + chunk_size]
conn.execute(
sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(chunk))
)
result = conn.execute(
sa.delete(workstreams).where(workstreams.c.ws_id.in_(chunk))
)
orphans += result.rowcount
# 2. Remove old unnamed workstreams
if retention_days > 0:
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
stale_rows = conn.execute(
sa.select(workstreams.c.ws_id).where(
workstreams.c.alias.is_(None),
workstreams.c.updated < cutoff,
)
).fetchall()
stale_ids = [r[0] for r in stale_rows]
if stale_ids:
chunk_size = 10_000
for i in range(0, len(stale_ids), chunk_size):
chunk = stale_ids[i : i + chunk_size]
conn.execute(
sa.delete(conversations).where(conversations.c.ws_id.in_(chunk))
)
conn.execute(
sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(chunk))
)
result = conn.execute(
sa.delete(workstreams).where(workstreams.c.ws_id.in_(chunk))
)
stale += result.rowcount
conn.commit()
return (orphans, stale)
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)
).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)
).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 + "%"))
).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:
if not 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 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
)
).fetchall()
return {row[0]: row[1] for row in rows}
# -- 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 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,
) -> None:
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
# 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,
created=now,
updated=now,
)
stmt = stmt.on_conflict_do_nothing(index_elements=["ws_id"])
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
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 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(self, ws_id: str) -> bool:
with self._conn() as conn:
conn.execute(
sa.delete(workstream_attachments).where(workstream_attachments.c.ws_id == ws_id)
)
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)
)
result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id))
conn.commit()
return result.rowcount > 0
# -- Workstream attachments ------------------------------------------------
def save_attachment(
self,
attachment_id: str,
ws_id: str,
user_id: str,
filename: str,
mime_type: str,
size_bytes: int,
kind: str,
content: bytes,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.insert(workstream_attachments),
{
"attachment_id": attachment_id,
"ws_id": ws_id,
"user_id": user_id,
"filename": filename,
"mime_type": mime_type,
"size_bytes": size_bytes,
"kind": kind,
"content": content,
"message_id": None,
"created": now,
},
)
conn.commit()
def list_pending_attachments(self, ws_id: str, user_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
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(
sa.and_(
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.message_id.is_(None),
workstream_attachments.c.reserved_for_msg_id.is_(None),
)
)
.order_by(workstream_attachments.c.created)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_attachments(self, attachment_ids: list[str]) -> list[dict[str, Any]]:
if not attachment_ids:
return []
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_attachments).where(
workstream_attachments.c.attachment_id.in_(attachment_ids)
)
).fetchall()
return [dict(r._mapping) for r in rows]
def get_pending_attachments_with_content(
self, ws_id: str, user_id: str
) -> list[dict[str, Any]]:
with self._conn() as conn:
rows = conn.execute(
sa.select(workstream_attachments)
.where(
sa.and_(
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.message_id.is_(None),
workstream_attachments.c.reserved_for_msg_id.is_(None),
)
)
.order_by(workstream_attachments.c.created)
).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 delete_attachment(self, attachment_id: str, ws_id: str, user_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(workstream_attachments).where(
sa.and_(
workstream_attachments.c.attachment_id == attachment_id,
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.message_id.is_(None),
workstream_attachments.c.reserved_for_msg_id.is_(None),
)
)
)
conn.commit()
return result.rowcount > 0
def mark_attachments_consumed(
self,
attachment_ids: list[str],
message_id: int,
ws_id: str,
user_id: str,
reserved_for_msg_id: str | None = None,
) -> None:
if not attachment_ids:
return
predicate = sa.and_(
workstream_attachments.c.attachment_id.in_(attachment_ids),
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.message_id.is_(None),
)
if reserved_for_msg_id is not None:
predicate = sa.and_(
predicate,
workstream_attachments.c.reserved_for_msg_id == reserved_for_msg_id,
)
with self._conn() as conn:
conn.execute(
sa.update(workstream_attachments)
.where(predicate)
.values(
message_id=message_id,
reserved_for_msg_id=None,
reserved_at=None,
)
)
conn.commit()
def reserve_attachments(
self,
attachment_ids: list[str],
queue_msg_id: str,
ws_id: str,
user_id: str,
) -> list[str]:
if not attachment_ids or not queue_msg_id:
return []
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
conn.execute(
sa.update(workstream_attachments)
.where(
sa.and_(
workstream_attachments.c.attachment_id.in_(attachment_ids),
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.message_id.is_(None),
workstream_attachments.c.reserved_for_msg_id.is_(None),
)
)
.values(reserved_for_msg_id=queue_msg_id, reserved_at=now)
)
rows = conn.execute(
sa.select(workstream_attachments.c.attachment_id).where(
sa.and_(
workstream_attachments.c.attachment_id.in_(attachment_ids),
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.reserved_for_msg_id == queue_msg_id,
)
)
).fetchall()
conn.commit()
return [r[0] for r in rows]
def unreserve_attachments(self, queue_msg_id: str, ws_id: str, user_id: str) -> None:
if not queue_msg_id:
return
with self._conn() as conn:
conn.execute(
sa.update(workstream_attachments)
.where(
sa.and_(
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.user_id == user_id,
workstream_attachments.c.reserved_for_msg_id == queue_msg_id,
)
)
.values(reserved_for_msg_id=None, reserved_at=None)
)
conn.commit()
def sweep_orphan_reservations(self, older_than_seconds: int) -> int:
if older_than_seconds <= 0:
return 0
cutoff = (datetime.now(UTC) - timedelta(seconds=older_than_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._conn() as conn:
result = conn.execute(
sa.update(workstream_attachments)
.where(
sa.and_(
workstream_attachments.c.reserved_for_msg_id.is_not(None),
workstream_attachments.c.message_id.is_(None),
workstream_attachments.c.reserved_at.is_not(None),
workstream_attachments.c.reserved_at < cutoff,
)
)
.values(reserved_for_msg_id=None, reserved_at=None)
)
conn.commit()
return int(result.rowcount or 0)
def load_attachments_for_messages(
self,
ws_id: str,
*,
message_ids: list[int] | None = None,
) -> dict[int, list[dict[str, Any]]]:
with self._conn() as conn:
where_clauses = [
workstream_attachments.c.ws_id == ws_id,
workstream_attachments.c.message_id.is_not(None),
]
if message_ids is not None:
if not message_ids:
return {}
where_clauses.append(workstream_attachments.c.message_id.in_(message_ids))
rows = conn.execute(
sa.select(workstream_attachments)
.where(sa.and_(*where_clauses))
.order_by(workstream_attachments.c.created)
).fetchall()
grouped: dict[int, list[dict[str, Any]]] = {}
for r in rows:
row = dict(r._mapping)
mid = row["message_id"]
grouped.setdefault(mid, []).append(row)
return grouped
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,
)
.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)
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()).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)
)
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) -> list[Any]:
if not query or not query.strip():
return []
capped = min(int(limit), 100)
capped_offset = max(0, int(offset))
with self._conn() as conn:
# Use PostgreSQL full-text search if search_vector column exists
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', COALESCE(c.content, '')) "
" @@ plainto_tsquery('english', :query) "
"ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
" plainto_tsquery('english', :query)) DESC "
"LIMIT :limit OFFSET :offset"
),
{"query": query, "limit": capped, "offset": capped_offset},
).fetchall()
)
except Exception:
# Fallback to ILIKE
return list(
conn.execute(
sa.text(
"SELECT timestamp, ws_id, role, content, tool_name "
"FROM conversations WHERE content ILIKE :pattern "
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
),
{"pattern": f"%{query}%", "limit": capped, "offset": capped_offset},
).fetchall()
)
def search_history_recent(self, limit: int = 20) -> list[Any]:
capped = min(limit, 100)
with self._conn() as conn:
return list(
conn.execute(
sa.text(
"SELECT timestamp, ws_id, role, content, tool_name "
"FROM conversations ORDER BY timestamp DESC LIMIT :limit"
),
{"limit": capped},
).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(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 = "[]",
) -> 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,
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",
"notify_targets",
"enabled",
"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,
).where(workstreams.c.ws_id.in_(clean))
).fetchall()
for r in rows:
out[r[0]] = {
"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],
}
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:
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(sa.insert(intent_verdicts), 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:
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 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 update_structured_memory(self, memory_id: str, **fields: str) -> bool:
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
if not fields:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
fields["updated"] = now
fields["last_accessed"] = now
with self._conn() as conn:
result = conn.execute(
sa.update(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory_by_id(self, memory_id: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id)
)
conn.commit()
return result.rowcount > 0
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 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,
last_ws_id: str | None,
last_tool_call_id: 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,
last_ws_id=last_ws_id,
last_tool_call_id=last_tool_call_id,
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_ws_id": stmt.excluded.last_ws_id,
"last_tool_call_id": stmt.excluded.last_tool_call_id,
"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"],
last_ws_id=m["last_ws_id"],
last_tool_call_id=m["last_tool_call_id"],
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_oauth_user_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 == "oauth_user")
.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,
) -> 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,
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, **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:
result = conn.execute(
sa.update(model_definitions)
.where(model_definitions.c.definition_id == definition_id)
.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
# -- OIDC identity ---------------------------------------------------------
def create_oidc_user(
self,
user_id: str,
username: str,
display_name: str,
password_hash: str,
issuer: str,
subject: str,
email: 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,
},
)
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,
).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],
)
return None
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.update(oidc_identities)
.where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
.values(last_login=now)
)
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,
)
.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],
)
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 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
# -- 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()