Files
turnstone/turnstone/core/storage/_sqlite.py
T
Patrick Buckley 02d9c5c797 feat: workstream templates — behavioral profiles for workstream creation (#49)
* feat: workstream templates — behavioral profiles for workstream creation

Workstream templates define the complete configuration for workstream
creation: system prompt, model, auto-approve policy, per-tool
auto-approve, temperature, reasoning effort, max tokens, agent max
turns, token budget, and completion notifications. Applied once at
creation time (snapshot, not live binding). Auto-versioning captures
pre-update state on every edit.

Schema & storage:
- workstream_templates + workstream_template_versions tables (migration 011)
- ws_template_id/ws_template_version columns on workstreams table
- ws_template column on scheduled_tasks table
- Full CRUD + versioning on SQLite and PostgreSQL backends
- prompt_template_hash (SHA-256) for drift detection

Runtime:
- Template resolution before mgr.create() for model override
- Post-creation settings application (prompt, temperature, approval, budget)
- Token budget enforcement in session.send() — 80% warning, approval gate
  at 100% via __budget_override__ synthetic tool
- WebUI.auto_approve_tools server-side per-tool auto-approve
- Prompt template drift detection (hash comparison, log warning on mismatch)

Integration:
- ws_template field on CreateWorkstreamMessage, bridge, channel router,
  scheduler dispatch, MQ client
- Console admin "WS Templates" tab (11th) with CRUD, version history modal
- Profile dropdown on workstream creation modal
- WS template dropdown on scheduler create/edit modals
- Prompt template name validation on ws_template create/update
- 7 console admin API endpoints + read-only summary endpoint
- Full OpenAPI spec entries in console_spec.py
- Python SDK (sync + async) and TypeScript SDK methods
- Pydantic schemas for all request/response models

Docs & diagrams:
- New 21-ws-template-architecture.puml sequence diagram
- Updated governance, storage, MQ protocol diagrams + PNGs
- Updated architecture.md, governance.md, api-reference.md, console.md, sdk.md

48 new tests (1788 total). mypy clean. ruff clean.

* fix: address PR #49 review feedback

- auto_approve_tools uses approval_label (not just func_name) for
  consistency with tool policy evaluation
- inline system_prompt from ws_template persisted as
  _ws_template_system_prompt in workstream_config, restored on resume
  (previously lost because _template_content wasn't persisted)
- budget gate (__budget_override__) no longer bypassed by blanket
  auto_approve — requires explicit approval or tool policy allow
- diagram 21 field list corrected (removed tool_search/threshold,
  added prompt_template_hash/notify_on_complete)

* fix: address PR #49 review feedback (round 2)

- Grant admin.ws_templates permission in migration 011 (tab was hidden)
- Center WS template modals and fix radio button alignment
- Skip template validation when ws_template overrides prompt
- Guard against empty version snapshots on no-op updates
- Replace setTimeout race with Promise chain in schedule ws_template select
- Validate numeric fields in admin create/update handlers (400 not 500)
- Add ws_template to TypeScript OpenAPI specs
- Use typed Pydantic response models in SDK ws_template methods
2026-03-12 21:06:51 -07:00

2179 lines
80 KiB
Python

"""SQLite storage backend."""
from __future__ import annotations
import contextlib
import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
import sqlalchemy as sa
from turnstone.core.storage._schema import (
api_tokens,
audit_events,
conversations,
memories,
metadata,
orgs,
prompt_templates,
roles,
tool_policies,
usage_events,
user_roles,
users,
workstream_config,
workstream_template_versions,
workstream_templates,
workstreams,
)
log = logging.getLogger(__name__)
def _escape_like(s: str) -> str:
"""Escape LIKE metacharacters for use with ESCAPE '\\\\'."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _fts5_query(query: str) -> str:
"""Convert a plain search string into a safe FTS5 query."""
terms = query.split()
safe = []
for t in terms:
if t:
safe.append(f'"{t.replace(chr(34), chr(34) + chr(34))}"')
return " ".join(safe)
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
d = dict(row._mapping)
for key in bool_fields:
if key in d:
d[key] = bool(d[key])
return d
# -- Field allowlists for governance update methods ---------------------------
_ROLE_MUTABLE = frozenset({"display_name", "permissions"})
_ORG_MUTABLE = frozenset({"display_name", "settings"})
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
_WS_TEMPLATE_MUTABLE = frozenset(
{
"name",
"description",
"system_prompt",
"prompt_template",
"prompt_template_hash",
"model",
"auto_approve",
"auto_approve_tools",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"notify_on_complete",
"enabled",
}
)
class SQLiteBackend:
"""SQLite implementation of the StorageBackend protocol."""
def __init__(self, path: str, *, create_tables: bool = True) -> None:
self._path = path
self._engine = sa.create_engine(
f"sqlite:///{path}",
pool_pre_ping=True,
connect_args={"check_same_thread": False},
)
self._fts5_available = False
if create_tables:
self._init_schema()
def _init_schema(self) -> None:
"""Create tables and FTS5 index."""
metadata.create_all(self._engine)
# Try to set up FTS5 for full-text search
with self._engine.connect() as conn:
try:
fts_exists = conn.execute(
sa.text(
"SELECT 1 FROM sqlite_master "
"WHERE type='table' AND name='conversations_fts'"
)
).fetchone()
if not fts_exists:
conn.execute(
sa.text(
"CREATE VIRTUAL TABLE conversations_fts "
"USING fts5(content, content=conversations, content_rowid=id)"
)
)
conn.execute(
sa.text(
"INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')"
)
)
conn.commit()
self._fts5_available = True
except Exception:
self._fts5_available = False
# -- Core conversation operations ------------------------------------------
def save_message(
self,
ws_id: str,
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(
sa.insert(conversations),
{
"ws_id": ws_id,
"timestamp": now,
"role": role,
"content": content,
"tool_name": tool_name,
"tool_args": tool_args,
"tool_call_id": tool_call_id,
"provider_data": provider_data,
},
)
# FTS5 indexing
if self._fts5_available and content:
try:
rowid = result.lastrowid
conn.execute(
sa.text(
"INSERT INTO conversations_fts(rowid, content) VALUES (:rowid, :content)"
),
{"rowid": rowid, "content": content},
)
except Exception:
self._fts5_available = False
# Bump workstream updated timestamp
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(updated=now)
)
conn.commit()
def load_messages(self, ws_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
conversations.c.role,
conversations.c.content,
conversations.c.tool_name,
conversations.c.tool_args,
conversations.c.tool_call_id,
conversations.c.provider_data,
)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
).fetchall()
return _reconstruct_messages(list(rows), ws_id)
# -- Workstream management -------------------------------------------------
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
with self._engine.connect() as conn:
return list(
conn.execute(
sa.text(
"SELECT w.ws_id, w.alias, w.title, 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) "
"ORDER BY w.updated DESC LIMIT :limit"
),
{"limit": limit},
).fetchall()
)
def prune_workstreams(self, retention_days: int = 90) -> tuple[int, int]:
orphans = stale = 0
with self._engine.connect() as conn:
# 1. Remove workstreams with no messages
orphan_ids = [
row[0]
for row in 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()
]
if orphan_ids:
placeholders = ",".join([":p" + str(i) for i in range(len(orphan_ids))])
params = {f"p{i}": oid for i, oid in enumerate(orphan_ids)}
conn.execute(
sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"),
params,
)
result = conn.execute(
sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"),
params,
)
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_ids = [
row[0]
for row in conn.execute(
sa.text(
"SELECT ws_id FROM workstreams "
"WHERE alias IS NULL AND updated < :cutoff"
),
{"cutoff": cutoff},
).fetchall()
]
if stale_ids:
placeholders = ",".join([":p" + str(i) for i in range(len(stale_ids))])
params = {f"p{i}": sid for i, sid in enumerate(stale_ids)}
conn.execute(
sa.text(f"DELETE FROM workstream_config WHERE ws_id IN ({placeholders})"),
params,
)
conn.execute(
sa.text(f"DELETE FROM conversations WHERE ws_id IN ({placeholders})"),
params,
)
result = conn.execute(
sa.text(f"DELETE FROM workstreams WHERE ws_id IN ({placeholders})"),
params,
)
stale = result.rowcount
conn.commit()
return (orphans, stale)
def resolve_workstream(self, alias_or_id: str) -> str | None:
with self._engine.connect() as conn:
# 1. Exact alias match
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 match
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. ws_id 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:
with self._engine.connect() as conn:
for key, value in config.items():
conn.execute(
sa.text(
"INSERT OR REPLACE INTO workstream_config "
"(ws_id, key, value) VALUES (:wid, :key, :value)"
),
{"wid": ws_id, "key": key, "value": value},
)
conn.commit()
def load_workstream_config(self, ws_id: str) -> dict[str, str]:
with self._engine.connect() 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._engine.connect() 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._engine.connect() as conn:
row = conn.execute(
sa.select(workstreams.c.alias, workstreams.c.title).where(
workstreams.c.ws_id == ws_id
)
).fetchone()
if row:
value = row[0] or row[1]
return str(value) if value is not None else None
return None
def update_workstream_title(self, ws_id: str, title: str) -> None:
with self._engine.connect() as conn:
conn.execute(
sa.update(workstreams).where(workstreams.c.ws_id == ws_id).values(title=title)
)
conn.commit()
# -- Generic key-value store -----------------------------------------------
def kv_get(self, key: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(memories.c.value).where(memories.c.key == key)).fetchone()
return str(row[0]) if row else None
def kv_set(self, key: str, value: str) -> str | None:
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(memories.c.value).where(memories.c.key == key)
).fetchone()
old_value = str(existing[0]) if existing else None
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.text(
"INSERT OR REPLACE INTO memories (key, value, created, updated) "
"VALUES (:key, :value, "
"COALESCE((SELECT created FROM memories WHERE key = :key), :now), "
":now)"
),
{"key": key, "value": value, "now": now},
)
conn.commit()
return old_value
def kv_delete(self, key: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.commit()
return result.rowcount > 0
def kv_list(self) -> list[tuple[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(memories.c.key, memories.c.value).order_by(memories.c.key)
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
def kv_search(self, query: str) -> list[tuple[str, str]]:
if not query or not query.strip():
return self.kv_list()
terms = query.split()
with self._engine.connect() as conn:
# Build WHERE clause: each term must match key OR value
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(f"(key LIKE :k{i} ESCAPE '\\' OR value LIKE :v{i} ESCAPE '\\')")
params[f"k{i}"] = f"%{escaped}%"
params[f"v{i}"] = f"%{escaped}%"
rows = conn.execute(
sa.text(
"SELECT key, value FROM memories WHERE "
+ " AND ".join(clauses)
+ " ORDER BY key"
),
params,
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
# -- 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,
ws_template_id: str = "",
ws_template_version: int = 0,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstreams).prefix_with("OR IGNORE"),
{
"ws_id": ws_id,
"node_id": node_id,
"user_id": user_id,
"alias": alias,
"title": title,
"name": name,
"state": state,
"ws_template_id": ws_template_id,
"ws_template_version": ws_template_version,
"created": now,
"updated": now,
},
)
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._engine.connect() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(state=state, updated=now)
)
conn.commit()
def update_workstream_template(
self, ws_id: str, ws_template_id: str, ws_template_version: int
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.update(workstreams)
.where(workstreams.c.ws_id == ws_id)
.values(
ws_template_id=ws_template_id,
ws_template_version=ws_template_version,
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._engine.connect() 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._engine.connect() as conn:
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))
result = conn.execute(sa.delete(workstreams).where(workstreams.c.ws_id == ws_id))
conn.commit()
return result.rowcount > 0
def list_workstreams(self, node_id: str | None = None, limit: int = 100) -> list[Any]:
with self._engine.connect() 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,
)
.order_by(workstreams.c.updated.desc())
.limit(limit)
)
if node_id is not None:
q = q.where(workstreams.c.node_id == node_id)
return list(conn.execute(q).fetchall())
# -- Conversation search ---------------------------------------------------
def search_history(self, query: str, limit: int = 20) -> list[Any]:
if not query or not query.strip():
return []
capped = min(limit, 100)
with self._engine.connect() as conn:
if self._fts5_available:
return list(
conn.execute(
sa.text(
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
"FROM conversations_fts f "
"JOIN conversations c ON c.id = f.rowid "
"WHERE conversations_fts MATCH :query "
"ORDER BY f.rank ASC LIMIT :limit"
),
{"query": _fts5_query(query), "limit": capped},
).fetchall()
)
return list(
conn.execute(
sa.text(
"SELECT timestamp, ws_id, role, content, tool_name "
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
"ORDER BY timestamp DESC LIMIT :limit"
),
{"pattern": f"%{_escape_like(query)}%", "limit": capped},
).fetchall()
)
def search_history_recent(self, limit: int = 20) -> list[Any]:
capped = min(limit, 100)
with self._engine.connect() 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._engine.connect() as conn:
conn.execute(
sa.insert(users).prefix_with("OR IGNORE"),
{
"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._engine.connect() 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._engine.connect() 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._engine.connect() 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._engine.connect() 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 delete_user(self, user_id: str) -> bool:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() 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))
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._engine.connect() 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._engine.connect() 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._engine.connect() 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._engine.connect() 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 turnstone.core.storage._schema import channel_users
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(channel_users).prefix_with("OR IGNORE"),
{
"channel_type": channel_type,
"channel_user_id": channel_user_id,
"user_id": user_id,
"created": now,
},
)
conn.commit()
def get_channel_user(self, channel_type: str, channel_user_id: str) -> dict[str, str] | None:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() 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:
from turnstone.core.storage._schema import channel_users
with self._engine.connect() 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 turnstone.core.storage._schema import channel_routes
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(channel_routes).prefix_with("OR IGNORE"),
{
"channel_type": channel_type,
"channel_id": channel_id,
"ws_id": ws_id,
"node_id": node_id,
"created": now,
},
)
conn.commit()
def get_channel_route(self, channel_type: str, channel_id: str) -> dict[str, str] | None:
from turnstone.core.storage._schema import channel_routes
with self._engine.connect() 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:
from turnstone.core.storage._schema import channel_routes
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import channel_routes
with self._engine.connect() 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:
from turnstone.core.storage._schema import channel_routes
with self._engine.connect() 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,
template: str = "",
ws_template: str = "",
) -> None:
from turnstone.core.storage._schema import scheduled_tasks
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(scheduled_tasks).prefix_with("OR IGNORE"),
{
"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),
"template": template,
"ws_template": ws_template,
"enabled": 1,
"created_by": created_by,
"next_run": next_run,
"created": now,
"updated": now,
},
)
conn.commit()
def get_scheduled_task(self, task_id: str) -> dict[str, Any] | None:
from turnstone.core.storage._schema import scheduled_tasks
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import scheduled_tasks
with self._engine.connect() 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",
"template",
"ws_template",
"enabled",
"last_run",
"next_run",
"updated",
}
)
def update_scheduled_task(self, task_id: str, **fields: Any) -> bool:
from turnstone.core.storage._schema import scheduled_tasks
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")
# Normalize boolean → int for auto_approve
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._engine.connect() 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:
from turnstone.core.storage._schema import scheduled_task_runs, scheduled_tasks
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import scheduled_tasks
with self._engine.connect() 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:
from turnstone.core.storage._schema import scheduled_task_runs
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import scheduled_task_runs
with self._engine.connect() 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:
from datetime import timedelta
from turnstone.core.storage._schema import scheduled_task_runs
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() 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 turnstone.core.storage._schema import watches
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(watches).prefix_with("OR IGNORE"),
{
"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,
},
)
conn.commit()
def get_watch(self, watch_id: str) -> dict[str, Any] | None:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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 list_watches_for_ws(self, ws_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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 list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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:
from turnstone.core.storage._schema import watches
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._engine.connect() 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:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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:
from turnstone.core.storage._schema import watches
with self._engine.connect() 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:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from turnstone.core.storage._schema import services
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = sqlite_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=["service_type", "service_id"],
set_={"url": url, "metadata": metadata, "last_heartbeat": now},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def heartbeat_service(self, service_type: str, service_id: str) -> bool:
from turnstone.core.storage._schema import services
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() 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]]:
from turnstone.core.storage._schema import services
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._engine.connect() 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:
from turnstone.core.storage._schema import services
with self._engine.connect() 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
# -- 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._engine.connect() as conn:
conn.execute(
sa.insert(roles).prefix_with("OR IGNORE"),
{
"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._engine.connect() 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._engine.connect() 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._engine.connect() 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._engine.connect() 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._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.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._engine.connect() as conn:
conn.execute(
sa.insert(user_roles).prefix_with("OR IGNORE"),
{
"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._engine.connect() 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._engine.connect() 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 get_user_permissions(self, user_id: str) -> set[str]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(roles.c.permissions)
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
.where(user_roles.c.user_id == user_id)
).fetchall()
perms: set[str] = set()
for r in rows:
if r[0]:
for p in r[0].split(","):
p = p.strip()
if p:
perms.add(p)
return perms
# -- 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._engine.connect() as conn:
conn.execute(
sa.insert(orgs).prefix_with("OR IGNORE"),
{
"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._engine.connect() 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._engine.connect() 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._engine.connect() 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._engine.connect() 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()
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._engine.connect() 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._engine.connect() 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._engine.connect() as conn:
result = conn.execute(
sa.update(tool_policies)
.where(tool_policies.c.policy_id == policy_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_tool_policy(self, policy_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- 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,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
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,
"created": now,
"updated": now,
},
)
conn.commit()
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
with self._engine.connect() 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")
return None
def get_prompt_template_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() 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")
return None
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() 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)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
def list_default_templates(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = (
sa.select(prompt_templates)
.where(prompt_templates.c.is_default == 1)
.order_by(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") for r in rows]
def list_prompt_templates_by_origin(self, origin: str) -> list[dict[str, Any]]:
with self._engine.connect() 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") for r in rows]
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
dropped = set(fields) - _TEMPLATE_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 _TEMPLATE_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"])
with self._engine.connect() 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 delete_prompt_template(self, template_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
)
conn.commit()
return result.rowcount > 0
# -- Workstream templates --------------------------------------------------
def create_ws_template(
self,
ws_template_id: str,
name: str,
description: str = "",
system_prompt: str = "",
prompt_template: str = "",
prompt_template_hash: str = "",
model: str = "",
auto_approve: bool = False,
auto_approve_tools: str = "",
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 = "{}",
org_id: str = "",
created_by: str = "",
enabled: bool = True,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_templates),
{
"ws_template_id": ws_template_id,
"name": name,
"description": description,
"system_prompt": system_prompt,
"prompt_template": prompt_template,
"prompt_template_hash": prompt_template_hash,
"model": model,
"auto_approve": 1 if auto_approve else 0,
"auto_approve_tools": auto_approve_tools,
"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,
"org_id": org_id,
"created_by": created_by,
"enabled": 1 if enabled else 0,
"version": 1,
"created": now,
"updated": now,
},
)
conn.commit()
def get_ws_template(self, ws_template_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def get_ws_template_by_name(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(workstream_templates).where(workstream_templates.c.name == name)
).fetchone()
if row:
return _row_to_dict(row, "auto_approve", "enabled")
return None
def list_ws_templates(
self, org_id: str = "", enabled_only: bool = False
) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(workstream_templates).order_by(workstream_templates.c.name)
if org_id:
q = q.where(workstream_templates.c.org_id == org_id)
if enabled_only:
q = q.where(workstream_templates.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "auto_approve", "enabled") for r in rows]
def update_ws_template(self, ws_template_id: str, changed_by: str = "", **fields: Any) -> bool:
with self._engine.connect() as conn:
# Snapshot current state before updating
current = conn.execute(
sa.select(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
).fetchone()
if not current:
return False
cur = _row_to_dict(current, "auto_approve", "enabled")
# Filter to allowed fields — skip snapshot if no effective changes
dropped = set(fields) - _WS_TEMPLATE_MUTABLE
if dropped:
log.warning("update_ws_template: ignoring unknown fields: %s", dropped)
fields = {k: v for k, v in fields.items() if k in _WS_TEMPLATE_MUTABLE}
if not fields:
return True # Nothing to update
# Create version snapshot
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": cur["version"],
"snapshot": json.dumps(cur, default=str),
"changed_by": changed_by,
"created": now,
},
)
fields["updated"] = now
fields["version"] = cur["version"] + 1
if "auto_approve" in fields:
fields["auto_approve"] = int(fields["auto_approve"])
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
result = conn.execute(
sa.update(workstream_templates)
.where(workstream_templates.c.ws_template_id == ws_template_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_ws_template(self, ws_template_id: str) -> bool:
with self._engine.connect() as conn:
# Cascade-delete versions first
conn.execute(
sa.delete(workstream_template_versions).where(
workstream_template_versions.c.ws_template_id == ws_template_id
)
)
result = conn.execute(
sa.delete(workstream_templates).where(
workstream_templates.c.ws_template_id == ws_template_id
)
)
conn.commit()
return result.rowcount > 0
def create_ws_template_version(
self,
ws_template_id: str,
version: int,
snapshot: str,
changed_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(workstream_template_versions),
{
"ws_template_id": ws_template_id,
"version": version,
"snapshot": snapshot,
"changed_by": changed_by,
"created": now,
},
)
conn.commit()
def list_ws_template_versions(self, ws_template_id: str) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(workstream_template_versions)
.where(workstream_template_versions.c.ws_template_id == ws_template_id)
.order_by(workstream_template_versions.c.version.desc())
).fetchall()
return [_row_to_dict(r) for r in rows]
# -- 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,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() 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,
"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 = "substr(timestamp, 1, 10)"
elif group_by == "hour":
key_expr = "substr(timestamp, 1, 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) FROM usage_events WHERE {where}"
)
with self._engine.connect() 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,
}
]
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
sql = (
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
f"GROUP BY {key_expr} ORDER BY key ASC"
)
with self._engine.connect() 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,
}
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._engine.connect() as conn:
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- 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._engine.connect() 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,
) -> list[dict[str, Any]]:
with self._engine.connect() 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)
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._engine.connect() 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._engine.connect() as conn:
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
conn.commit()
return result.rowcount
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
self._engine.dispose()
def _reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
"""Reconstruct OpenAI message format from stored conversation rows.
Handles tool_call / tool_result grouping and incomplete turn repair.
"""
messages: list[dict[str, Any]] = []
i = 0
while i < len(rows):
role, content, tool_name, tool_args, tc_id, provider_data = rows[i]
if role == "user":
messages.append({"role": "user", "content": content or ""})
i += 1
elif role == "assistant":
msg: dict[str, Any] = {"role": "assistant", "content": content}
if provider_data:
with contextlib.suppress(json.JSONDecodeError, TypeError):
msg["_provider_content"] = json.loads(provider_data)
messages.append(msg)
i += 1
elif role == "tool_call":
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": None,
"tool_calls": [],
}
if (
messages
and messages[-1]["role"] == "assistant"
and not messages[-1].get("tool_calls")
):
assistant_msg = messages.pop()
assistant_msg["tool_calls"] = []
while i < len(rows) and rows[i][0] == "tool_call":
_, _, tn, ta, stored_tc_id, _ = rows[i]
call_id = stored_tc_id or f"call_{ws_id}_{i}"
assistant_msg["tool_calls"].append(
{
"id": call_id,
"type": "function",
"function": {"name": tn or "", "arguments": ta or ""},
}
)
i += 1
messages.append(assistant_msg)
# Consume matching tool_result rows
result_idx = 0
while i < len(rows) and rows[i][0] == "tool_result":
_, result_content, _, _, result_tc_id, _ = rows[i]
if result_tc_id:
tc_id_to_use = result_tc_id
elif result_idx < len(assistant_msg["tool_calls"]):
tc_id_to_use = assistant_msg["tool_calls"][result_idx]["id"]
else:
tc_id_to_use = f"call_orphan_{i}"
messages.append(
{
"role": "tool",
"tool_call_id": tc_id_to_use,
"content": result_content or "",
}
)
result_idx += 1
i += 1
elif role == "tool_result":
# Orphaned tool_result (no preceding tool_call) — skip
i += 1
else:
i += 1
# Repair: strip trailing incomplete tool call turns
while messages:
tail_tools = 0
for j in range(len(messages) - 1, -1, -1):
if messages[j].get("role") == "tool":
tail_tools += 1
else:
break
asst_idx = len(messages) - 1 - tail_tools
if asst_idx < 0:
break
asst = messages[asst_idx]
if asst.get("role") != "assistant" or not asst.get("tool_calls"):
break
if tail_tools >= len(asst["tool_calls"]):
break
del messages[asst_idx:]
return messages