chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""User API module - provides all user-related API endpoints"""
from .routes import user
__all__ = ["user"]
+15
View File
@@ -0,0 +1,15 @@
"""Agents module."""
from .routes import agents_ns
from .sharing import agents_sharing_ns
from .webhooks import agents_webhooks_ns
from .folders import agents_folders_ns
from .portability import agents_portability_ns
__all__ = [
"agents_ns",
"agents_sharing_ns",
"agents_webhooks_ns",
"agents_folders_ns",
"agents_portability_ns",
]
+366
View File
@@ -0,0 +1,366 @@
"""
Agent folders management routes.
Provides virtual folder organization for agents (Google Drive-like structure).
"""
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource, fields
from sqlalchemy import text as _sql_text
from application.api import api
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly, db_session
agents_folders_ns = Namespace(
"agents_folders", description="Agent folder management", path="/api/agents/folders"
)
def _resolve_folder_id(repo: AgentFoldersRepository, folder_id: str, user: str):
"""Resolve a folder id that may be either a UUID or legacy Mongo ObjectId."""
if not folder_id:
return None
if looks_like_uuid(folder_id):
row = repo.get(folder_id, user)
if row is not None:
return row
return repo.get_by_legacy_id(folder_id, user)
def _folder_error_response(message: str, err: Exception):
current_app.logger.error(f"{message}: {err}", exc_info=True)
return make_response(jsonify({"success": False, "message": message}), 400)
def _serialize_folder(f: dict) -> dict:
created_at = f.get("created_at")
updated_at = f.get("updated_at")
return {
"id": str(f["id"]),
"name": f.get("name"),
"parent_id": str(f["parent_id"]) if f.get("parent_id") else None,
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at,
"updated_at": updated_at.isoformat() if hasattr(updated_at, "isoformat") else updated_at,
}
@agents_folders_ns.route("/")
class AgentFolders(Resource):
@api.doc(description="Get all folders for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
folders = AgentFoldersRepository(conn).list_for_user(user)
result = [_serialize_folder(f) for f in folders]
return make_response(jsonify({"folders": result}), 200)
except Exception as err:
return _folder_error_response("Failed to fetch folders", err)
@api.doc(description="Create a new folder")
@api.expect(
api.model(
"CreateFolder",
{
"name": fields.String(required=True, description="Folder name"),
"parent_id": fields.String(required=False, description="Parent folder ID"),
},
)
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data or not data.get("name"):
return make_response(jsonify({"success": False, "message": "Folder name is required"}), 400)
parent_id_input = data.get("parent_id")
description = data.get("description")
try:
with db_session() as conn:
repo = AgentFoldersRepository(conn)
pg_parent_id = None
if parent_id_input:
parent = _resolve_folder_id(repo, parent_id_input, user)
if not parent:
return make_response(
jsonify({"success": False, "message": "Parent folder not found"}),
404,
)
pg_parent_id = str(parent["id"])
folder = repo.create(
user, data["name"],
description=description,
parent_id=pg_parent_id,
)
return make_response(
jsonify(
{
"id": str(folder["id"]),
"name": folder["name"],
"parent_id": pg_parent_id,
}
),
201,
)
except Exception as err:
return _folder_error_response("Failed to create folder", err)
@agents_folders_ns.route("/<string:folder_id>")
class AgentFolder(Resource):
@api.doc(description="Get a specific folder with its agents")
def get(self, folder_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
folders_repo = AgentFoldersRepository(conn)
folder = _resolve_folder_id(folders_repo, folder_id, user)
if not folder:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
pg_folder_id = str(folder["id"])
agents_rows = conn.execute(
_sql_text(
"SELECT id, name, description FROM agents "
"WHERE user_id = :user_id AND folder_id = CAST(:fid AS uuid) "
"ORDER BY created_at DESC"
),
{"user_id": user, "fid": pg_folder_id},
).fetchall()
agents_list = [
{
"id": str(row._mapping["id"]),
"name": row._mapping["name"],
"description": row._mapping.get("description", "") or "",
}
for row in agents_rows
]
subfolders = folders_repo.list_children(pg_folder_id, user)
subfolders_list = [
{"id": str(sf["id"]), "name": sf["name"]}
for sf in subfolders
]
return make_response(
jsonify(
{
"id": pg_folder_id,
"name": folder["name"],
"parent_id": (
str(folder["parent_id"]) if folder.get("parent_id") else None
),
"agents": agents_list,
"subfolders": subfolders_list,
}
),
200,
)
except Exception as err:
return _folder_error_response("Failed to fetch folder", err)
@api.doc(description="Update a folder")
def put(self, folder_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data:
return make_response(jsonify({"success": False, "message": "No data provided"}), 400)
try:
with db_session() as conn:
repo = AgentFoldersRepository(conn)
folder = _resolve_folder_id(repo, folder_id, user)
if not folder:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
pg_folder_id = str(folder["id"])
update_fields: dict = {}
if "name" in data:
update_fields["name"] = data["name"]
if "description" in data:
update_fields["description"] = data["description"]
if "parent_id" in data:
parent_input = data.get("parent_id")
if parent_input:
if parent_input == folder_id or parent_input == pg_folder_id:
return make_response(
jsonify(
{
"success": False,
"message": "Cannot set folder as its own parent",
}
),
400,
)
parent = _resolve_folder_id(repo, parent_input, user)
if not parent:
return make_response(
jsonify({"success": False, "message": "Parent folder not found"}),
404,
)
if str(parent["id"]) == pg_folder_id:
return make_response(
jsonify(
{
"success": False,
"message": "Cannot set folder as its own parent",
}
),
400,
)
update_fields["parent_id"] = str(parent["id"])
else:
update_fields["parent_id"] = None
if update_fields:
repo.update(pg_folder_id, user, update_fields)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
return _folder_error_response("Failed to update folder", err)
@api.doc(description="Delete a folder")
def delete(self, folder_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_session() as conn:
repo = AgentFoldersRepository(conn)
folder = _resolve_folder_id(repo, folder_id, user)
if not folder:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
pg_folder_id = str(folder["id"])
# Clear folder assignments from agents; self-FK
# ``ON DELETE SET NULL`` handles child folders.
AgentsRepository(conn).clear_folder_for_all(pg_folder_id, user)
deleted = repo.delete(pg_folder_id, user)
if not deleted:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
return _folder_error_response("Failed to delete folder", err)
@agents_folders_ns.route("/move_agent")
class MoveAgentToFolder(Resource):
@api.doc(description="Move an agent to a folder or remove from folder")
@api.expect(
api.model(
"MoveAgent",
{
"agent_id": fields.String(required=True, description="Agent ID to move"),
"folder_id": fields.String(required=False, description="Target folder ID (null to remove from folder)"),
},
)
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data or not data.get("agent_id"):
return make_response(jsonify({"success": False, "message": "Agent ID is required"}), 400)
agent_id_input = data["agent_id"]
folder_id_input = data.get("folder_id")
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
agent = agents_repo.get_any(agent_id_input, user)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}),
404,
)
pg_folder_id = None
if folder_id_input:
folders_repo = AgentFoldersRepository(conn)
folder = _resolve_folder_id(folders_repo, folder_id_input, user)
if not folder:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
pg_folder_id = str(folder["id"])
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
return _folder_error_response("Failed to move agent", err)
@agents_folders_ns.route("/bulk_move")
class BulkMoveAgents(Resource):
@api.doc(description="Move multiple agents to a folder")
@api.expect(
api.model(
"BulkMoveAgents",
{
"agent_ids": fields.List(fields.String, required=True, description="List of agent IDs"),
"folder_id": fields.String(required=False, description="Target folder ID"),
},
)
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data or not data.get("agent_ids"):
return make_response(jsonify({"success": False, "message": "Agent IDs are required"}), 400)
agent_ids = data["agent_ids"]
folder_id_input = data.get("folder_id")
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
pg_folder_id = None
if folder_id_input:
folders_repo = AgentFoldersRepository(conn)
folder = _resolve_folder_id(folders_repo, folder_id_input, user)
if not folder:
return make_response(
jsonify({"success": False, "message": "Folder not found"}),
404,
)
pg_folder_id = str(folder["id"])
for agent_id_input in agent_ids:
agent = agents_repo.get_any(agent_id_input, user)
if agent is not None:
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
return _folder_error_response("Failed to move agents", err)
+949
View File
@@ -0,0 +1,949 @@
"""Agent YAML export / import.
Agents are mostly references (sources, tools, prompt, models). Export
translates those into portable identifiers and never emits a secret;
import resolves them back against the importing user — matching existing
resources or creating them from the file plus user-supplied tokens.
Re-import is idempotent at every level: the agent is matched by
``metadata.id`` then ``metadata.slug``, tools by ``(type, name)``,
prompts by ``(name, content)``, custom models by
``(display_name, upstream_model_id, base_url)`` — so the same file
applied twice updates rather than duplicating.
"""
from __future__ import annotations
import re
from typing import Any, Optional
import yaml
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
from application.agents.default_tools import (
default_tool_id,
is_synthesized_tool_id,
synthesize_tool_by_name,
synthesized_tool_name_for_id,
)
from application.api import api
from application.core.model_utils import validate_model_id
from application.core.url_validation import SSRFError, validate_url
from application.security.safe_url import UnsafeUserUrlError, validate_user_base_url
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.session import db_readonly, db_session
API_VERSION = "docsgpt.arc53.com/v1"
KIND = "Agent"
# Import safety bounds (DoS): cap the document size and the number of
# child resources a single import may reference / create.
MAX_IMPORT_BYTES = 512 * 1024
MAX_LIST_ITEMS = 100
class AgentImportError(Exception):
"""Raised when an agent YAML document is malformed or unsupported."""
class _SafeNoAliasLoader(yaml.SafeLoader):
"""SafeLoader that also rejects anchors/aliases (billion-laughs guard)."""
def compose_node(self, parent, index):
if self.check_event(yaml.events.AliasEvent):
raise AgentImportError("YAML anchors/aliases are not allowed")
return super().compose_node(parent, index)
# ---------------------------------------------------------------------------
# Slug helpers
# ---------------------------------------------------------------------------
def slugify(name: Optional[str]) -> str:
"""Turn an agent name into a url-safe slug; never empty."""
s = (name or "").strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s or "agent"
def _unique_slug(
repo: AgentsRepository,
user: str,
base: str,
*,
exclude_id: Optional[str] = None,
) -> str:
"""Return ``base`` (slugified) or the first ``-N`` variant free for ``user``."""
base = slugify(base)
candidate = base
n = 2
while True:
found = repo.find_by_slug(user, candidate)
if found is None or (exclude_id and str(found["id"]) == str(exclude_id)):
return candidate
candidate = f"{base}-{n}"
n += 1
def ensure_agent_slug(conn, agent: dict, user: str) -> str:
"""Return the agent's slug, lazily assigning and persisting one if absent."""
existing = agent.get("slug")
if existing:
return str(existing)
repo = AgentsRepository(conn)
slug = _unique_slug(repo, user, agent.get("name"), exclude_id=str(agent["id"]))
repo.update(str(agent["id"]), user, {"slug": slug})
return slug
# ---------------------------------------------------------------------------
# Tool-manager access (lazy — avoids importing the tool registry at module load)
# ---------------------------------------------------------------------------
def _tool_manager():
from application.api.user.tools.routes import tool_manager
return tool_manager
def _tool_instance(tool_type: str):
return _tool_manager().tools.get(tool_type)
def _secret_field_names(config_requirements: dict, *, required_only: bool = False) -> list:
out = []
for key, spec in (config_requirements or {}).items():
if not isinstance(spec, dict) or not spec.get("secret"):
continue
if required_only and not spec.get("required"):
continue
out.append(key)
return out
def _live_requires_secrets(tool_type: str) -> list:
inst = _tool_instance(tool_type)
if inst is None:
return []
return _secret_field_names(inst.get_config_requirements() or {})
def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
"""Return only config values provably non-secret (allowlist).
Export must never leak a credential. A key is emitted ONLY if
``config_requirements`` declares it and does not mark it secret.
Anything not described by requirements — e.g. ``api_tool``/MCP free-form
``headers``, ``url`` query strings, ``server_url`` that routinely embed
tokens, or tools with empty ``config_requirements`` — is dropped. Reuse
on import matches the existing tool; create re-collects config from the
user. This blocklist-free approach can't be defeated by a stale or empty
``config_requirements``.
"""
requirements = config_requirements or {}
safe: dict[str, Any] = {}
for key, value in (stored_config or {}).items():
spec = requirements.get(key)
if isinstance(spec, dict) and not spec.get("secret"):
safe[key] = value
return safe
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def _serialize_prompt(conn, agent: dict, user: str):
prompt_id = agent.get("prompt_id")
if not prompt_id:
return "default"
row = PromptsRepository(conn).get(str(prompt_id), user)
if not row:
return "default"
return {"name": row.get("name") or "", "content": row.get("content") or ""}
def _serialize_sources(conn, agent: dict, user: str) -> list:
ids: list[str] = []
if agent.get("source_id"):
ids.append(str(agent["source_id"]))
for sid in agent.get("extra_source_ids") or []:
s = str(sid)
if s and s not in ids:
ids.append(s)
repo = SourcesRepository(conn)
out = []
for sid in ids:
row = repo.get(sid, user)
if not row:
continue
out.append(
{
"name": row.get("name") or "",
"type": row.get("type") or "",
"ref": sid,
}
)
return out
def _serialize_tools(conn, agent: dict, user: str) -> list:
repo = UserToolsRepository(conn)
out = []
for tid in agent.get("tools") or []:
tid_str = str(tid)
if is_synthesized_tool_id(tid_str):
out.append({"type": synthesized_tool_name_for_id(tid_str), "builtin": True})
continue
row = repo.get_any(tid_str, user)
if not row:
continue
requirements = row.get("config_requirements") or {}
requires_secrets = _secret_field_names(requirements)
entry: dict[str, Any] = {
"type": row.get("name") or "",
# Raw custom_name (may be "") so import matching is symmetric and
# idempotent — see _find_user_tool.
"name": row.get("custom_name") or "",
"display_name": row.get("display_name") or "",
"description": row.get("description") or "",
"config": _safe_export_config(row.get("config") or {}, requirements),
"ref": tid_str,
}
if requires_secrets:
entry["requires_secrets"] = requires_secrets
out.append(entry)
return out
def _serialize_models(conn, agent: dict, user: str) -> dict:
repo = UserCustomModelsRepository(conn)
def describe(model_id: str):
mid = str(model_id)
if looks_like_uuid(mid):
row = repo.get(mid, user)
if not row:
return None
return {
"type": "custom",
"display_name": row.get("display_name") or "",
"upstream_model_id": row.get("upstream_model_id") or "",
"base_url": row.get("base_url") or "",
"capabilities": row.get("capabilities") or {},
"requires_secrets": ["api_key"],
"ref": mid,
}
return mid
available = []
for m in agent.get("models") or []:
described = describe(m)
if described is not None:
available.append(described)
default_value = ""
raw_default = agent.get("default_model_id") or ""
if raw_default:
described = describe(raw_default)
if isinstance(described, dict):
default_value = described["display_name"]
elif isinstance(described, str):
default_value = described
return {"default": default_value, "available": available}
def serialize_agent(conn, agent: dict, user: str) -> dict:
"""Build the portable export document for an agent row."""
spec = {
"name": agent.get("name") or "",
"description": agent.get("description") or "",
"agent_type": agent.get("agent_type") or "classic",
"retriever": agent.get("retriever") or "classic",
"chunks": int(agent["chunks"]) if agent.get("chunks") is not None else None,
"prompt": _serialize_prompt(conn, agent, user),
"model": _serialize_models(conn, agent, user),
"sources": _serialize_sources(conn, agent, user),
"tools": _serialize_tools(conn, agent, user),
"limits": {
"limited_token_mode": bool(agent.get("limited_token_mode", False)),
"token_limit": agent.get("token_limit"),
"limited_request_mode": bool(agent.get("limited_request_mode", False)),
"request_limit": agent.get("request_limit"),
},
"json_schema": agent.get("json_schema"),
"allow_system_prompt_override": bool(agent.get("allow_system_prompt_override", False)),
}
return {
"apiVersion": API_VERSION,
"kind": KIND,
"metadata": {"id": str(agent["id"]), "slug": agent.get("slug") or ""},
"spec": spec,
}
class _AgentYamlDumper(yaml.SafeDumper):
"""SafeDumper that renders multi-line strings as block scalars."""
def _str_representer(dumper, data):
style = "|" if "\n" in data else None
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
_AgentYamlDumper.add_representer(str, _str_representer)
def agent_to_yaml(export: dict) -> str:
"""Serialize an export document to YAML, preserving key order."""
return yaml.dump(
export,
Dumper=_AgentYamlDumper,
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
# ---------------------------------------------------------------------------
# Parse
# ---------------------------------------------------------------------------
def parse_agent_yaml(text: str) -> dict:
"""Parse and shallow-validate an agent YAML document."""
if text and len(text) > MAX_IMPORT_BYTES:
raise AgentImportError("Import document too large")
try:
doc = yaml.load(text, Loader=_SafeNoAliasLoader)
except yaml.YAMLError as exc:
raise AgentImportError(f"Invalid YAML: {exc}") from exc
if not isinstance(doc, dict):
raise AgentImportError("Top-level YAML must be a mapping")
if doc.get("kind") != KIND:
raise AgentImportError(f"Unsupported kind {doc.get('kind')!r}; expected {KIND!r}")
if not str(doc.get("apiVersion") or "").startswith("docsgpt."):
raise AgentImportError(f"Unsupported apiVersion {doc.get('apiVersion')!r}")
spec = doc.get("spec")
if not isinstance(spec, dict):
raise AgentImportError("Missing or invalid 'spec'")
if not spec.get("name"):
raise AgentImportError("spec.name is required")
if spec.get("agent_type") == "workflow":
raise AgentImportError("Workflow agents are not supported for import yet")
for key in ("sources", "tools"):
value = spec.get(key)
if isinstance(value, list) and len(value) > MAX_LIST_ITEMS:
raise AgentImportError(f"Too many {key} (max {MAX_LIST_ITEMS})")
available = (spec.get("model") or {}).get("available")
if isinstance(available, list) and len(available) > MAX_LIST_ITEMS:
raise AgentImportError(f"Too many models (max {MAX_LIST_ITEMS})")
return doc
# ---------------------------------------------------------------------------
# Reference resolution (shared by plan + apply)
# ---------------------------------------------------------------------------
def _resolve_target(conn, user: str, metadata: dict) -> dict:
repo = AgentsRepository(conn)
metadata = metadata or {}
agent_id = metadata.get("id")
if agent_id and looks_like_uuid(str(agent_id)):
row = repo.get(str(agent_id), user)
if row:
return {
"action": "update",
"agent_id": str(row["id"]),
"matched_by": "id",
"status": row.get("status"),
}
slug = metadata.get("slug")
if slug:
row = repo.find_by_slug(user, str(slug))
if row:
return {
"action": "update",
"agent_id": str(row["id"]),
"matched_by": "slug",
"status": row.get("status"),
}
return {"action": "create", "agent_id": None, "matched_by": None, "status": None}
def _find_user_tool(user_tools: list, tool_type: str, custom_name: str):
"""Match a stored tool by ``(type, custom_name)`` for idempotent reuse.
Compares against the stored ``custom_name`` directly (export emits the
raw value), so a named tool matches exactly and an unnamed one (``""``)
reuses the first same-type tool rather than spawning a duplicate.
"""
target = custom_name or ""
for row in user_tools:
if (row.get("name") or "") != tool_type:
continue
if (row.get("custom_name") or "") == target:
return row
return None
def _find_custom_model(customs: list, spec_model: dict):
for row in customs:
if (
(row.get("display_name") or "") == (spec_model.get("display_name") or "")
and (row.get("upstream_model_id") or "") == (spec_model.get("upstream_model_id") or "")
and (row.get("base_url") or "") == (spec_model.get("base_url") or "")
):
return row
return None
def _tool_key(index: int) -> str:
return f"tool-{index}"
# ---------------------------------------------------------------------------
# Plan (dry run)
# ---------------------------------------------------------------------------
def plan_import(conn, user: str, doc: dict) -> dict:
"""Resolve every reference without writing; returns a resolution report."""
spec = doc["spec"]
target = _resolve_target(conn, user, doc.get("metadata") or {})
sources_repo = SourcesRepository(conn)
sources = []
for src in spec.get("sources") or []:
name = src.get("name") or ""
match = sources_repo.find_by_name(user, name)
sources.append(
{
"name": name,
"type": src.get("type") or "",
"status": "matched" if match else "missing",
"target_id": str(match["id"]) if match else None,
}
)
user_tools = UserToolsRepository(conn).list_for_user(user)
tools = []
for index, tool in enumerate(spec.get("tools") or []):
tool_type = tool.get("type") or ""
if tool.get("builtin"):
available = synthesize_tool_by_name(tool_type) is not None
tools.append(
{
"key": _tool_key(index),
"type": tool_type,
"builtin": True,
"status": "builtin" if available else "unavailable",
"target_id": default_tool_id(tool_type) if available else None,
}
)
continue
custom_name = tool.get("name") or ""
match = _find_user_tool(user_tools, tool_type, custom_name)
if match:
tools.append(
{
"key": _tool_key(index),
"type": tool_type,
"name": custom_name,
"status": "reuse",
"target_id": str(match["id"]),
}
)
continue
available = _tool_instance(tool_type) is not None
tools.append(
{
"key": _tool_key(index),
"type": tool_type,
"name": custom_name,
"status": "create" if available else "unavailable",
"requires_secrets": tool.get("requires_secrets") or _live_requires_secrets(tool_type),
}
)
prompt_spec = spec.get("prompt")
if isinstance(prompt_spec, dict):
existing = PromptsRepository(conn).find(
user, prompt_spec.get("name") or "", prompt_spec.get("content") or ""
)
prompt = {"status": "reuse" if existing else "create", "name": prompt_spec.get("name") or ""}
else:
prompt = {"status": "default"}
customs = UserCustomModelsRepository(conn).list_for_user(user)
models = []
for entry in (spec.get("model") or {}).get("available") or []:
if isinstance(entry, str):
models.append(
{
"id": entry,
"status": "matched" if validate_model_id(entry, user) else "unavailable",
}
)
elif isinstance(entry, dict):
match = _find_custom_model(customs, entry)
models.append(
{
"display_name": entry.get("display_name") or "",
"status": "reuse" if match else "create",
"requires_secrets": ["api_key"] if not match else [],
}
)
return {"target": target, "sources": sources, "tools": tools, "prompt": prompt, "models": models}
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def _apply_prompt(conn, user: str, spec: dict) -> Optional[str]:
prompt_spec = spec.get("prompt")
if not isinstance(prompt_spec, dict):
return None
row = PromptsRepository(conn).find_or_create(
user, prompt_spec.get("name") or "Imported prompt", prompt_spec.get("content") or ""
)
return str(row["id"])
def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
repo = SourcesRepository(conn)
mapping = resolution.get("sources")
if not isinstance(mapping, dict):
mapping = {}
resolved: list[str] = []
for src in spec.get("sources") or []:
name = src.get("name") or ""
mapped = mapping.get(name)
if mapped:
# Ownership-check the client-supplied id before linking (IDOR guard).
owned = repo.get_any(str(mapped), user)
if owned:
resolved.append(str(owned["id"]))
else:
warnings.append(f"Source mapping for '{name}' is not yours; ignored")
continue
match = repo.find_by_name(user, name)
if match:
resolved.append(str(match["id"]))
else:
warnings.append(f"Source '{name}' not found; left unattached")
seen: set = set()
out = []
for sid in resolved:
if sid not in seen:
seen.add(sid)
out.append(sid)
return out
def _validate_tool_urls(tool_type: str, config: dict) -> Optional[str]:
"""SSRF-guard imported tool config to parity with the create routes.
Returns an error message if a URL is unsafe, else None.
"""
try:
if tool_type == "mcp_tool":
server_url = (config.get("server_url") or "").strip()
if server_url:
validate_url(server_url)
elif tool_type == "api_tool":
url = (config.get("url") or "").strip()
if url:
validate_url(url)
except SSRFError:
return f"Tool '{tool_type}' has an unsafe URL; not created"
return None
def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings: list) -> Optional[str]:
from application.api.user.tools.routes import _encrypt_secret_fields, transform_actions
tool_type = tool.get("type") or ""
inst = _tool_instance(tool_type)
if inst is None:
warnings.append(f"Tool type '{tool_type}' not available on this instance; skipped")
return None
config_requirements = inst.get_config_requirements() or {}
config = dict(tool.get("config") or {})
config.update(secrets or {})
missing = [k for k in _secret_field_names(config_requirements, required_only=True) if not config.get(k)]
if missing:
warnings.append(f"Tool '{tool_type}' needs secret(s) {missing}; not created")
return None
url_error = _validate_tool_urls(tool_type, config)
if url_error:
warnings.append(url_error)
return None
provided_secrets = [k for k in _secret_field_names(config_requirements) if config.get(k)]
storage_config = _encrypt_secret_fields(config, config_requirements, user)
if provided_secrets and not storage_config.get("encrypted_credentials"):
warnings.append(f"Tool '{tool_type}' secret encryption failed; not created")
return None
actions = transform_actions(inst.get_actions_metadata() or [])
created = UserToolsRepository(conn).create(
user,
tool_type,
config=storage_config,
custom_name=tool.get("name") or "",
display_name=tool.get("display_name") or tool_type,
description=tool.get("description") or "",
config_requirements=config_requirements,
actions=actions,
status=True,
)
return str(created["id"])
def _apply_tools(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
repo = UserToolsRepository(conn)
user_tools = repo.list_for_user(user)
decisions = resolution.get("tools")
if not isinstance(decisions, dict):
decisions = {}
out: list[str] = []
for index, tool in enumerate(spec.get("tools") or []):
tool_type = tool.get("type") or ""
decision = decisions.get(_tool_key(index))
if not isinstance(decision, dict):
decision = {}
choice = decision.get("decision")
if tool.get("builtin"):
if synthesize_tool_by_name(tool_type) is None:
warnings.append(f"Built-in tool '{tool_type}' unavailable; skipped")
continue
out.append(default_tool_id(tool_type))
continue
if choice == "skip":
continue
if choice == "reuse" and decision.get("tool_id"):
# Ownership-check the client-supplied id before linking (IDOR guard).
tid = str(decision["tool_id"])
if is_synthesized_tool_id(tid) or repo.get_any(tid, user):
out.append(tid)
else:
warnings.append(f"Tool reuse id '{tid}' is not yours; ignored")
continue
custom_name = tool.get("name") or ""
match = _find_user_tool(user_tools, tool_type, custom_name)
if match and choice != "create":
out.append(str(match["id"]))
continue
created_id = _create_tool_from_spec(conn, user, tool, decision.get("secrets") or {}, warnings)
if created_id:
out.append(created_id)
# Record so a later identical spec entry reuses rather than re-creates.
user_tools.append({"id": created_id, "name": tool_type, "custom_name": custom_name})
return out
def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list):
model_spec = spec.get("model") or {}
repo = UserCustomModelsRepository(conn)
customs = repo.list_for_user(user)
decisions = resolution.get("models")
if not isinstance(decisions, dict):
decisions = {}
name_to_id: dict[str, str] = {}
models: list[str] = []
for entry in model_spec.get("available") or []:
if isinstance(entry, str):
if validate_model_id(entry, user):
models.append(entry)
name_to_id[entry] = entry
else:
warnings.append(f"Model '{entry}' not available; skipped")
continue
if not isinstance(entry, dict):
continue
display_name = entry.get("display_name") or ""
match = _find_custom_model(customs, entry)
if match:
models.append(str(match["id"]))
name_to_id[display_name] = str(match["id"])
continue
decision = decisions.get(display_name)
api_key = decision.get("api_key") if isinstance(decision, dict) else None
if not api_key:
warnings.append(f"Custom model '{display_name}' needs an API key; skipped")
continue
base_url = entry.get("base_url") or ""
try:
validate_user_base_url(base_url)
except UnsafeUserUrlError:
warnings.append(f"Custom model '{display_name}' has an unsafe base URL; skipped")
continue
created = repo.create(
user,
entry.get("upstream_model_id") or "",
display_name,
base_url,
api_key,
capabilities=entry.get("capabilities") or {},
)
if not created.get("api_key_encrypted"):
warnings.append(f"Custom model '{display_name}' key encryption failed; skipped")
continue
models.append(str(created["id"]))
name_to_id[display_name] = str(created["id"])
default_id = ""
raw_default = model_spec.get("default") or ""
if raw_default:
if raw_default in name_to_id:
default_id = name_to_id[raw_default]
elif validate_model_id(raw_default, user):
default_id = raw_default
else:
# Custom-model default carried as its display_name — resolve against
# the user's own models even if it wasn't in `available`.
owned = next(
(c for c in customs if (c.get("display_name") or "") == raw_default), None
)
if owned:
default_id = str(owned["id"])
else:
warnings.append(f"Default model '{raw_default}' not resolved")
return (models or None), (default_id or None)
def _limit(spec: dict, key: str):
return (spec.get("limits") or {}).get(key)
def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict:
"""Create or update an agent from a parsed YAML doc.
A newly created agent lands as a draft. An update to an existing agent
(matched by id or slug) preserves that agent's current status, so
re-importing over a published agent keeps it live — its API key and any
active users are unaffected — rather than silently reverting it to draft.
On update the YAML is authoritative: fields it specifies are written even
when that clears a value (e.g. removing all models, dropping ``json_schema``,
or switching ``prompt`` back to default), so re-importing an edited file is
a true sync rather than an additive merge.
"""
if not isinstance(resolution, dict):
resolution = {}
spec = doc["spec"]
metadata = doc.get("metadata") or {}
warnings: list[str] = []
target = _resolve_target(conn, user, metadata)
prompt_id = _apply_prompt(conn, user, spec)
source_ids = _apply_sources(conn, user, spec, resolution, warnings)
tool_ids = _apply_tools(conn, user, spec, resolution, warnings)
models, default_model_id = _apply_models(conn, user, spec, resolution, warnings)
agents_repo = AgentsRepository(conn)
is_update = bool(target.get("action") == "update" and target.get("agent_id"))
exclude_id = str(target["agent_id"]) if is_update else None
# Slug is recomputed from the file, not preserved from the matched row:
# a round-tripped export carries metadata.slug so it stays stable, but an
# update-by-id whose file omits the slug and renames the agent rewrites the
# slug to follow the new name (excluding the row's own slug from collision
# checks). Intended — the slug tracks the file.
slug = _unique_slug(agents_repo, user, metadata.get("slug") or spec.get("name"), exclude_id=exclude_id)
try:
chunks_value = int(spec["chunks"]) if spec.get("chunks") is not None else 2
except (TypeError, ValueError):
chunks_value = 2
# YAML-authoritative fields — written even when the resolved value is None,
# so a re-import can CLEAR models / json_schema / prompt-to-default. On
# create, AgentsRepository.create() skips None kwargs, so Nones become
# column defaults.
authoritative: dict[str, Any] = {
"description": spec.get("description") or "",
"agent_type": spec.get("agent_type") or "classic",
"chunks": chunks_value,
"prompt_id": prompt_id,
"tools": tool_ids,
"json_schema": spec.get("json_schema"),
"models": models,
"default_model_id": default_model_id,
"extra_source_ids": source_ids,
"limited_token_mode": bool(_limit(spec, "limited_token_mode")),
"limited_request_mode": bool(_limit(spec, "limited_request_mode")),
"allow_system_prompt_override": bool(spec.get("allow_system_prompt_override")),
"slug": slug,
}
# Optional fields — applied only when present so a partial file doesn't wipe them.
optional = {
"retriever": spec.get("retriever") or ("classic" if not source_ids else None),
"token_limit": _limit(spec, "token_limit"),
"request_limit": _limit(spec, "request_limit"),
}
optional = {k: v for k, v in optional.items() if v is not None}
if is_update:
# Status is intentionally omitted so an update never changes it:
# re-importing over a published agent keeps it live (its API key and
# any active users are unaffected) instead of reverting it to draft.
# Only a brand-new agent (the create path below) starts as a draft.
fields = {**authoritative, **optional, "name": spec.get("name")}
if agents_repo.update(str(target["agent_id"]), user, fields):
return {
"agent_id": str(target["agent_id"]),
"action": "updated",
"status": target.get("status") or "draft",
"slug": slug,
"warnings": warnings,
}
# Row vanished between resolve and write — fall through to create.
row = agents_repo.create(user, spec.get("name"), "draft", **{**authoritative, **optional})
return {
"agent_id": str(row["id"]),
"action": "created",
"status": "draft",
"slug": slug,
"warnings": warnings,
}
# ---------------------------------------------------------------------------
# HTTP surface
# ---------------------------------------------------------------------------
agents_portability_ns = Namespace(
"agents", description="Agent import/export operations", path="/api"
)
def _read_import_payload(req):
"""Pull (yaml_text, resolution) from a JSON body or uploaded file.
Enforces a body-size cap before reading so a giant document can't be
buffered into memory.
"""
if req.content_length and req.content_length > MAX_IMPORT_BYTES:
raise AgentImportError("Import document too large")
content_type = req.content_type or ""
if "application/json" in content_type:
data = req.get_json(silent=True) or {}
resolution = data.get("resolution")
return (
data.get("yaml") or data.get("content") or "",
resolution if isinstance(resolution, dict) else {},
)
if "file" in req.files:
raw = req.files["file"].read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
raise AgentImportError("Import document too large")
return raw.decode("utf-8", "replace"), {}
# Raw body: read from the stream with a hard cap so a chunked request
# (no Content-Length) can't be buffered unbounded into memory.
raw = req.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
raise AgentImportError("Import document too large")
return raw.decode("utf-8", "replace"), {}
@agents_portability_ns.route("/export_agent")
class ExportAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Export an agent as YAML")
def get(self):
if not (decoded_token := request.decoded_token):
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(jsonify({"success": False, "message": "id is required"}), 400)
with db_session() as conn:
repo = AgentsRepository(conn)
agent = repo.get_any(agent_id, user)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
if (agent.get("agent_type") or "") == "workflow":
return make_response(
jsonify(
{"success": False, "message": "Workflow agents can't be exported yet"}
),
400,
)
agent["slug"] = ensure_agent_slug(conn, agent, user)
export = serialize_agent(conn, agent, user)
body = agent_to_yaml(export)
filename = f"{export['metadata'].get('slug') or 'agent'}.agent.yaml"
response = make_response(body, 200)
response.headers["Content-Type"] = "application/x-yaml; charset=utf-8"
response.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@agents_portability_ns.route("/import_agent/plan")
class ImportAgentPlan(Resource):
@api.doc(description="Dry-run an agent YAML import and return the resolution plan")
def post(self):
if not (decoded_token := request.decoded_token):
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
yaml_text, _ = _read_import_payload(request)
doc = parse_agent_yaml(yaml_text)
except AgentImportError as exc:
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
try:
with db_readonly() as conn:
plan = plan_import(conn, user, doc)
except Exception:
current_app.logger.error("Agent import plan failed", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Could not analyze the agent file"}), 500
)
return make_response(jsonify({"success": True, "plan": plan}), 200)
@agents_portability_ns.route("/import_agent")
class ImportAgent(Resource):
@api.doc(description="Import an agent from YAML (created as a draft)")
def post(self):
if not (decoded_token := request.decoded_token):
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
yaml_text, resolution = _read_import_payload(request)
doc = parse_agent_yaml(yaml_text)
except AgentImportError as exc:
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
try:
with db_session() as conn:
result = apply_import(conn, user, doc, resolution)
except Exception:
current_app.logger.error("Agent import failed", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Import failed"}), 500
)
return make_response(jsonify({"success": True, **result}), 200)
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
"""Agent management sharing functionality."""
import datetime
import secrets
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from sqlalchemy import text as _sql_text
from application.api import api
from application.core.settings import settings
from application.api.user.base import resolve_tool_details
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import generate_image_url
agents_sharing_ns = Namespace(
"agents", description="Agent management operations", path="/api"
)
def _serialize_agent_basic(agent: dict) -> dict:
"""Shape a PG agent row into the API response dict."""
source_id = agent.get("source_id")
return {
"id": str(agent["id"]),
"user": agent.get("user_id", ""),
"name": agent.get("name", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"description": agent.get("description", ""),
"source": str(source_id) if source_id else "",
"chunks": str(agent["chunks"]) if agent.get("chunks") is not None else "0",
"retriever": agent.get("retriever", "classic") or "classic",
"prompt_id": str(agent["prompt_id"]) if agent.get("prompt_id") else "default",
"tools": agent.get("tools", []) or [],
"tool_details": resolve_tool_details(agent.get("tools", []) or []),
"agent_type": agent.get("agent_type", "") or "",
"status": agent.get("status", "") or "",
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get("token_limit") or settings.DEFAULT_AGENT_LIMITS["token_limit"],
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get("request_limit") or settings.DEFAULT_AGENT_LIMITS["request_limit"],
"created_at": agent.get("created_at", ""),
"updated_at": agent.get("updated_at", ""),
"shared": bool(agent.get("shared", False)),
"shared_token": agent.get("shared_token", "") or "",
"shared_metadata": agent.get("shared_metadata", {}) or {},
}
@agents_sharing_ns.route("/shared_agent")
class SharedAgent(Resource):
@api.doc(
params={
"token": "Shared token of the agent",
},
description="Get a shared agent by token or ID",
)
def get(self):
shared_token = request.args.get("token")
if not shared_token:
return make_response(
jsonify({"success": False, "message": "Token or ID is required"}), 400
)
try:
with db_readonly() as conn:
shared_agent = AgentsRepository(conn).find_by_shared_token(
shared_token,
)
if not shared_agent:
return make_response(
jsonify({"success": False, "message": "Shared agent not found"}),
404,
)
agent_id = str(shared_agent["id"])
data = _serialize_agent_basic(shared_agent)
if data["tools"]:
enriched_tools = []
for detail in data["tool_details"]:
enriched_tools.append(detail.get("name", ""))
data["tools"] = enriched_tools
decoded_token = getattr(request, "decoded_token", None)
if decoded_token:
user_id = decoded_token.get("sub")
owner_id = shared_agent.get("user_id")
if user_id != owner_id:
with db_session() as conn:
users_repo = UsersRepository(conn)
users_repo.upsert(user_id)
users_repo.add_shared(user_id, agent_id)
return make_response(jsonify(data), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving shared agent: {err}")
return make_response(jsonify({"success": False}), 400)
@agents_sharing_ns.route("/shared_agents")
class SharedAgents(Resource):
@api.doc(description="Get shared agents explicitly shared with the user")
def get(self):
try:
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
with db_session() as conn:
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user_id)
shared_with_ids = (
user_doc.get("agent_preferences", {}).get("shared_with_me", [])
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
# Keep only UUID-shaped ids; ObjectId leftovers are stripped below.
uuid_ids = [sid for sid in shared_with_ids if looks_like_uuid(sid)]
non_uuid_ids = [sid for sid in shared_with_ids if not looks_like_uuid(sid)]
if uuid_ids:
result = conn.execute(
_sql_text(
"SELECT * FROM agents "
"WHERE id = ANY(CAST(:ids AS uuid[])) "
"AND shared = true"
),
{"ids": uuid_ids},
)
shared_agents = [dict(row._mapping) for row in result.fetchall()]
else:
shared_agents = []
found_ids_set = {str(agent["id"]) for agent in shared_agents}
stale_ids = [sid for sid in uuid_ids if sid not in found_ids_set]
stale_ids.extend(non_uuid_ids)
if stale_ids:
users_repo.remove_shared_bulk(user_id, stale_ids)
pinned_ids = set(
user_doc.get("agent_preferences", {}).get("pinned", [])
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
list_shared_agents = []
for agent in shared_agents:
agent_id_str = str(agent["id"])
list_shared_agents.append(
{
"id": agent_id_str,
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"tools": agent.get("tools", []) or [],
"tool_details": resolve_tool_details(
agent.get("tools", []) or []
),
"agent_type": agent.get("agent_type", "") or "",
"status": agent.get("status", "") or "",
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get("token_limit") or settings.DEFAULT_AGENT_LIMITS["token_limit"],
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get("request_limit") or settings.DEFAULT_AGENT_LIMITS["request_limit"],
"created_at": agent.get("created_at", ""),
"updated_at": agent.get("updated_at", ""),
"pinned": agent_id_str in pinned_ids,
"shared": bool(agent.get("shared", False)),
"shared_token": agent.get("shared_token", "") or "",
"shared_metadata": agent.get("shared_metadata", {}) or {},
}
)
return make_response(jsonify(list_shared_agents), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving shared agents: {err}")
return make_response(jsonify({"success": False}), 400)
@agents_sharing_ns.route("/share_agent")
class ShareAgent(Resource):
@api.expect(
api.model(
"ShareAgentModel",
{
"id": fields.String(required=True, description="ID of the agent"),
"shared": fields.Boolean(
required=True, description="Share or unshare the agent"
),
"username": fields.String(
required=False, description="Name of the user"
),
},
)
)
@api.doc(description="Share or unshare an agent")
def put(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data:
return make_response(
jsonify({"success": False, "message": "Missing JSON body"}), 400
)
agent_id = data.get("id")
shared = data.get("shared")
username = data.get("username", "")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
if shared is None:
return make_response(
jsonify(
{
"success": False,
"message": "Shared parameter is required and must be true or false",
}
),
400,
)
shared_token = None
try:
with db_session() as conn:
repo = AgentsRepository(conn)
agent = repo.get_any(agent_id, user)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
if shared:
shared_metadata = {
"shared_by": username,
"shared_at": datetime.datetime.now(
datetime.timezone.utc
).isoformat(),
}
shared_token = secrets.token_urlsafe(32)
repo.update(
str(agent["id"]), user,
{
"shared": True,
"shared_token": shared_token,
"shared_metadata": shared_metadata,
},
)
else:
repo.update(
str(agent["id"]), user,
{
"shared": False,
"shared_token": None,
"shared_metadata": None,
},
)
except Exception as err:
current_app.logger.error(f"Error sharing/unsharing agent: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to update agent sharing status"}), 400)
return make_response(
jsonify({"success": True, "shared_token": shared_token}), 200
)
+232
View File
@@ -0,0 +1,232 @@
"""Agent management webhook handlers."""
import secrets
import uuid
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
from sqlalchemy import text as sql_text
from application.api import api
from application.api.user.base import require_agent
from application.api.user.tasks import process_agent_webhook
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.idempotency import IdempotencyRepository
from application.storage.db.session import db_readonly, db_session
agents_webhooks_ns = Namespace(
"agents", description="Agent management operations", path="/api"
)
_IDEMPOTENCY_KEY_MAX_LEN = 256
def _read_idempotency_key():
"""Return (key, error_response). Empty header → (None, None); oversized → (None, 400)."""
key = request.headers.get("Idempotency-Key")
if not key:
return None, None
if len(key) > _IDEMPOTENCY_KEY_MAX_LEN:
return None, make_response(
jsonify(
{
"success": False,
"message": (
f"Idempotency-Key exceeds maximum length of "
f"{_IDEMPOTENCY_KEY_MAX_LEN} characters"
),
}
),
400,
)
return key, None
def _scoped_idempotency_key(idempotency_key, scope):
"""``{scope}:{key}`` so different agents can't collide on the same key."""
if not idempotency_key or not scope:
return None
return f"{scope}:{idempotency_key}"
@agents_webhooks_ns.route("/agent_webhook")
class AgentWebhook(Resource):
@api.doc(
params={"id": "ID of the agent"},
description="Generate webhook URL for the agent",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
with db_readonly() as conn:
agent = AgentsRepository(conn).get_any(agent_id, user)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
webhook_token = agent.get("incoming_webhook_token")
if not webhook_token:
webhook_token = secrets.token_urlsafe(32)
with db_session() as conn:
AgentsRepository(conn).update(
str(agent["id"]), user,
{"incoming_webhook_token": webhook_token},
)
base_url = settings.API_URL.rstrip("/")
full_webhook_url = f"{base_url}/api/webhooks/agents/{webhook_token}"
except Exception as err:
current_app.logger.error(
f"Error generating webhook URL: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Error generating webhook URL"}),
400,
)
return make_response(
jsonify({"success": True, "webhook_url": full_webhook_url}), 200
)
@agents_webhooks_ns.route("/webhooks/agents/<string:webhook_token>")
class AgentWebhookListener(Resource):
method_decorators = [require_agent]
def _enqueue_webhook_task(self, agent_id_str, payload, source_method, agent=None):
if not payload:
current_app.logger.warning(
f"Webhook ({source_method}) received for agent {agent_id_str} with empty payload."
)
current_app.logger.info(
f"Incoming {source_method} webhook for agent {agent_id_str}. Enqueuing task with payload: {payload}"
)
idempotency_key, key_error = _read_idempotency_key()
if key_error is not None:
return key_error
# Resolve to PG UUID first so dedup writes don't crash on legacy ids.
agent_uuid = None
if agent is not None:
candidate = str(agent.get("id") or "")
if looks_like_uuid(candidate):
agent_uuid = candidate
if idempotency_key and agent_uuid is None:
current_app.logger.warning(
"Skipping webhook idempotency dedup: agent %s has non-UUID id",
agent_id_str,
)
idempotency_key = None
# Agent-scoped (webhooks have no user_id).
scoped_key = _scoped_idempotency_key(idempotency_key, agent_uuid)
# Claim before enqueue; the loser returns the winner's task_id.
predetermined_task_id = None
if scoped_key:
predetermined_task_id = str(uuid.uuid4())
with db_session() as conn:
claimed = IdempotencyRepository(conn).record_webhook(
key=scoped_key,
agent_id=agent_uuid,
task_id=predetermined_task_id,
response_json={
"success": True, "task_id": predetermined_task_id,
},
)
if claimed is None:
with db_readonly() as conn:
cached = IdempotencyRepository(conn).get_webhook(scoped_key)
if cached is not None:
return make_response(jsonify(cached["response_json"]), 200)
return make_response(
jsonify({"success": True, "task_id": "deduplicated"}), 200
)
try:
apply_kwargs = dict(
kwargs={
"agent_id": agent_id_str,
"payload": payload,
# Scoped so the worker dedup row matches the HTTP claim.
"idempotency_key": scoped_key or idempotency_key,
},
)
if predetermined_task_id is not None:
apply_kwargs["task_id"] = predetermined_task_id
task = process_agent_webhook.apply_async(**apply_kwargs)
current_app.logger.info(
f"Task {task.id} enqueued for agent {agent_id_str} ({source_method})."
)
response_payload = {"success": True, "task_id": task.id}
return make_response(jsonify(response_payload), 200)
except Exception as err:
current_app.logger.error(
f"Error enqueuing webhook task ({source_method}) for agent {agent_id_str}: {err}",
exc_info=True,
)
if scoped_key:
# Roll back the claim so a retry can succeed.
try:
with db_session() as conn:
conn.execute(
sql_text(
"DELETE FROM webhook_dedup "
"WHERE idempotency_key = :k"
),
{"k": scoped_key},
)
except Exception:
current_app.logger.exception(
"Failed to release webhook_dedup claim for key=%s",
scoped_key,
)
return make_response(
jsonify({"success": False, "message": "Error processing webhook"}), 500
)
@api.doc(
description=(
"Webhook listener for agent events (POST). Expects JSON payload, which "
"is used to trigger processing. Honors an optional ``Idempotency-Key`` "
"header: a repeat request with the same key within 24h returns the "
"original cached response and does not re-enqueue the task."
),
)
def post(self, webhook_token, agent, agent_id_str):
payload = request.get_json()
if payload is None:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid or missing JSON data in request body",
}
),
400,
)
return self._enqueue_webhook_task(
agent_id_str, payload, source_method="POST", agent=agent,
)
@api.doc(
description=(
"Webhook listener for agent events (GET). Uses URL query parameters as "
"payload to trigger processing. Honors an optional ``Idempotency-Key`` "
"header: a repeat request with the same key within 24h returns the "
"original cached response and does not re-enqueue the task."
),
)
def get(self, webhook_token, agent, agent_id_str):
payload = request.args.to_dict(flat=True)
return self._enqueue_webhook_task(
agent_id_str, payload, source_method="GET", agent=agent,
)
@@ -0,0 +1,5 @@
"""Analytics module."""
from .routes import analytics_ns
__all__ = ["analytics_ns"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"""Artifacts module."""
from .routes import artifacts_ns
__all__ = ["artifacts_ns"]
+168
View File
@@ -0,0 +1,168 @@
"""Parent-derived authorization helpers for artifact access."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from flask import request
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.artifacts import ArtifactsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.shared_conversations import (
SharedConversationsRepository,
)
from application.storage.db.repositories.workflow_runs import WorkflowRunsRepository
from application.storage.db.session import db_readonly
@dataclass(frozen=True)
class Principal:
"""The resolved caller of an artifact route.
A decoded JWT is a full *owner* session (``agent_id`` is ``None``). An
``api_key`` is a low-trust, publicly-distributed *agent* credential -- it is
embedded client-side in the widget and accepted from the query string -- so
it resolves to the owning ``user_id`` but stays **agent-scoped**: it may reach
an artifact only when the request also carries that artifact's parent
``conversation_id`` (the unguessable per-visitor secret), confining it to a
single conversation rather than the agent's whole corpus, and may not perform
mutating operations.
"""
user_id: Optional[str] = None
agent_id: Optional[str] = None
@property
def is_agent_scoped(self) -> bool:
"""True for an api_key (agent) principal that must be confined to its agent."""
return self.agent_id is not None
def resolve_principal() -> Principal:
"""Resolve the caller to a :class:`Principal`.
Priority: a decoded JWT (owner session) first, then an ``api_key`` query/form
param (agent-scoped). An unresolvable caller yields an anonymous principal
(both fields ``None``) that only a valid ``share_token`` can authorize.
"""
decoded_token = getattr(request, "decoded_token", None)
if decoded_token:
return Principal(user_id=decoded_token.get("sub"))
api_key = request.args.get("api_key") or request.form.get("api_key")
if api_key:
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_key(api_key)
if agent and agent.get("user_id") and agent.get("id"):
return Principal(user_id=str(agent["user_id"]), agent_id=str(agent["id"]))
return Principal()
def _shared_row_for(conn, conversation_id, share_token):
"""Return the shared_conversations row iff share_token grants this conversation, else None."""
if not share_token:
return None
shared = SharedConversationsRepository(conn).find_by_uuid(share_token)
if shared and str(shared.get("conversation_id")) == str(conversation_id):
return shared
return None
def user_can_access_conversation(
conn, conversation_id: str, user_id: Optional[str], share_token: Optional[str]
) -> bool:
"""Allow if the caller owns/shares the conversation, or holds a valid share token.
Conversation-level gate only. Reuses ``ConversationsRepository.get`` (owner OR
``shared_with``) so artifact access tracks message access, and honours a valid
share token. A share-token caller reaches the conversation here but must still
be snapshot-scoped per-artifact by the caller (see ``authorize_artifact``): a
valid token does NOT imply access to every artifact in the conversation.
"""
if user_id:
if ConversationsRepository(conn).get(conversation_id, user_id) is not None:
return True
return _shared_row_for(conn, conversation_id, share_token) is not None
def authorize_artifact(conn, artifact: dict, principal: Principal) -> bool:
"""Authorize a READ of ``artifact`` for ``principal``; missing parent fails closed.
A low-trust agent api_key is confined to a single conversation it proves by
carrying that artifact's parent ``conversation_id`` query param (owner match +
matching conversation + agent scope) and never inherits share-link access. A
JWT owner or ``shared_with`` collaborator gets full access to every artifact of
the parent; a share-token holder is confined to the shared ``first_n_queries``
snapshot (an artifact whose ``message_id`` is outside it, or NULL, is denied).
"""
if principal.is_agent_scoped:
# An agent key is not the owner's session and is embedded in public widget
# JS, so require the artifact to belong to the owner AND the request to
# carry the parent conversation_id (the unguessable per-visitor secret):
# the key alone, without that id, cannot download a known artifact.
if str(artifact.get("user_id")) != str(principal.user_id):
return False
req_conv = request.args.get("conversation_id")
parent = artifact.get("conversation_id")
if not req_conv or parent is None or str(parent) != str(req_conv):
return False
return ArtifactsRepository(conn).artifact_in_agent_scope(
str(artifact.get("id")), str(principal.agent_id)
)
conversation_id = artifact.get("conversation_id")
workflow_run_id = artifact.get("workflow_run_id")
share_token = request.args.get("share_token")
if conversation_id is not None:
# Owner or shared_with collaborator: full access to every artifact.
if principal.user_id and ConversationsRepository(conn).get(
str(conversation_id), principal.user_id
) is not None:
return True
# Share-token holder: only artifacts inside the shared first_n_queries snapshot.
shared = _shared_row_for(conn, str(conversation_id), share_token)
if shared is None:
return False
message_id = artifact.get("message_id")
if not message_id:
return False
first_n = int(shared.get("first_n_queries") or 0)
return ConversationsRepository(conn).message_in_first_n(
str(conversation_id), str(message_id), first_n
)
if workflow_run_id is not None:
if not principal.user_id:
return False
run = WorkflowRunsRepository(conn).get(str(workflow_run_id))
return run is not None and run.get("user_id") == principal.user_id
# No parent row reachable -> deny (e.g. deleted conversation/run).
return False
def authorize_artifact_write(conn, artifact: dict, principal: Principal) -> bool:
"""Authorize a *mutating* artifact operation (e.g. delete / restore).
Stricter than :func:`authorize_artifact`: a write requires an authenticated
*owner* of the parent. Low-trust agent api_keys, share links, and
``shared_with`` collaborators inherit read/download access only, so an
agent-scoped, anonymous, or share-token-only caller is denied -- they can read
an artifact but never mutate it.
"""
if principal.is_agent_scoped or not principal.user_id:
return False
conversation_id = artifact.get("conversation_id")
workflow_run_id = artifact.get("workflow_run_id")
if conversation_id is not None:
return (
ConversationsRepository(conn).get_owned(str(conversation_id), principal.user_id)
is not None
)
if workflow_run_id is not None:
run = WorkflowRunsRepository(conn).get(str(workflow_run_id))
return run is not None and run.get("user_id") == principal.user_id
# No parent row reachable -> deny (e.g. deleted conversation/run).
return False
+402
View File
@@ -0,0 +1,402 @@
"""Artifact metadata and download routes (parent-derived authz)."""
from __future__ import annotations
import re
from typing import Optional
from flask import (
Response,
current_app,
jsonify,
make_response,
redirect,
request,
stream_with_context,
)
from flask_restx import Namespace, Resource
from application.api import api
from application.api.user.artifacts.authz import (
_shared_row_for,
authorize_artifact,
authorize_artifact_write,
resolve_principal,
user_can_access_conversation,
)
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.artifacts import ArtifactsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.workflow_runs import WorkflowRunsRepository
from application.storage.db.session import db_readonly, db_session
from application.storage.storage_creator import StorageCreator
artifacts_ns = Namespace("artifacts", description="Artifact operations", path="/api")
# Presigned-URL TTL for private S3 artifact downloads (seconds).
_PRESIGNED_URL_TTL = 300
_ARTIFACT_URL_ENVELOPE_MIME = "application/vnd.docsgpt.artifact-url+json"
def _sanitize_header_filename(filename: Optional[str], fallback: str) -> str:
"""Strip CRLF / quotes from a display filename for a Content-Disposition header."""
if not filename:
return fallback
cleaned = re.sub(r'[\r\n"]', "", str(filename)).strip()
return cleaned or fallback
def _artifact_summary(artifact: dict) -> dict:
"""Project an artifact identity row to its API metadata shape (owner id withheld)."""
return {
"id": str(artifact.get("id")),
"conversation_id": (str(artifact["conversation_id"]) if artifact.get("conversation_id") is not None else None),
"workflow_run_id": (str(artifact["workflow_run_id"]) if artifact.get("workflow_run_id") is not None else None),
"message_id": (str(artifact["message_id"]) if artifact.get("message_id") is not None else None),
"kind": artifact.get("kind"),
"title": artifact.get("title"),
"metadata": artifact.get("metadata"),
"current_version": artifact.get("current_version"),
"created_at": _iso(artifact.get("created_at")),
"updated_at": _iso(artifact.get("updated_at")),
}
def _version_summary(version: dict, *, include_spec: bool = False) -> dict:
"""Project a version row to its API metadata shape (storage_path withheld)."""
out = {
"version": version.get("version"),
"mime_type": version.get("mime_type"),
"filename": version.get("filename"),
"size": version.get("size"),
"sha256": version.get("sha256"),
"preview_text": version.get("preview_text"),
"produced_by": version.get("produced_by"),
"created_at": _iso(version.get("created_at")),
}
if include_spec:
out["spec"] = version.get("spec")
return out
def _iso(value):
"""ISO-format a datetime, passing through other values unchanged."""
return value.isoformat() if hasattr(value, "isoformat") else value
@artifacts_ns.route("/artifacts")
class ListArtifacts(Resource):
@api.doc(description="List artifacts for a conversation, workflow run, or the caller")
def get(self):
principal = resolve_principal()
user_id = principal.user_id
conversation_id = request.args.get("conversation_id")
workflow_run_id = request.args.get("workflow_run_id")
share_token = request.args.get("share_token")
if not user_id and not share_token:
return make_response(jsonify({"success": False, "message": "Authentication required"}), 401)
# Gate UUID-shape before any CAST(:id AS uuid) reaches the repo, so a
# malformed id is rejected cleanly instead of poisoning the transaction.
if conversation_id and not looks_like_uuid(conversation_id):
return make_response(jsonify({"success": False, "message": "Invalid conversation_id"}), 400)
if workflow_run_id and not looks_like_uuid(workflow_run_id):
return make_response(jsonify({"success": False, "message": "Invalid workflow_run_id"}), 400)
try:
with db_readonly() as conn:
repo = ArtifactsRepository(conn)
if principal.is_agent_scoped:
# A low-trust agent api_key is embedded in public widget JS and
# shared by every anonymous visitor, so it may NOT enumerate the
# agent's artifacts. The unguessable conversation_id (UUIDv4) is
# the only per-visitor secret: require it as a bearer capability
# and scope the query to it. Workflow runs are not agent-scoped,
# so reject that filter.
if workflow_run_id:
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
if not conversation_id:
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
rows = repo.list_artifacts_for_agent(principal.agent_id, user_id, conversation_id=conversation_id)
elif conversation_id:
if not user_can_access_conversation(conn, conversation_id, user_id, share_token):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
rows = repo.list_artifacts(conversation_id=conversation_id)
# Owner / shared_with collaborator sees every artifact; a
# share-token caller is confined to the shared first_n_queries
# snapshot (drop artifacts whose message is outside it or NULL).
conv_repo = ConversationsRepository(conn)
is_owner = bool(user_id and conv_repo.get(conversation_id, user_id) is not None)
if not is_owner:
shared = _shared_row_for(conn, conversation_id, share_token)
first_n = int(shared.get("first_n_queries") or 0) if shared else 0
snapshot_ids = conv_repo.first_n_message_ids(conversation_id, first_n)
rows = [
r
for r in rows
if r.get("message_id") is not None and str(r.get("message_id")) in snapshot_ids
]
elif workflow_run_id:
run = WorkflowRunsRepository(conn).get(workflow_run_id)
if run is None or run.get("user_id") != user_id:
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
rows = repo.list_artifacts(workflow_run_id=workflow_run_id)
else:
if not user_id:
return make_response(
jsonify({"success": False, "message": "Authentication required"}),
401,
)
rows = repo.list_artifacts(user_id=user_id)
return make_response(
jsonify({"success": True, "artifacts": [_artifact_summary(r) for r in rows]}),
200,
)
except Exception as err:
current_app.logger.error(f"Error listing artifacts: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@artifacts_ns.route("/artifacts/<artifact_id>")
class GetArtifact(Resource):
@api.doc(description="Get an artifact's metadata, version list, and current spec")
def get(self, artifact_id: str):
if not looks_like_uuid(artifact_id):
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
principal = resolve_principal()
try:
with db_readonly() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact(artifact_id)
if artifact is None:
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
if not authorize_artifact(conn, artifact, principal):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
versions = repo.list_versions(artifact_id)
current = repo.get_version(artifact_id, artifact.get("current_version"))
payload = _artifact_summary(artifact)
payload["versions"] = [_version_summary(v) for v in versions]
payload["spec"] = current.get("spec") if current else None
return make_response(jsonify({"success": True, "artifact": payload}), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving artifact: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@api.doc(description="Delete an artifact and all its versions (owner only)")
def delete(self, artifact_id: str):
if not looks_like_uuid(artifact_id):
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
principal = resolve_principal()
try:
with db_session() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact(artifact_id)
if artifact is None:
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
# Delete is a WRITE: only the parent owner may delete; share
# links / read-only collaborators are denied (read access only).
if not authorize_artifact_write(conn, artifact, principal):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
storage_paths = repo.delete_artifact(artifact_id)
# Reap the bytes best-effort AFTER the row delete commits.
_reap_storage(storage_paths)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
current_app.logger.error(f"Error deleting artifact: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
def _reap_storage(paths: list) -> None:
"""Best-effort delete artifact bytes after the DB rows are gone; never raises."""
if not paths:
return
try:
storage = StorageCreator.get_storage()
except Exception:
current_app.logger.warning("artifact delete: storage unavailable", exc_info=True)
return
for path in paths:
try:
storage.delete_file(path)
except Exception:
current_app.logger.warning("artifact delete: failed to delete bytes %s", path, exc_info=True)
@artifacts_ns.route("/artifacts/<artifact_id>/versions/<int:version>")
class GetArtifactVersion(Resource):
@api.doc(description="Get a single artifact version's metadata and spec")
def get(self, artifact_id: str, version: int):
if not looks_like_uuid(artifact_id):
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
principal = resolve_principal()
try:
with db_readonly() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact(artifact_id)
if artifact is None:
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
if not authorize_artifact(conn, artifact, principal):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
version_row = repo.get_version(artifact_id, version)
if version_row is None:
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
return make_response(
jsonify({"success": True, "version": _version_summary(version_row, include_spec=True)}),
200,
)
except Exception as err:
current_app.logger.error(f"Error retrieving artifact version: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@artifacts_ns.route("/artifacts/<artifact_id>/download")
class DownloadArtifact(Resource):
@api.doc(description="Download an artifact's bytes (302 to a presigned URL on S3)")
def get(self, artifact_id: str):
if not looks_like_uuid(artifact_id):
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
principal = resolve_principal()
version_arg = request.args.get("version")
try:
with db_readonly() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact(artifact_id)
if artifact is None:
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
if not authorize_artifact(conn, artifact, principal):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
version = artifact.get("current_version")
if version_arg is not None:
try:
version = int(version_arg)
except ValueError:
return make_response(jsonify({"success": False, "message": "Invalid version"}), 400)
version_row = repo.get_version(artifact_id, version)
if version_row is None:
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
# The object key is derived only from the stored path, never client input.
storage_path = version_row.get("storage_path")
if not storage_path:
return make_response(jsonify({"success": False, "message": "No file for this version"}), 404)
filename = _sanitize_header_filename(version_row.get("filename"), f"artifact-{artifact_id}")
mime_type = version_row.get("mime_type") or "application/octet-stream"
storage = StorageCreator.get_storage()
# With URL_STRATEGY=="s3" the contract is to hand back a presigned
# URL. If the active backend can't mint one, that's a config error:
# surface a 500 rather than silently proxying bytes from a backend
# the operator expected to be off the hot path.
if getattr(settings, "URL_STRATEGY", "backend") == "s3":
try:
url = storage.generate_presigned_url(storage_path, expires_in=_PRESIGNED_URL_TTL)
except NotImplementedError:
current_app.logger.error(
"URL_STRATEGY=s3 but %s cannot mint presigned URLs",
type(storage).__name__,
)
return make_response(
jsonify({"success": False, "message": "Storage misconfigured"}),
500,
)
# A 302 to a cross-origin S3 URL can't be read by the app's authed
# fetch (the bucket has no CORS grant for the app origin). When the
# client opts in via ?disposition=url (or Accept: application/json)
# hand the presigned URL back as JSON so it can navigate to it
# top-level (no CORS). Default stays a 302 so nothing else breaks.
if request.args.get("disposition") == "url" or (
"application/json" in request.headers.get("Accept", "")
):
resp = make_response(jsonify({"success": True, "url": url}), 200)
# Tag the envelope with a distinctive media type so the client
# keys off a server-set signal, not the JSON body shape. A
# ``data`` artifact whose bytes happen to be
# ``{"success":true,"url":"..."}`` is streamed under the backend
# strategy with its own (``application/json``) content-type and
# can never carry this vendor type, so it can't be mistaken for a
# redirect (open-redirect gadget). Content-Type is CORS-safelisted,
# so this stays readable cross-origin without expose-headers.
resp.headers["Content-Type"] = _ARTIFACT_URL_ENVELOPE_MIME
return resp
return redirect(url, code=302)
# Stream the bytes in chunks instead of buffering the whole object in
# worker memory (artifacts can be many MB); close the handle when done.
file_obj = storage.get_file(storage_path)
def _stream():
try:
for chunk in iter(lambda: file_obj.read(65536), b""):
yield chunk
finally:
close = getattr(file_obj, "close", None)
if callable(close):
close()
return Response(
stream_with_context(_stream()),
mimetype=mime_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except FileNotFoundError:
return make_response(jsonify({"success": False, "message": "File not found"}), 404)
except Exception as err:
current_app.logger.error(f"Error downloading artifact: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@artifacts_ns.route("/artifacts/<artifact_id>/restore")
class RestoreArtifact(Resource):
@api.doc(description="Restore a prior version by appending it as the new current version")
def post(self, artifact_id: str):
if not looks_like_uuid(artifact_id):
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
principal = resolve_principal()
data = request.get_json(silent=True) or {}
target_version = data.get("version")
if target_version is None:
return make_response(jsonify({"success": False, "message": "Missing version"}), 400)
try:
target_version = int(target_version)
except (ValueError, TypeError):
return make_response(jsonify({"success": False, "message": "Invalid version"}), 400)
try:
with db_session() as conn:
repo = ArtifactsRepository(conn)
artifact = repo.get_artifact(artifact_id)
if artifact is None:
return make_response(jsonify({"success": False, "message": "Artifact not found"}), 404)
# Restore is a WRITE (it appends a new current version); share
# links / shared_with collaborators inherit read access only, so
# gate on the stricter owner-required write check.
if not authorize_artifact_write(conn, artifact, principal):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
source = repo.get_version(artifact_id, target_version)
if source is None:
return make_response(jsonify({"success": False, "message": "Version not found"}), 404)
new_version = repo.append_version(
artifact_id,
mime_type=source.get("mime_type"),
filename=source.get("filename"),
storage_path=source.get("storage_path"),
size=source.get("size"),
sha256=source.get("sha256"),
spec=source.get("spec"),
preview_text=source.get("preview_text"),
produced_by=source.get("produced_by"),
)
return make_response(
jsonify({"success": True, "version": _version_summary(new_version, include_spec=True)}),
200,
)
except Exception as err:
current_app.logger.error(f"Error restoring artifact: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -0,0 +1,5 @@
"""Attachments module."""
from .routes import attachments_ns
__all__ = ["attachments_ns"]
+700
View File
@@ -0,0 +1,700 @@
"""File attachments and media routes."""
import os
import tempfile
from pathlib import Path
import uuid
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.cache import get_redis_instance
from application.core.settings import settings
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
from application.stt.constants import (
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_AUDIO_MIME_TYPES,
)
from application.stt.upload_limits import (
AudioFileTooLargeError,
build_stt_file_size_limit_message,
enforce_audio_file_size_limit,
is_audio_filename,
)
from application.stt.live_session import (
apply_live_stt_hypothesis,
create_live_stt_session,
delete_live_stt_session,
finalize_live_stt_session,
get_live_stt_transcript_text,
load_live_stt_session,
save_live_stt_session,
)
from application.stt.stt_creator import STTCreator
from application.tts.tts_creator import TTSCreator
from application.utils import safe_filename
attachments_ns = Namespace(
"attachments", description="File attachments and media operations", path="/api"
)
def _resolve_authenticated_user():
decoded_token = getattr(request, "decoded_token", None)
api_key = request.form.get("api_key") or request.args.get("api_key")
# Return the RAW user identity (not safe_filename'd): the attachment row's
# ``user_id`` is an identity key that must match the raw ``sub`` used by
# /stream (initial_user_id) and the artifact authz gate. safe_filename is
# applied only to the storage path component at write time. (An email-style
# sub like ``a@b.com`` was previously sanitized to ``abcom``, so the
# uploaded attachment became unreadable by its own owner on /stream.)
if decoded_token:
return decoded_token.get("sub")
if api_key:
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_key(api_key)
if not agent:
return make_response(
jsonify({"success": False, "message": "Invalid API key"}), 401
)
return agent.get("user_id")
return None
def _get_uploaded_file_size(file) -> int:
try:
current_position = file.stream.tell()
file.stream.seek(0, os.SEEK_END)
size_bytes = file.stream.tell()
file.stream.seek(current_position)
return size_bytes
except Exception:
return 0
def _is_supported_audio_mimetype(mimetype: str) -> bool:
if not mimetype:
return True
normalized = mimetype.split(";")[0].strip().lower()
return normalized.startswith("audio/") or normalized in SUPPORTED_AUDIO_MIME_TYPES
def _enforce_uploaded_audio_size_limit(file, filename: str) -> None:
if not is_audio_filename(filename):
return
size_bytes = _get_uploaded_file_size(file)
if size_bytes:
enforce_audio_file_size_limit(size_bytes)
def _get_store_attachment_user_error(exc: Exception) -> str:
if isinstance(exc, AudioFileTooLargeError):
return build_stt_file_size_limit_message()
return "Failed to process file"
def _require_live_stt_redis():
redis_client = get_redis_instance()
if redis_client:
return redis_client
return make_response(
jsonify({"success": False, "message": "Live transcription is unavailable"}),
503,
)
def _parse_bool_form_value(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
@attachments_ns.route("/store_attachment")
class StoreAttachment(Resource):
@api.expect(
api.model(
"AttachmentModel",
{
"file": fields.Raw(required=True, description="File(s) to upload"),
"api_key": fields.String(
required=False, description="API key (optional)"
),
},
)
)
@api.doc(
description="Stores one or multiple attachments without vectorization or training. Supports user or API key authentication."
)
def post(self):
auth_user = _resolve_authenticated_user()
if hasattr(auth_user, "status_code"):
return auth_user
files = request.files.getlist("file")
if not files:
single_file = request.files.get("file")
if single_file:
files = [single_file]
if not files or all(f.filename == "" for f in files):
return make_response(
jsonify({"status": "error", "message": "Missing file(s)"}),
400,
)
user = auth_user
if not user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
try:
from application.api.user.tasks import store_attachment
from application.api.user.base import storage
tasks = []
errors = []
original_file_count = len(files)
for idx, file in enumerate(files):
try:
attachment_id = uuid.uuid4()
original_filename = safe_filename(os.path.basename(file.filename))
_enforce_uploaded_audio_size_limit(file, original_filename)
# safe_filename only the path component; the DB user_id stays raw.
path_user = safe_filename(user)
relative_path = (
f"{settings.UPLOAD_FOLDER}/{path_user}/attachments/"
f"{str(attachment_id)}/{original_filename}"
)
metadata = storage.save_file(file, relative_path)
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id),
"path": relative_path,
"metadata": metadata,
}
task = store_attachment.delay(file_info, user)
tasks.append({
"task_id": task.id,
"filename": original_filename,
"attachment_id": str(attachment_id),
"upload_index": idx,
})
except Exception as file_err:
current_app.logger.error(f"Error processing file {idx} ({file.filename}): {file_err}", exc_info=True)
errors.append({
"upload_index": idx,
"filename": file.filename,
"error": _get_store_attachment_user_error(file_err),
})
if not tasks:
if errors and all(
error.get("error") == build_stt_file_size_limit_message()
for error in errors
):
return make_response(
jsonify(
{
"success": False,
"message": build_stt_file_size_limit_message(),
"errors": errors,
}
),
413,
)
return make_response(
jsonify({"status": "error", "message": "No valid files to upload"}),
400,
)
if original_file_count == 1 and len(tasks) == 1:
current_app.logger.info("Returning single task_id response")
return make_response(
jsonify(
{
"success": True,
"task_id": tasks[0]["task_id"],
# Surface the attachment_id so the frontend
# can correlate ``attachment.*`` SSE events
# to this row and skip the polling fallback.
"attachment_id": tasks[0]["attachment_id"],
"message": "File uploaded successfully. Processing started.",
}
),
200,
)
else:
response_data = {
"success": True,
"tasks": tasks,
"message": f"{len(tasks)} file(s) uploaded successfully. Processing started.",
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" {len(errors)} file(s) failed."
return make_response(
jsonify(response_data),
200,
)
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to store attachment"}), 400)
@attachments_ns.route("/stt")
class SpeechToText(Resource):
@api.expect(
api.model(
"SpeechToTextModel",
{
"file": fields.Raw(required=True, description="Audio file"),
"language": fields.String(
required=False, description="Optional transcription language hint"
),
},
)
)
@api.doc(description="Transcribe an uploaded audio file")
def post(self):
auth_user = _resolve_authenticated_user()
if hasattr(auth_user, "status_code"):
return auth_user
if not auth_user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}),
401,
)
file = request.files.get("file")
if not file or file.filename == "":
return make_response(
jsonify({"success": False, "message": "Missing file"}),
400,
)
filename = safe_filename(os.path.basename(file.filename))
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
return make_response(
jsonify({"success": False, "message": "Unsupported audio format"}),
400,
)
if not _is_supported_audio_mimetype(file.mimetype or ""):
return make_response(
jsonify({"success": False, "message": "Unsupported audio MIME type"}),
400,
)
try:
_enforce_uploaded_audio_size_limit(file, filename)
except AudioFileTooLargeError:
return make_response(
jsonify(
{
"success": False,
"message": build_stt_file_size_limit_message(),
}
),
413,
)
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
file.save(temp_file.name)
temp_path = Path(temp_file.name)
stt_instance = STTCreator.create_stt(settings.STT_PROVIDER)
transcript = stt_instance.transcribe(
temp_path,
language=request.form.get("language") or settings.STT_LANGUAGE,
timestamps=settings.STT_ENABLE_TIMESTAMPS,
diarize=settings.STT_ENABLE_DIARIZATION,
)
return make_response(jsonify({"success": True, **transcript}), 200)
except Exception as err:
current_app.logger.error(f"Error transcribing audio: {err}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Failed to transcribe audio"}),
400,
)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
@attachments_ns.route("/stt/live/start")
class LiveSpeechToTextStart(Resource):
@api.doc(description="Start a live speech-to-text session")
def post(self):
auth_user = _resolve_authenticated_user()
if hasattr(auth_user, "status_code"):
return auth_user
if not auth_user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}),
401,
)
redis_client = _require_live_stt_redis()
if hasattr(redis_client, "status_code"):
return redis_client
payload = request.get_json(silent=True) or {}
session_state = create_live_stt_session(
user=auth_user,
language=payload.get("language") or settings.STT_LANGUAGE,
)
save_live_stt_session(redis_client, session_state)
return make_response(
jsonify(
{
"success": True,
"session_id": session_state["session_id"],
"language": session_state.get("language"),
"committed_text": "",
"mutable_text": "",
"previous_hypothesis": "",
"latest_hypothesis": "",
"finalized_text": "",
"pending_text": "",
"transcript_text": "",
}
),
200,
)
@attachments_ns.route("/stt/live/chunk")
class LiveSpeechToTextChunk(Resource):
@api.expect(
api.model(
"LiveSpeechToTextChunkModel",
{
"session_id": fields.String(
required=True, description="Live transcription session ID"
),
"chunk_index": fields.Integer(
required=True, description="Sequential chunk index"
),
"is_silence": fields.Boolean(
required=False,
description="Whether the latest capture window was mostly silence",
),
"file": fields.Raw(required=True, description="Audio chunk"),
},
)
)
@api.doc(description="Transcribe a chunk for a live speech-to-text session")
def post(self):
auth_user = _resolve_authenticated_user()
if hasattr(auth_user, "status_code"):
return auth_user
if not auth_user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}),
401,
)
redis_client = _require_live_stt_redis()
if hasattr(redis_client, "status_code"):
return redis_client
session_id = request.form.get("session_id", "").strip()
if not session_id:
return make_response(
jsonify({"success": False, "message": "Missing session_id"}),
400,
)
session_state = load_live_stt_session(redis_client, session_id)
if not session_state:
return make_response(
jsonify(
{
"success": False,
"message": "Live transcription session not found",
}
),
404,
)
# The stored ``user`` is the RAW sub (see _resolve_authenticated_user), and
# auth_user is also raw, so compare raw-vs-raw -- never safe_filename'd, or an
# email-style sub (a@b.com) would 403 the owner on their own session.
if str(session_state.get("user", "")) != auth_user:
return make_response(
jsonify({"success": False, "message": "Forbidden"}),
403,
)
chunk_index_raw = request.form.get("chunk_index", "").strip()
if chunk_index_raw == "":
return make_response(
jsonify({"success": False, "message": "Missing chunk_index"}),
400,
)
try:
chunk_index = int(chunk_index_raw)
except ValueError:
return make_response(
jsonify({"success": False, "message": "Invalid chunk_index"}),
400,
)
is_silence = _parse_bool_form_value(request.form.get("is_silence"))
file = request.files.get("file")
if not file or file.filename == "":
return make_response(
jsonify({"success": False, "message": "Missing file"}),
400,
)
filename = safe_filename(os.path.basename(file.filename))
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
return make_response(
jsonify({"success": False, "message": "Unsupported audio format"}),
400,
)
if not _is_supported_audio_mimetype(file.mimetype or ""):
return make_response(
jsonify({"success": False, "message": "Unsupported audio MIME type"}),
400,
)
try:
_enforce_uploaded_audio_size_limit(file, filename)
except AudioFileTooLargeError:
return make_response(
jsonify(
{
"success": False,
"message": build_stt_file_size_limit_message(),
}
),
413,
)
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
file.save(temp_file.name)
temp_path = Path(temp_file.name)
session_language = session_state.get("language") or settings.STT_LANGUAGE
stt_instance = STTCreator.create_stt(settings.STT_PROVIDER)
transcript = stt_instance.transcribe(
temp_path,
language=session_language,
timestamps=False,
diarize=False,
)
if not session_state.get("language") and transcript.get("language"):
session_state["language"] = transcript["language"]
try:
apply_live_stt_hypothesis(
session_state,
str(transcript.get("text", "")),
chunk_index,
is_silence=is_silence,
)
except ValueError:
current_app.logger.warning(
"Invalid live transcription chunk",
exc_info=True,
)
return make_response(
jsonify(
{
"success": False,
"message": "Invalid live transcription chunk",
}
),
409,
)
save_live_stt_session(redis_client, session_state)
return make_response(
jsonify(
{
"success": True,
"session_id": session_id,
"chunk_index": chunk_index,
"chunk_text": transcript.get("text", ""),
"is_silence": is_silence,
"language": session_state.get("language"),
"committed_text": session_state.get("committed_text", ""),
"mutable_text": session_state.get("mutable_text", ""),
"previous_hypothesis": session_state.get(
"previous_hypothesis", ""
),
"latest_hypothesis": session_state.get(
"latest_hypothesis", ""
),
"finalized_text": session_state.get("committed_text", ""),
"pending_text": session_state.get("mutable_text", ""),
"transcript_text": get_live_stt_transcript_text(session_state),
}
),
200,
)
except Exception as err:
current_app.logger.error(
f"Error transcribing live audio chunk: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Failed to transcribe audio"}),
400,
)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
@attachments_ns.route("/stt/live/finish")
class LiveSpeechToTextFinish(Resource):
@api.doc(description="Finish a live speech-to-text session")
def post(self):
auth_user = _resolve_authenticated_user()
if hasattr(auth_user, "status_code"):
return auth_user
if not auth_user:
return make_response(
jsonify({"success": False, "message": "Authentication required"}),
401,
)
redis_client = _require_live_stt_redis()
if hasattr(redis_client, "status_code"):
return redis_client
payload = request.get_json(silent=True) or {}
session_id = str(payload.get("session_id", "")).strip()
if not session_id:
return make_response(
jsonify({"success": False, "message": "Missing session_id"}),
400,
)
session_state = load_live_stt_session(redis_client, session_id)
if not session_state:
return make_response(
jsonify(
{
"success": False,
"message": "Live transcription session not found",
}
),
404,
)
# Stored ``user`` and auth_user are both RAW subs; compare raw-vs-raw so an
# email-style sub owner is not 403'd on their own finish call.
if str(session_state.get("user", "")) != auth_user:
return make_response(
jsonify({"success": False, "message": "Forbidden"}),
403,
)
final_text = finalize_live_stt_session(session_state)
delete_live_stt_session(redis_client, session_id)
return make_response(
jsonify(
{
"success": True,
"session_id": session_id,
"language": session_state.get("language"),
"text": final_text,
}
),
200,
)
@attachments_ns.route("/images/<path:image_path>")
class ServeImage(Resource):
@api.doc(description="Serve an image from storage")
def get(self, image_path):
if ".." in image_path or image_path.startswith("/") or "\x00" in image_path:
return make_response(
jsonify({"success": False, "message": "Invalid image path"}), 400
)
try:
from application.api.user.base import storage
file_obj = storage.get_file(image_path)
extension = image_path.split(".")[-1].lower()
content_type = f"image/{extension}"
if extension == "jpg":
content_type = "image/jpeg"
response = make_response(file_obj.read())
response.headers.set("Content-Type", content_type)
response.headers.set("Cache-Control", "max-age=86400")
return response
except FileNotFoundError:
return make_response(
jsonify({"success": False, "message": "Image not found"}), 404
)
except ValueError:
return make_response(
jsonify({"success": False, "message": "Invalid image path"}), 400
)
except Exception as e:
current_app.logger.error(f"Error serving image: {e}")
return make_response(
jsonify({"success": False, "message": "Error retrieving image"}), 500
)
@attachments_ns.route("/tts")
class TextToSpeech(Resource):
tts_model = api.model(
"TextToSpeechModel",
{
"text": fields.String(
required=True, description="Text to be synthesized as audio"
),
},
)
@api.expect(tts_model)
@api.doc(description="Synthesize audio speech from text")
def post(self):
data = request.get_json()
text = data["text"]
try:
tts_instance = TTSCreator.create_tts(settings.TTS_PROVIDER)
audio_base64, detected_language = tts_instance.text_to_speech(text)
return make_response(
jsonify(
{
"success": True,
"audio_base64": audio_base64,
"lang": detected_language,
}
),
200,
)
except Exception as err:
current_app.logger.error(f"Error synthesizing audio: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
+97
View File
@@ -0,0 +1,97 @@
"""Role resolution and authorization helpers (admin/user RBAC).
Roles are resolved per-request from the database plus computed overlays and are
NEVER read from the inbound JWT: ``simple_jwt``/``session_jwt`` tokens are
self-mintable, so trusting a ``roles`` claim would be a trivial privilege
escalation. ``resolve_roles`` always rebuilds the set from scratch.
Policy (see ``rbac-spec.md``):
- Persisted RBAC (``user_roles``) applies only under ``AUTH_TYPE=oidc``.
- ``AUTH_TYPE=None`` (no-auth self-host) grants admin only when
``LOCAL_MODE_ADMIN`` is enabled (default off).
- ``simple_jwt`` / ``session_jwt`` can never be admin (shared/throwaway ``sub``).
- ``/v1`` agent keys, device tokens, and pre-auth requests are role-less.
"""
from __future__ import annotations
import logging
from functools import wraps
from flask import jsonify, make_response, request
from application.core.settings import settings
from application.storage.db.repositories.user_roles import UserRolesRepository
from application.storage.db.session import db_readonly
logger = logging.getLogger(__name__)
ROLE_USER = "user"
ROLE_ADMIN = "admin"
def resolve_roles(token: dict | None) -> list[str]:
"""Compute the sorted role set for a request principal.
Fails open to less privilege: a DB error while reading grants demotes
DB-backed admins to ``user`` rather than raising (which would turn a roles
outage into a total-auth outage on the universal before-request path).
"""
if not token:
return [ROLE_USER]
roles = {ROLE_USER}
sub = token.get("sub")
if settings.AUTH_TYPE == "oidc":
if sub:
try:
with db_readonly() as conn:
roles.update(UserRolesRepository(conn).role_names_for(sub))
except Exception:
logger.error(
"resolve_roles: user_roles read failed for sub=%s", sub, exc_info=True
)
elif settings.AUTH_TYPE is None and settings.LOCAL_MODE_ADMIN:
roles.add(ROLE_ADMIN)
# simple_jwt / session_jwt: never admin.
return sorted(roles)
def has_role(token: dict | None, name: str) -> bool:
"""True if the principal holds ``name``.
``user`` is implicit and always true. Tolerates a ``None`` token or a token
that never went through ``resolve_roles`` (e.g. async/SSE or /v1 paths) —
those are treated as role-less and only satisfy the implicit ``user`` role.
"""
if name == ROLE_USER:
return True
roles = (token or {}).get("roles") or []
return name in roles
def require_role(name: str):
"""Decorator factory: 401 when unauthenticated, 403 when lacking ``name``.
Fails closed — a missing/None token or absent ``roles`` key never raises and
never passes through. The frontend route guard is cosmetic; this is the
security boundary.
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
token = getattr(request, "decoded_token", None)
if not token:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
if not has_role(token, name):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
return func(*args, **kwargs)
return wrapper
return decorator
admin_required = require_role(ROLE_ADMIN)
+308
View File
@@ -0,0 +1,308 @@
"""
Shared utilities, database connections, and helper functions for user API routes.
"""
import datetime
import os
import uuid
from functools import wraps
from typing import Optional, Tuple
from flask import current_app, jsonify, make_response, Response
from werkzeug.utils import secure_filename
from sqlalchemy import text as _sql_text
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
from application.storage.storage_creator import StorageCreator
from application.vectorstore.vector_creator import VectorCreator
storage = StorageCreator.get_storage()
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def generate_minute_range(start_date, end_date):
"""Generate a dictionary with minute-level time ranges."""
return {
(start_date + datetime.timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 60) + 1)
}
def generate_hourly_range(start_date, end_date):
"""Generate a dictionary with hourly time ranges."""
return {
(start_date + datetime.timedelta(hours=i)).strftime("%Y-%m-%d %H:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 3600) + 1)
}
def generate_date_range(start_date, end_date):
"""Generate a dictionary with daily date ranges."""
return {
(start_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d"): 0
for i in range((end_date - start_date).days + 1)
}
def ensure_user_doc(user_id):
"""
Ensure a Postgres ``users`` row exists for ``user_id``.
Returns the row as a dict with the shape legacy callers expect — in
particular ``user_id`` and ``agent_preferences`` (with ``pinned`` and
``shared_with_me`` list keys always present).
Args:
user_id: The user ID to ensure
Returns:
The user document as a dict.
"""
with db_session() as conn:
user_doc = UsersRepository(conn).upsert(user_id)
prefs = user_doc.get("agent_preferences") or {}
if not isinstance(prefs, dict):
prefs = {}
prefs.setdefault("pinned", [])
prefs.setdefault("shared_with_me", [])
user_doc["agent_preferences"] = prefs
return user_doc
def resolve_tool_details(tool_ids):
"""
Resolve tool IDs to their display details.
Accepts Postgres UUIDs, legacy Mongo ObjectId strings, or the
synthetic ids of default chat tools / agent-selectable builtins
(mixed lists are supported). Synthetic ids are resolved in memory;
real ids are looked up via ``get_any``. Unknown ids are silently
skipped.
Args:
tool_ids: List of tool IDs (UUIDs, legacy ObjectId strings, or
synthetic default-tool / builtin ids).
Returns:
List of tool details with ``id``, ``name``, and ``display_name``.
"""
if not tool_ids:
return []
from application.agents.default_tools import (
is_synthesized_tool_id,
synthesize_tool_by_name,
synthesized_tool_name_for_id,
)
uuid_ids: list[str] = []
legacy_ids: list[str] = []
default_details: list[dict] = []
for tid in tool_ids:
if not tid:
continue
tid_str = str(tid)
if is_synthesized_tool_id(tid_str):
synth = synthesize_tool_by_name(synthesized_tool_name_for_id(tid_str))
if synth is not None:
default_details.append(
{
"id": tid_str,
"name": synth.get("name", ""),
"display_name": synth.get("display_name", ""),
}
)
continue
if looks_like_uuid(tid_str):
uuid_ids.append(tid_str)
else:
legacy_ids.append(tid_str)
if not uuid_ids and not legacy_ids:
return default_details
rows: list[dict] = []
with db_readonly() as conn:
if uuid_ids:
result = conn.execute(
_sql_text(
"SELECT * FROM user_tools "
"WHERE id = ANY(CAST(:ids AS uuid[]))"
),
{"ids": uuid_ids},
)
rows.extend(row_to_dict(r) for r in result.fetchall())
if legacy_ids:
result = conn.execute(
_sql_text(
"SELECT * FROM user_tools "
"WHERE legacy_mongo_id = ANY(:ids)"
),
{"ids": legacy_ids},
)
rows.extend(row_to_dict(r) for r in result.fetchall())
return default_details + [
{
"id": str(tool.get("id") or tool.get("legacy_mongo_id") or ""),
"name": tool.get("name", "") or "",
"display_name": (
tool.get("custom_name")
or tool.get("display_name")
or tool.get("name", "")
or ""
),
}
for tool in rows
]
_BUILTIN_PROMPT_NAMES = {
"default": "Default",
"creative": "Creative",
"strict": "Strict",
}
def resolve_prompt_name(prompt_id) -> Optional[str]:
"""Resolve a prompt id to its display name by id (owner-agnostic).
Mirrors ``resolve_tool_details``: looks the prompt up by id without user
scoping, so a team member viewing a shared agent sees the OWNER's prompt
name rather than nothing. Built-in prompt sentinels map to friendly labels.
Returns None when the prompt is missing/unknown.
"""
if not prompt_id:
return None
pid = str(prompt_id)
if pid in _BUILTIN_PROMPT_NAMES:
return _BUILTIN_PROMPT_NAMES[pid]
if not looks_like_uuid(pid):
return None
with db_readonly() as conn:
result = conn.execute(
_sql_text("SELECT name FROM prompts WHERE id = CAST(:id AS uuid)"),
{"id": pid},
)
row = result.fetchone()
return row[0] if row is not None else None
def resolve_source_details(source_ids) -> list[dict]:
"""Resolve source ids to ``[{"id", "name"}]`` by id (owner-agnostic).
Order-preserving; an id with no matching source row yields ``name: None`` so
the client can fall back. Lets a team member viewing a shared agent see the
owner's source names instead of a raw id / "External KB".
"""
ids = [str(s) for s in (source_ids or []) if s and looks_like_uuid(str(s))]
if not ids:
return []
with db_readonly() as conn:
result = conn.execute(
_sql_text(
"SELECT id, name FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))"
),
{"ids": ids},
)
by_id = {str(r[0]): r[1] for r in result.fetchall()}
return [{"id": sid, "name": by_id.get(sid)} for sid in ids]
def get_vector_store(source_id):
"""
Get the Vector Store for a given source ID.
Args:
source_id (str): source id of the document
Returns:
Vector store instance
"""
store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE,
source_id=source_id,
embeddings_key=os.getenv("EMBEDDINGS_KEY"),
)
return store
def handle_image_upload(
request, existing_url: str, user: str, storage, base_path: str = "attachments/"
) -> Tuple[str, Optional[Response]]:
"""
Handle image file upload from request.
Args:
request: Flask request object
existing_url: Existing image URL (fallback)
user: User ID
storage: Storage instance
base_path: Base path for upload
Returns:
Tuple of (image_url, error_response)
"""
image_url = existing_url
if "image" in request.files:
file = request.files["image"]
if file.filename != "":
filename = secure_filename(file.filename)
upload_path = f"{settings.UPLOAD_FOLDER.rstrip('/')}/{user}/{base_path.rstrip('/')}/{uuid.uuid4()}_{filename}"
try:
storage.save_file(file, upload_path, storage_class="STANDARD")
image_url = upload_path
except Exception as e:
current_app.logger.error(f"Error uploading image: {e}")
return None, make_response(
jsonify({"success": False, "message": "Image upload failed"}),
400,
)
return image_url, None
def require_agent(func):
"""
Decorator to require valid agent webhook token.
Args:
func: Function to decorate
Returns:
Wrapped function
"""
@wraps(func)
def wrapper(*args, **kwargs):
from application.storage.db.repositories.agents import AgentsRepository
webhook_token = kwargs.get("webhook_token")
if not webhook_token:
return make_response(
jsonify({"success": False, "message": "Webhook token missing"}), 400
)
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_webhook_token(webhook_token)
if not agent:
current_app.logger.warning(
f"Webhook attempt with invalid token: {webhook_token}"
)
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
kwargs["agent"] = agent
kwargs["agent_id_str"] = str(agent["id"])
return func(*args, **kwargs)
return wrapper
@@ -0,0 +1,5 @@
"""Conversation management module."""
from .routes import conversations_ns
__all__ = ["conversations_ns"]
@@ -0,0 +1,475 @@
"""Conversation management routes."""
import datetime
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from sqlalchemy import text as sql_text
from application.api import api
from application.api.answer.services.conversation_service import (
TERMINATED_RESPONSE_PLACEHOLDER,
)
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
from application.storage.db.repositories.attachments import AttachmentsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.message_events import MessageEventsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
conversations_ns = Namespace(
"conversations", description="Conversation management operations", path="/api"
)
@conversations_ns.route("/delete_conversation")
class DeleteConversation(Resource):
@api.doc(
description="Deletes a conversation by ID",
params={"id": "The ID of the conversation to delete"},
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
conversation_id = request.args.get("id")
if not conversation_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
user_id = decoded_token["sub"]
try:
with db_session() as conn:
repo = ConversationsRepository(conn)
conv = repo.get_any(conversation_id, user_id)
if conv is not None:
repo.delete(str(conv["id"]), user_id)
except Exception as err:
current_app.logger.error(
f"Error deleting conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/delete_all_conversations")
class DeleteAllConversations(Resource):
@api.doc(
description="Deletes all conversations for a specific user",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
with db_session() as conn:
ConversationsRepository(conn).delete_all_for_user(user_id)
except Exception as err:
current_app.logger.error(
f"Error deleting all conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/get_conversations")
class GetConversations(Resource):
@api.doc(
description="Retrieve a list of the latest 30 sidebar conversations (visibility = listed)",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
conversations = ConversationsRepository(conn).list_for_user(
user_id, limit=30
)
list_conversations = [
{
"id": str(conversation["id"]),
"name": conversation["name"],
"agent_id": (
str(conversation["agent_id"])
if conversation.get("agent_id")
else None
),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
}
for conversation in conversations
]
except Exception as err:
current_app.logger.error(
f"Error retrieving conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_conversations), 200)
@conversations_ns.route("/search_conversations")
class SearchConversations(Resource):
@staticmethod
def _build_match_snippet(text_value: str, query: str, radius: int = 60) -> str:
if not text_value:
return ""
idx = text_value.lower().find(query.lower())
if idx == -1:
snippet = text_value[: radius * 2]
return snippet + ("" if len(text_value) > len(snippet) else "")
start = max(0, idx - radius)
end = min(len(text_value), idx + len(query) + radius)
snippet = text_value[start:end]
if start > 0:
snippet = "" + snippet
if end < len(text_value):
snippet = snippet + ""
return snippet
@api.doc(
description=(
"Search the authenticated user's conversations by name or "
"message content (case-insensitive substring match). Mirrors "
"the visibility filter and response shape of /get_conversations, "
"and additionally returns ``match_field`` (``name``, ``prompt`` "
"or ``response``) and ``match_snippet`` (a short excerpt of the "
"matched text centered on the query) for each result."
),
params={
"q": "Search term (required)",
"limit": "Maximum number of results to return (default 30, max 100)",
},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
query = (request.args.get("q") or "").strip()
if not query:
return make_response(
jsonify({"success": False, "message": "q is required"}), 400
)
try:
limit = int(request.args.get("limit", 30))
except (TypeError, ValueError):
limit = 30
limit = max(1, min(limit, 100))
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
conversations = ConversationsRepository(conn).search_for_user(
user_id, query, limit=limit
)
list_conversations = [
{
"id": str(conversation["id"]),
"name": conversation["name"],
"agent_id": (
str(conversation["agent_id"])
if conversation.get("agent_id")
else None
),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
"match_field": conversation.get("match_field"),
"match_snippet": self._build_match_snippet(
conversation.get("match_text") or "", query
),
}
for conversation in conversations
]
except Exception as err:
current_app.logger.error(
f"Error searching conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_conversations), 200)
@conversations_ns.route("/get_single_conversation")
class GetSingleConversation(Resource):
@api.doc(
description="Retrieve a single conversation by ID",
params={"id": "The conversation ID"},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
conversation_id = request.args.get("id")
if not conversation_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
repo = ConversationsRepository(conn)
conversation = repo.get_any(conversation_id, user_id)
if not conversation:
return make_response(jsonify({"status": "not found"}), 404)
conv_pg_id = str(conversation["id"])
messages = repo.get_messages(conv_pg_id)
# Resolve attachment details (id, fileName) for each message.
attachments_repo = AttachmentsRepository(conn)
queries = []
for msg in messages:
metadata = msg.get("metadata") or {}
query = {
"prompt": msg.get("prompt"),
"response": msg.get("response"),
"thought": msg.get("thought"),
"sources": msg.get("sources") or [],
"tool_calls": msg.get("tool_calls") or [],
"timestamp": msg.get("timestamp"),
"model_id": msg.get("model_id"),
# Lets the client distinguish placeholder rows from
# finalised answers and tail-poll in-flight ones.
"message_id": str(msg["id"]) if msg.get("id") else None,
"status": msg.get("status"),
"request_id": msg.get("request_id"),
"last_heartbeat_at": metadata.get("last_heartbeat_at"),
# Surfaced from metadata so the chat can render a
# workflow run's produced artifacts on reload.
"workflow_run_id": metadata.get("workflow_run_id"),
}
if metadata:
query["metadata"] = metadata
# Feedback on conversation_messages is a JSONB blob with
# shape {"text": <str>, "timestamp": <iso>}. The legacy
# frontend consumed a flat scalar feedback string, so
# unwrap the ``text`` field for compat.
feedback = msg.get("feedback")
if feedback is not None:
if isinstance(feedback, dict):
query["feedback"] = feedback.get("text")
if feedback.get("timestamp"):
query["feedback_timestamp"] = feedback["timestamp"]
else:
query["feedback"] = feedback
attachments = msg.get("attachments") or []
if attachments:
attachment_details = []
for attachment_id in attachments:
try:
att = attachments_repo.get_any(
str(attachment_id), user_id
)
if att:
attachment_details.append(
{
"id": str(att["id"]),
"fileName": att.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
queries.append(query)
except Exception as err:
current_app.logger.error(
f"Error retrieving conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
data = {
"queries": queries,
"agent_id": (
str(conversation["agent_id"]) if conversation.get("agent_id") else None
),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
}
return make_response(jsonify(data), 200)
@conversations_ns.route("/update_conversation_name")
class UpdateConversationName(Resource):
@api.expect(
api.model(
"UpdateConversationModel",
{
"id": fields.String(required=True, description="Conversation ID"),
"name": fields.String(
required=True, description="New name of the conversation"
),
},
)
)
@api.doc(
description="Updates the name of a conversation",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["id", "name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
user_id = decoded_token.get("sub")
try:
with db_session() as conn:
repo = ConversationsRepository(conn)
conv = repo.get_any(data["id"], user_id)
if conv is not None:
repo.rename(str(conv["id"]), user_id, data["name"])
except Exception as err:
current_app.logger.error(
f"Error updating conversation name: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/feedback")
class SubmitFeedback(Resource):
@api.expect(
api.model(
"FeedbackModel",
{
"question": fields.String(
required=False, description="The user question"
),
"answer": fields.String(required=False, description="The AI answer"),
"feedback": fields.String(required=True, description="User feedback"),
"question_index": fields.Integer(
required=True,
description="The question number in that particular conversation",
),
"conversation_id": fields.String(
required=True, description="id of the particular conversation"
),
"api_key": fields.String(description="Optional API key"),
},
)
)
@api.doc(
description="Submit feedback for a conversation",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["feedback", "conversation_id", "question_index"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
user_id = decoded_token.get("sub")
feedback_value = data["feedback"]
question_index = int(data["question_index"])
# Normalize string feedback to lowercase so analytics queries
# (which match 'like'/'dislike') count rows correctly. Tolerate
# legacy uppercase clients on ingest. Non-string values pass through.
if isinstance(feedback_value, str):
feedback_value = feedback_value.lower()
feedback_payload = (
None
if feedback_value is None
else {
"text": feedback_value,
"timestamp": datetime.datetime.now(
datetime.timezone.utc
).isoformat(),
}
)
try:
with db_session() as conn:
repo = ConversationsRepository(conn)
conv = repo.get_any(data["conversation_id"], user_id)
if conv is None:
return make_response(
jsonify({"success": False, "message": "Not found"}), 404
)
repo.set_feedback(str(conv["id"]), question_index, feedback_payload)
except Exception as err:
current_app.logger.error(f"Error submitting feedback: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/messages/<string:message_id>/tail")
class GetMessageTail(Resource):
@api.doc(
description=(
"Current state of one conversation_messages row, scoped to the "
"authenticated user. Used to reconnect to an in-flight stream "
"after a refresh."
),
params={"message_id": "Message UUID"},
)
def get(self, message_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
if not looks_like_uuid(message_id):
return make_response(
jsonify({"success": False, "message": "Invalid message id"}), 400
)
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
# Owner-or-shared, matching ``ConversationsRepository.get``.
row = conn.execute(
sql_text(
"SELECT m.* FROM conversation_messages m "
"JOIN conversations c ON c.id = m.conversation_id "
"WHERE m.id = CAST(:mid AS uuid) "
"AND (c.user_id = :uid OR :uid = ANY(c.shared_with))"
),
{"mid": message_id, "uid": user_id},
).fetchone()
if row is None:
return make_response(jsonify({"status": "not found"}), 404)
msg = row_to_dict(row)
# Mid-stream the row's response is the placeholder; rebuild
# the live partial from the journal so /tail mirrors SSE.
status = msg.get("status")
response = msg.get("response")
thought = msg.get("thought")
sources = msg.get("sources") or []
tool_calls = msg.get("tool_calls") or []
if status in ("pending", "streaming") and (
response == TERMINATED_RESPONSE_PLACEHOLDER
):
partial = MessageEventsRepository(conn).reconstruct_partial(
message_id
)
response = partial["response"]
thought = partial["thought"] or thought
if partial["sources"]:
sources = partial["sources"]
if partial["tool_calls"]:
tool_calls = partial["tool_calls"]
except Exception as err:
current_app.logger.error(
f"Error tailing message {message_id}: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
metadata = msg.get("message_metadata") or {}
return make_response(
jsonify(
{
"message_id": str(msg["id"]),
"status": status,
"response": response,
"thought": thought,
"sources": sources,
"tool_calls": tool_calls,
"request_id": msg.get("request_id"),
"last_heartbeat_at": metadata.get("last_heartbeat_at"),
"error": metadata.get("error"),
}
),
200,
)
+294
View File
@@ -0,0 +1,294 @@
"""Per-Celery-task idempotency wrapper backed by ``task_dedup``."""
from __future__ import annotations
import functools
import inspect
import logging
import threading
import uuid
from typing import Any, Callable, Optional
from application.storage.db.repositories.idempotency import IdempotencyRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
# Poison-loop cap; transient-failure headroom without infinite retry.
MAX_TASK_ATTEMPTS = 5
# 30s heartbeat / 60s TTL → ~2 missed ticks of slack before reclaim.
LEASE_TTL_SECONDS = 60
LEASE_HEARTBEAT_INTERVAL = 30
# 10 × 60s ≈ 5 min of deferral before giving up on a held lease.
LEASE_RETRY_MAX = 10
def with_idempotency(
task_name: str,
*,
on_poison: Optional[Callable[[str, dict], None]] = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Short-circuit on completed key; gate concurrent runs via a lease.
The guard key is the caller's ``idempotency_key``, or one synthesized
from ``source_id`` so a keyless dispatch is still poison-guarded.
Entry short-circuits:
- completed row → return cached result
- live lease held → retry(countdown=LEASE_TTL_SECONDS)
- attempt_count > MAX_TASK_ATTEMPTS → poison alert; ``on_poison`` fires
Success writes ``completed``; exceptions leave ``pending`` for
autoretry until the poison-loop guard trips.
"""
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(self, *args: Any, idempotency_key: Any = None, **kwargs: Any) -> Any:
explicit_key = (
idempotency_key
if isinstance(idempotency_key, str) and idempotency_key
else None
)
# A keyless dispatch still gets the guard via a synthesized key;
# None means no anchor exists — run unguarded, as before.
key = explicit_key or _synthesize_guard_key(task_name, kwargs)
if key is None:
return fn(self, *args, idempotency_key=idempotency_key, **kwargs)
cached = _lookup_completed(key)
if cached is not None:
logger.info(
"idempotency hit for task=%s key=%s — returning cached result",
task_name, key,
)
return cached
owner_id = str(uuid.uuid4())
attempt = _try_claim_lease(
key, task_name, _safe_task_id(self), owner_id,
)
if attempt is None:
# Live lease held by another worker. Re-queue and bail
# quickly — by the time the retry fires (LEASE_TTL
# seconds), Worker 1 has either finalised (we'll hit
# ``_lookup_completed`` and return cached) or its lease
# has expired and we can claim.
logger.info(
"idempotency: live lease held; deferring task=%s key=%s",
task_name, key,
)
raise self.retry(
countdown=LEASE_TTL_SECONDS,
max_retries=LEASE_RETRY_MAX,
)
if attempt > MAX_TASK_ATTEMPTS:
logger.error(
"idempotency poison-loop guard: task=%s key=%s attempts=%s",
task_name, key, attempt,
extra={
"alert": "idempotency_poison_loop",
"task_name": task_name,
"idempotency_key": key,
"attempts": attempt,
},
)
poisoned = {
"success": False,
"error": "idempotency poison-loop guard tripped",
"attempts": attempt,
}
_finalize(key, poisoned, status="failed")
_run_poison_hook(
on_poison, task_name, fn, self, args, kwargs, idempotency_key,
)
return poisoned
heartbeat_thread, heartbeat_stop = _start_lease_heartbeat(
key, owner_id,
)
try:
result = fn(self, *args, idempotency_key=idempotency_key, **kwargs)
_finalize(key, result, status="completed")
return result
except Exception:
# Drop the lease so the next retry doesn't wait LEASE_TTL.
_release_lease(key, owner_id)
raise
finally:
_stop_lease_heartbeat(heartbeat_thread, heartbeat_stop)
return wrapper
return decorator
def _synthesize_guard_key(task_name: str, kwargs: dict) -> Optional[str]:
"""Derive a deterministic guard key from ``source_id`` for a keyless dispatch.
``source_id`` is stable across broker redeliveries and unique per
upload, so the poison-loop counter survives an OOM SIGKILL. Returns
``None`` when absent — the dispatch then runs unguarded as before.
"""
source_id = kwargs.get("source_id")
if source_id:
return f"auto:{task_name}:{source_id}"
return None
def _run_poison_hook(
on_poison: Optional[Callable[[str, dict], None]],
task_name: str,
fn: Callable[..., Any],
task_self: Any,
args: tuple,
kwargs: dict,
idempotency_key: Any,
) -> None:
"""Invoke a task's poison-path hook with named call args; swallow failures.
A hook failure must never change the poison-guard outcome.
"""
if on_poison is None:
return
try:
bound = inspect.signature(fn).bind_partial(
task_self, *args, idempotency_key=idempotency_key, **kwargs,
)
on_poison(task_name, dict(bound.arguments))
except Exception:
logger.exception(
"idempotency: poison hook failed for task=%s", task_name,
)
def _lookup_completed(key: str) -> Any:
"""Return cached ``result_json`` if a completed row exists for ``key``, else None."""
with db_readonly() as conn:
row = IdempotencyRepository(conn).get_task(key)
if row is None:
return None
if row.get("status") != "completed":
return None
return row.get("result_json")
def _try_claim_lease(
key: str, task_name: str, task_id: str, owner_id: str,
) -> Optional[int]:
"""Atomic CAS; returns ``attempt_count`` or ``None`` when held.
DB outage → treated as ``attempt=1`` so transient failures don't
block all task execution; reconciler repairs the lease columns.
"""
try:
with db_session() as conn:
return IdempotencyRepository(conn).try_claim_lease(
key=key,
task_name=task_name,
task_id=task_id,
owner_id=owner_id,
ttl_seconds=LEASE_TTL_SECONDS,
)
except Exception:
logger.exception(
"idempotency lease-claim failed for key=%s task=%s", key, task_name,
)
return 1
def _finalize(key: str, result_json: Any, *, status: str) -> None:
"""Best-effort terminal write. Never let DB outage fail the task."""
try:
with db_session() as conn:
IdempotencyRepository(conn).finalize_task(
key=key, result_json=result_json, status=status,
)
except Exception:
logger.exception(
"idempotency finalize failed for key=%s status=%s", key, status,
)
def _release_lease(key: str, owner_id: str) -> None:
"""Best-effort lease release on the wrapper's exception path."""
try:
with db_session() as conn:
IdempotencyRepository(conn).release_lease(key, owner_id)
except Exception:
logger.exception("idempotency release-lease failed for key=%s", key)
def _start_lease_heartbeat(
key: str, owner_id: str,
) -> tuple[threading.Thread, threading.Event]:
"""Spawn a daemon thread that bumps ``lease_expires_at`` every
:data:`LEASE_HEARTBEAT_INTERVAL` seconds until ``stop_event`` fires.
Mirrors ``application.worker._start_ingest_heartbeat`` so the two
durability heartbeats share shape and cadence.
"""
stop_event = threading.Event()
thread = threading.Thread(
target=_lease_heartbeat_loop,
args=(key, owner_id, stop_event, LEASE_HEARTBEAT_INTERVAL),
daemon=True,
name=f"idempotency-lease-heartbeat:{key[:32]}",
)
thread.start()
return thread, stop_event
def _stop_lease_heartbeat(
thread: threading.Thread, stop_event: threading.Event,
) -> None:
"""Signal the heartbeat thread to exit and join with a short timeout."""
stop_event.set()
thread.join(timeout=10)
def _lease_heartbeat_loop(
key: str,
owner_id: str,
stop_event: threading.Event,
interval: int,
) -> None:
"""Refresh the lease until ``stop_event`` is set or ownership is lost.
A failed refresh (rowcount 0) means another worker stole the lease
after expiry — at that point the damage is already possible, so we
log and keep ticking. Don't escalate to thread death; the main task
body needs to keep running so its outcome is at least *recorded*.
"""
while not stop_event.wait(interval):
try:
with db_session() as conn:
still_owned = IdempotencyRepository(conn).refresh_lease(
key=key, owner_id=owner_id, ttl_seconds=LEASE_TTL_SECONDS,
)
if not still_owned:
logger.warning(
"idempotency lease lost mid-task for key=%s "
"(another worker may have taken over)",
key,
)
except Exception:
logger.exception(
"idempotency lease-heartbeat tick failed for key=%s", key,
)
def _safe_task_id(task_self: Any) -> str:
"""Best-effort extraction of ``self.request.id`` from a Celery task."""
try:
request = getattr(task_self, "request", None)
task_id: Optional[str] = (
getattr(request, "id", None) if request is not None else None
)
except Exception:
task_id = None
return task_id or "unknown"
+3
View File
@@ -0,0 +1,3 @@
from .routes import me_ns
__all__ = ["me_ns"]
+34
View File
@@ -0,0 +1,34 @@
"""Current-principal endpoint.
``GET /api/user/me`` returns the caller's user id and resolved roles, sourced
only from ``request.decoded_token`` (already populated and role-resolved by the
auth chokepoint in ``app.py``). Auth-mode-agnostic. ``email``/``name``/
``picture`` are OIDC-only and optional — they are echoed from the token and are
never present for ``simple_jwt``/``session_jwt``/no-auth modes.
"""
from __future__ import annotations
from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
me_ns = Namespace("me", description="Current user identity and roles", path="/api")
@me_ns.route("/user/me")
class MeResource(Resource):
def get(self):
"""Return ``{user_id, roles, email?, name?, picture?}`` for the caller."""
decoded_token = getattr(request, "decoded_token", None)
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
body = {
"success": True,
"user_id": decoded_token.get("sub"),
"roles": decoded_token.get("roles") or ["user"],
}
for field in ("email", "name", "picture"):
value = decoded_token.get(field)
if value:
body[field] = value
return make_response(jsonify(body), 200)
+3
View File
@@ -0,0 +1,3 @@
from .routes import models_ns
__all__ = ["models_ns"]
+521
View File
@@ -0,0 +1,521 @@
"""Model routes.
- ``GET /api/models`` — list available models for the current user.
Combines the built-in catalog with the user's BYOM records.
- ``GET/POST/PATCH/DELETE /api/user/models[/<id>]`` — CRUD for the
user's own OpenAI-compatible model registrations (BYOM).
- ``POST /api/user/models/<id>/test`` — sanity-check the upstream
endpoint with a tiny request.
Every BYOM endpoint is user-scoped at the repository layer
(every query filters on ``user_id`` from ``request.decoded_token``).
"""
from __future__ import annotations
import logging
import requests
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
from application.api import api
from application.core.model_registry import ModelRegistry
from application.security.safe_url import (
UnsafeUserUrlError,
pinned_post,
validate_user_base_url,
)
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
logger = logging.getLogger(__name__)
models_ns = Namespace("models", description="Available models", path="/api")
_CONTEXT_WINDOW_MIN = 1_000
_CONTEXT_WINDOW_MAX = 10_000_000
def _user_id_or_401():
decoded_token = request.decoded_token
if not decoded_token:
return None, make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
if not user_id:
return None, make_response(jsonify({"success": False}), 401)
return user_id, None
def _normalize_capabilities(raw) -> dict:
"""Coerce + bound the user-supplied capabilities payload."""
raw = raw or {}
out = {}
if "supports_tools" in raw:
out["supports_tools"] = bool(raw["supports_tools"])
if "supports_structured_output" in raw:
out["supports_structured_output"] = bool(raw["supports_structured_output"])
if "supports_streaming" in raw:
out["supports_streaming"] = bool(raw["supports_streaming"])
if "attachments" in raw:
atts = raw["attachments"] or []
if not isinstance(atts, list):
raise ValueError("'capabilities.attachments' must be a list")
coerced = [str(a) for a in atts]
# Reject unknown aliases at the API boundary so bad payloads
# never reach the registry layer (where lenient expansion just
# drops them). Raw MIME types (containing ``/``) pass through
# unchanged for parity with the built-in YAML schema.
from application.core.model_yaml import builtin_attachment_aliases
aliases = builtin_attachment_aliases()
for entry in coerced:
if "/" in entry:
continue
if entry not in aliases:
valid = ", ".join(sorted(aliases.keys())) or "<none defined>"
raise ValueError(
f"unknown attachment alias '{entry}' in "
f"'capabilities.attachments'. Valid aliases: {valid}, "
f"or use a raw MIME type like 'image/png'."
)
out["attachments"] = coerced
if "context_window" in raw:
try:
cw = int(raw["context_window"])
except (TypeError, ValueError):
raise ValueError("'capabilities.context_window' must be an integer")
if not (_CONTEXT_WINDOW_MIN <= cw <= _CONTEXT_WINDOW_MAX):
raise ValueError(
f"'capabilities.context_window' must be between "
f"{_CONTEXT_WINDOW_MIN} and {_CONTEXT_WINDOW_MAX}"
)
out["context_window"] = cw
return out
def _row_to_response(row: dict) -> dict:
"""Wire-format projection — never includes the API key."""
return {
"id": str(row["id"]),
"upstream_model_id": row["upstream_model_id"],
"display_name": row["display_name"],
"description": row.get("description") or "",
"base_url": row["base_url"],
"capabilities": row.get("capabilities") or {},
"enabled": bool(row.get("enabled", True)),
"source": "user",
}
@models_ns.route("/models")
class ModelsListResource(Resource):
def get(self):
"""Get list of available models with their capabilities.
When the request is authenticated, the response includes the
user's own BYOM registrations alongside the built-in catalog.
"""
try:
user_id = None
decoded_token = getattr(request, "decoded_token", None)
if decoded_token:
user_id = decoded_token.get("sub")
registry = ModelRegistry.get_instance()
models = registry.get_enabled_models(user_id=user_id)
response = {
"models": [model.to_dict() for model in models],
"default_model_id": registry.default_model_id,
"count": len(models),
}
except Exception as err:
current_app.logger.error(f"Error fetching models: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
return make_response(jsonify(response), 200)
@models_ns.route("/user/models")
class UserModelsCollectionResource(Resource):
@api.doc(description="List the current user's BYOM custom models")
def get(self):
user_id, err = _user_id_or_401()
if err:
return err
try:
with db_readonly() as conn:
rows = UserCustomModelsRepository(conn).list_for_user(user_id)
return make_response(
jsonify({"models": [_row_to_response(r) for r in rows]}), 200
)
except Exception as e:
current_app.logger.error(
f"Error listing user custom models: {e}", exc_info=True
)
return make_response(jsonify({"success": False}), 500)
@api.doc(description="Register a new BYOM custom model")
def post(self):
user_id, err = _user_id_or_401()
if err:
return err
data = request.get_json() or {}
missing = check_required_fields(
data,
["upstream_model_id", "display_name", "base_url", "api_key"],
)
if missing:
return missing
# SECURITY: reject blank api_key — would leak instance API key
# to the user-supplied base_url via LLMCreator fallback.
for required_nonblank in (
"upstream_model_id",
"display_name",
"base_url",
"api_key",
):
value = data.get(required_nonblank)
if not isinstance(value, str) or not value.strip():
return make_response(
jsonify(
{
"success": False,
"error": f"'{required_nonblank}' must be a non-empty string",
}
),
400,
)
# SSRF guard at create time. Re-runs at dispatch time (LLMCreator)
# as defense in depth against DNS rebinding and pre-guard rows.
try:
validate_user_base_url(data["base_url"])
except UnsafeUserUrlError as e:
return make_response(
jsonify({"success": False, "error": str(e)}), 400
)
try:
capabilities = _normalize_capabilities(data.get("capabilities"))
except ValueError as e:
return make_response(
jsonify({"success": False, "error": str(e)}), 400
)
try:
with db_session() as conn:
row = UserCustomModelsRepository(conn).create(
user_id=user_id,
upstream_model_id=data["upstream_model_id"],
display_name=data["display_name"],
description=data.get("description") or "",
base_url=data["base_url"],
api_key_plaintext=data["api_key"],
capabilities=capabilities,
enabled=bool(data.get("enabled", True)),
)
except Exception as e:
current_app.logger.error(
f"Error creating user custom model: {e}", exc_info=True
)
return make_response(jsonify({"success": False}), 500)
ModelRegistry.invalidate_user(user_id)
return make_response(jsonify(_row_to_response(row)), 201)
@models_ns.route("/user/models/<string:model_id>")
class UserModelResource(Resource):
@api.doc(description="Get one BYOM custom model")
def get(self, model_id):
user_id, err = _user_id_or_401()
if err:
return err
try:
with db_readonly() as conn:
row = UserCustomModelsRepository(conn).get(model_id, user_id)
except Exception as e:
current_app.logger.error(
f"Error fetching user custom model: {e}", exc_info=True
)
return make_response(jsonify({"success": False}), 500)
if row is None:
return make_response(jsonify({"success": False}), 404)
return make_response(jsonify(_row_to_response(row)), 200)
@api.doc(description="Update a BYOM custom model (partial)")
def patch(self, model_id):
user_id, err = _user_id_or_401()
if err:
return err
data = request.get_json() or {}
# Reject present-but-blank values for fields where blank doesn't
# mean "no change". (The api_key special case — blank means "keep
# existing" — is handled below.)
for required_nonblank in (
"upstream_model_id",
"display_name",
"base_url",
):
if required_nonblank in data:
value = data[required_nonblank]
if not isinstance(value, str) or not value.strip():
return make_response(
jsonify(
{
"success": False,
"error": f"'{required_nonblank}' cannot be blank",
}
),
400,
)
if "base_url" in data and data["base_url"]:
try:
validate_user_base_url(data["base_url"])
except UnsafeUserUrlError as e:
return make_response(
jsonify({"success": False, "error": str(e)}), 400
)
update_fields: dict = {}
for k in (
"upstream_model_id",
"display_name",
"description",
"base_url",
"enabled",
):
if k in data:
update_fields[k] = data[k]
if "capabilities" in data:
try:
update_fields["capabilities"] = _normalize_capabilities(
data["capabilities"]
)
except ValueError as e:
return make_response(
jsonify({"success": False, "error": str(e)}), 400
)
# PATCH semantics: blank/missing api_key → keep the existing
# ciphertext; non-empty api_key → re-encrypt and replace.
if data.get("api_key"):
update_fields["api_key_plaintext"] = data["api_key"]
if not update_fields:
return make_response(
jsonify({"success": False, "error": "no updatable fields"}), 400
)
try:
with db_session() as conn:
ok = UserCustomModelsRepository(conn).update(
model_id, user_id, update_fields
)
except Exception as e:
current_app.logger.error(
f"Error updating user custom model: {e}", exc_info=True
)
return make_response(jsonify({"success": False}), 500)
if not ok:
return make_response(jsonify({"success": False}), 404)
ModelRegistry.invalidate_user(user_id)
with db_readonly() as conn:
row = UserCustomModelsRepository(conn).get(model_id, user_id)
return make_response(jsonify(_row_to_response(row)), 200)
@api.doc(description="Delete a BYOM custom model")
def delete(self, model_id):
user_id, err = _user_id_or_401()
if err:
return err
try:
with db_session() as conn:
ok = UserCustomModelsRepository(conn).delete(model_id, user_id)
except Exception as e:
current_app.logger.error(
f"Error deleting user custom model: {e}", exc_info=True
)
return make_response(jsonify({"success": False}), 500)
if not ok:
return make_response(jsonify({"success": False}), 404)
ModelRegistry.invalidate_user(user_id)
return make_response(jsonify({"success": True}), 200)
def _run_connection_test(
base_url: str, api_key: str, upstream_model_id: str
):
"""Send a 1-token chat-completion to verify a BYOM endpoint.
Returns ``(body, http_status)``. Upstream errors return 200 with
``ok=False`` so the UI can render inline errors; only local SSRF
rejection returns 400.
"""
url = base_url.rstrip("/") + "/chat/completions"
payload = {
"model": upstream_model_id,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1,
"stream": False,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
# pinned_post closes the DNS-rebinding window. Redirects off
# because 3xx could bounce to an internal address (the SSRF
# guard only validates the supplied URL).
resp = pinned_post(
url,
json=payload,
headers=headers,
timeout=5,
allow_redirects=False,
)
except UnsafeUserUrlError as e:
return {"ok": False, "error": str(e)}, 400
except requests.RequestException as e:
return {"ok": False, "error": f"connection error: {e}"}, 200
if 300 <= resp.status_code < 400:
return (
{
"ok": False,
"error": (
f"upstream returned HTTP {resp.status_code} "
"redirect; refusing to follow"
),
},
200,
)
if resp.status_code >= 400:
# Cap and only reflect JSON to avoid body-exfil via non-API responses.
content_type = (resp.headers.get("Content-Type") or "").lower()
if "application/json" in content_type:
text = (resp.text or "")[:500]
error_msg = f"upstream returned HTTP {resp.status_code}: {text}"
else:
error_msg = f"upstream returned HTTP {resp.status_code}"
return {"ok": False, "error": error_msg}, 200
return {"ok": True}, 200
@models_ns.route("/user/models/test")
class UserModelTestPayloadResource(Resource):
@api.doc(
description=(
"Test an arbitrary BYOM payload (display_name / model id / "
"base_url / api_key) without saving. Used by the UI's 'Test "
"connection' button so the user can validate before they "
"Save. Same SSRF guard, same 1-token request, same 5s "
"timeout as the by-id variant."
)
)
def post(self):
user_id, err = _user_id_or_401()
if err:
return err
data = request.get_json() or {}
missing = check_required_fields(
data, ["base_url", "api_key", "upstream_model_id"]
)
if missing:
return missing
body, status = _run_connection_test(
data["base_url"], data["api_key"], data["upstream_model_id"]
)
return make_response(jsonify(body), status)
@models_ns.route("/user/models/<string:model_id>/test")
class UserModelTestResource(Resource):
@api.doc(
description=(
"Test a saved BYOM record. Defaults to the stored "
"base_url / upstream_model_id / encrypted api_key, but "
"any of those can be overridden via the request body so "
"the UI can test in-flight edits before saving. Used by "
"the 'Test connection' button in edit mode."
)
)
def post(self, model_id):
user_id, err = _user_id_or_401()
if err:
return err
data = request.get_json() or {}
# Per-field overrides; blank/missing falls back to stored value.
override_base_url = (data.get("base_url") or "").strip() or None
override_upstream_model_id = (
data.get("upstream_model_id") or ""
).strip() or None
override_api_key = (data.get("api_key") or "").strip() or None
try:
with db_readonly() as conn:
repo = UserCustomModelsRepository(conn)
row = repo.get(model_id, user_id)
if row is None:
return make_response(jsonify({"success": False}), 404)
stored_api_key = (
repo._decrypt_api_key(
row.get("api_key_encrypted", ""), user_id
)
if not override_api_key
else None
)
except Exception as e:
current_app.logger.error(
f"Error loading user custom model for test: {e}", exc_info=True
)
return make_response(
jsonify({"ok": False, "error": "internal error loading model"}),
500,
)
api_key = override_api_key or stored_api_key
if not api_key:
return make_response(
jsonify(
{
"ok": False,
"error": (
"Stored API key could not be decrypted. The "
"encryption secret may have rotated. Re-save "
"the model with the API key to recover."
),
}
),
400,
)
base_url = override_base_url or row["base_url"]
upstream_model_id = (
override_upstream_model_id or row["upstream_model_id"]
)
body, status = _run_connection_test(
base_url, api_key, upstream_model_id
)
return make_response(jsonify(body), status)
+5
View File
@@ -0,0 +1,5 @@
"""Prompts module."""
from .routes import prompts_ns
__all__ = ["prompts_ns"]
+250
View File
@@ -0,0 +1,250 @@
"""Prompt management routes."""
import os
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import current_dir
from application.api.user.team_sharing import team_access_for, visible_with_access
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
prompts_ns = Namespace(
"prompts", description="Prompt management operations", path="/api"
)
@prompts_ns.route("/create_prompt")
class CreatePrompt(Resource):
create_prompt_model = api.model(
"CreatePromptModel",
{
"content": fields.String(
required=True, description="Content of the prompt"
),
"name": fields.String(required=True, description="Name of the prompt"),
},
)
@api.expect(create_prompt_model)
@api.doc(description="Create a new prompt")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["content", "name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
user = decoded_token.get("sub")
try:
with db_session() as conn:
prompt = PromptsRepository(conn).create(user, data["name"], data["content"])
new_id = str(prompt["id"])
except Exception as err:
current_app.logger.error(f"Error creating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id}), 200)
@prompts_ns.route("/get_prompts")
class GetPrompts(Resource):
@api.doc(description="Get all prompts for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
repo = PromptsRepository(conn)
prompts = repo.list_for_user(user)
owned_ids = {str(p["id"]) for p in prompts}
team_shared = visible_with_access(conn, user, "prompt")
shared_ids = [pid for pid in team_shared if pid not in owned_ids]
shared_prompts = repo.list_by_ids(shared_ids)
list_prompts = [
{"id": "default", "name": "default", "type": "public"},
{"id": "creative", "name": "creative", "type": "public"},
{"id": "strict", "name": "strict", "type": "public"},
]
for prompt in prompts:
list_prompts.append(
{
"id": str(prompt["id"]),
"name": prompt["name"],
"type": "private",
}
)
for prompt in shared_prompts:
list_prompts.append(
{
"id": str(prompt["id"]),
"name": prompt["name"],
"type": "team",
"team_access": team_shared.get(str(prompt["id"])),
}
)
except Exception as err:
current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_prompts), 200)
@prompts_ns.route("/get_single_prompt")
class GetSinglePrompt(Resource):
@api.doc(params={"id": "ID of the prompt"}, description="Get a single prompt by ID")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
prompt_id = request.args.get("id")
if not prompt_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
if prompt_id == "default":
with open(
os.path.join(current_dir, "prompts", "chat_combine_default.txt"),
"r",
) as f:
chat_combine_template = f.read()
return make_response(jsonify({"content": chat_combine_template}), 200)
elif prompt_id == "creative":
with open(
os.path.join(current_dir, "prompts", "chat_combine_creative.txt"),
"r",
) as f:
chat_reduce_creative = f.read()
return make_response(jsonify({"content": chat_reduce_creative}), 200)
elif prompt_id == "strict":
with open(
os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r"
) as f:
chat_reduce_strict = f.read()
return make_response(jsonify({"content": chat_reduce_strict}), 200)
with db_readonly() as conn:
repo = PromptsRepository(conn)
prompt = repo.get_any(prompt_id, user)
if not prompt and team_access_for(conn, user, "prompt", prompt_id):
# Team fallback: ownerless fetch only after a grant check.
prompt = repo.get_for_rendering(prompt_id)
if not prompt:
return make_response(
jsonify({"success": False, "message": "Prompt not found"}), 404
)
except Exception as err:
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"content": prompt["content"]}), 200)
@prompts_ns.route("/delete_prompt")
class DeletePrompt(Resource):
delete_prompt_model = api.model(
"DeletePromptModel",
{"id": fields.String(required=True, description="Prompt ID to delete")},
)
@api.expect(delete_prompt_model)
@api.doc(description="Delete a prompt by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
with db_session() as conn:
repo = PromptsRepository(conn)
prompt = repo.get_any(data["id"], user)
if not prompt:
return make_response(
jsonify({"success": False, "message": "Prompt not found"}),
404,
)
repo.delete(str(prompt["id"]), user)
except Exception as err:
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@prompts_ns.route("/update_prompt")
class UpdatePrompt(Resource):
update_prompt_model = api.model(
"UpdatePromptModel",
{
"id": fields.String(required=True, description="Prompt ID to update"),
"name": fields.String(required=True, description="New name of the prompt"),
"content": fields.String(
required=True, description="New content of the prompt"
),
},
)
@api.expect(update_prompt_model)
@api.doc(description="Update an existing prompt")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "name", "content"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
with db_session() as conn:
repo = PromptsRepository(conn)
prompt = repo.get_any(data["id"], user)
if prompt:
repo.update(str(prompt["id"]), user, data["name"], data["content"])
else:
# Team editor write path (viewer is read-only).
access = team_access_for(conn, user, "prompt", data["id"])
if access == "editor":
result = repo.update_by_id(
data["id"],
data["name"],
data["content"],
expected_updated_at=data.get("expected_updated_at"),
)
if result is None:
return make_response(
jsonify(
{
"success": False,
"message": "Prompt was modified by someone else",
"code": "stale_write",
}
),
409,
)
elif access == "viewer":
return make_response(
jsonify(
{"success": False, "message": "Read-only: editor access required"}
),
403,
)
else:
return make_response(
jsonify({"success": False, "message": "Prompt not found"}),
404,
)
except Exception as err:
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
+435
View File
@@ -0,0 +1,435 @@
"""Reconciler tick: sweep stuck rows and escalate to terminal status + alert."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy import Connection
from application.api.user.idempotency import MAX_TASK_ATTEMPTS
from application.core.settings import settings
from application.storage.db.engine import get_engine
from application.storage.db.repositories.pending_tool_state import (
PendingToolStateRepository,
)
from application.storage.db.repositories.reconciliation import (
ReconciliationRepository,
)
from application.storage.db.repositories.stack_logs import StackLogsRepository
if TYPE_CHECKING:
from application.storage.db.repositories.schedules import SchedulesRepository
logger = logging.getLogger(__name__)
MAX_MESSAGE_RECONCILE_ATTEMPTS = 3
def run_reconciliation() -> Dict[str, Any]:
"""Single tick of the reconciler. Five sweeps, FOR UPDATE SKIP LOCKED.
Stuck ``executed`` tool calls always flip to ``failed`` — operators
handle cleanup manually via the structured alert. The side effect is
assumed to have committed; no automated rollback is attempted.
Stuck ``task_dedup`` rows (lease expired AND attempts >= max)
promote to ``failed`` so a same-key retry can re-claim instead of
sitting in ``pending`` until 24 h TTL.
"""
if not settings.POSTGRES_URI:
return {
"messages_failed": 0,
"tool_calls_failed": 0,
"skipped": "POSTGRES_URI not set",
}
engine = get_engine()
summary = {
"messages_failed": 0,
"tool_calls_failed": 0,
"ingests_stalled": 0,
"idempotency_pending_failed": 0,
"schedule_runs_failed": 0,
}
# User-facing events to fan out once their DB writes have committed
# (publish-after-commit). Each item is
# ``(user_id, event_type, payload, scope)``.
events: list[tuple] = []
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
pt_repo = PendingToolStateRepository(conn)
for msg in repo.find_and_lock_stuck_messages():
new_count = repo.increment_message_reconcile_attempts(msg["id"])
if new_count >= MAX_MESSAGE_RECONCILE_ATTEMPTS:
repo.mark_message_failed(
msg["id"],
error=(
"reconciler: stuck in pending/streaming for >5 min "
f"after {new_count} attempts"
),
)
summary["messages_failed"] += 1
# Revoke any awaiting-approval prompt: the resumable state
# dies with the message, so the durable
# ``tool.approval.required`` envelope must be cleared or the
# UI toast lingers on reconnect. Only emit when a row was
# actually deleted so non-approval failures stay quiet.
user_id = msg.get("user_id")
conversation_id = msg.get("conversation_id")
if (
user_id
and conversation_id
and pt_repo.delete_state(str(conversation_id), str(user_id))
):
events.append(
(
str(user_id),
"tool.approval.cleared",
{
"conversation_id": str(conversation_id),
"message_id": str(msg["id"]),
"reason": "failed",
},
{"kind": "conversation", "id": str(conversation_id)},
)
)
_emit_alert(
conn,
name="reconciler_message_failed",
user_id=msg.get("user_id"),
detail={
"message_id": str(msg["id"]),
"attempts": new_count,
},
)
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_proposed_tool_calls():
repo.mark_tool_call_failed(
row["call_id"],
error=(
"reconciler: stuck in 'proposed' for >5 min; "
"side effect status unknown"
),
)
summary["tool_calls_failed"] += 1
_emit_alert(
conn,
name="reconciler_tool_call_failed_proposed",
user_id=None,
detail={
"call_id": row["call_id"],
"tool_name": row.get("tool_name"),
},
)
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_executed_tool_calls():
repo.mark_tool_call_failed(
row["call_id"],
error=(
"reconciler: executed-not-confirmed; side effect "
"assumed committed, manual cleanup required"
),
)
summary["tool_calls_failed"] += 1
_emit_alert(
conn,
name="reconciler_tool_call_failed_executed",
user_id=None,
detail={
"call_id": row["call_id"],
"tool_name": row.get("tool_name"),
"action_name": row.get("action_name"),
},
)
# Q4: ingest checkpoints whose heartbeat has gone silent. Each is
# escalated to terminal ``status='stalled'`` and alerted once — no
# worker kill, no rollback of the partial embed. The 'stalled' flag
# ends the re-alert loop and drives the "indexing failed" badge the
# sources list derives from this row.
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_stalled_ingests():
summary["ingests_stalled"] += 1
_emit_alert(
conn,
name="reconciler_ingest_stalled",
user_id=row.get("user_id"),
detail={
"source_id": str(row.get("source_id")),
"embedded_chunks": row.get("embedded_chunks"),
"total_chunks": row.get("total_chunks"),
"last_updated": str(row.get("last_updated")),
},
)
repo.mark_ingest_stalled(str(row["source_id"]))
# Tell the upload toast the ingest is done-for. Without it the
# toast spins on "Training…" for the whole live session; only
# ``source.ingest.failed`` flips it to a terminal error.
user_id = row.get("user_id")
source_id = row.get("source_id")
if user_id and source_id:
events.append(
(
str(user_id),
"source.ingest.failed",
{
"source_id": str(source_id),
"filename": row.get("source_name") or "",
"operation": "upload",
"error": "Indexing stopped after a stall.",
},
{"kind": "source", "id": str(source_id)},
)
)
# Q5: idempotency rows whose lease expired with attempts exhausted.
# The wrapper's poison-loop guard normally finalises these, but if
# the wrapper itself died mid-task (worker SIGKILL, OOM during
# heartbeat) the row sits in ``pending`` blocking same-key retries
# via ``_lookup_completed`` returning None for the whole 24 h TTL.
# Promote to ``failed`` so a retry can re-claim and either resume
# or fail loudly.
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_stuck_idempotency_pending(
max_attempts=MAX_TASK_ATTEMPTS,
):
error_msg = (
"reconciler: idempotency lease expired with attempts "
f"({row['attempt_count']}) >= {MAX_TASK_ATTEMPTS}; "
"task abandoned"
)
repo.mark_idempotency_pending_failed(
row["idempotency_key"], error=error_msg,
)
summary["idempotency_pending_failed"] += 1
_emit_alert(
conn,
name="reconciler_idempotency_pending_failed",
user_id=None,
detail={
"idempotency_key": row["idempotency_key"],
"task_name": row.get("task_name"),
"task_id": row.get("task_id"),
"attempts": row.get("attempt_count"),
},
)
# Q6: scheduler runs stuck in 'running' past the soft-time-limit window.
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
from application.core.settings import settings as _settings
stuck_age = max(
15, int(_settings.SCHEDULE_RUN_TIMEOUT // 60) + 5,
)
with engine.begin() as conn:
runs_repo = ScheduleRunsRepository(conn)
schedules_repo = SchedulesRepository(conn)
for run in runs_repo.list_stuck_running(age_minutes=stuck_age):
runs_repo.update(
run["id"],
{
"status": "timeout",
"finished_at": datetime.now(timezone.utc),
"error_type": "timeout",
"error": (
"reconciler: schedule_run stuck in 'running' past "
f"{stuck_age} min"
),
},
)
schedules_repo.bump_failure_count(str(run["schedule_id"]))
flipped = _terminal_flip_once_schedule(
schedules_repo, str(run["schedule_id"]),
)
summary["schedule_runs_failed"] += 1
events.extend(
_schedule_terminal_events(
run,
error_type="timeout",
error=(
"reconciler: schedule_run stuck in 'running' past "
f"{stuck_age} min"
),
once_completed=flipped,
)
)
_emit_alert(
conn,
name="reconciler_schedule_run_timeout",
user_id=run.get("user_id"),
detail={
"run_id": str(run["id"]),
"schedule_id": str(run["schedule_id"]),
},
)
# Q7: scheduler runs orphaned in 'pending' — dispatcher committed but
# apply_async failed (broker outage / crash mid-dispatch).
with engine.begin() as conn:
runs_repo = ScheduleRunsRepository(conn)
schedules_repo = SchedulesRepository(conn)
for run in runs_repo.list_stuck_pending(age_minutes=stuck_age):
runs_repo.update(
run["id"],
{
"status": "failed",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": (
"reconciler: schedule_run stuck in 'pending' past "
f"{stuck_age} min (worker_never_started)"
),
},
)
schedules_repo.bump_failure_count(str(run["schedule_id"]))
flipped = _terminal_flip_once_schedule(
schedules_repo, str(run["schedule_id"]),
)
summary["schedule_runs_failed"] += 1
events.extend(
_schedule_terminal_events(
run,
error_type="internal",
error=(
"reconciler: schedule_run stuck in 'pending' past "
f"{stuck_age} min (worker_never_started)"
),
once_completed=flipped,
)
)
_emit_alert(
conn,
name="reconciler_schedule_run_pending",
user_id=run.get("user_id"),
detail={
"run_id": str(run["id"]),
"schedule_id": str(run["schedule_id"]),
},
)
_publish_events(events)
return summary
def _terminal_flip_once_schedule(
schedules_repo: "SchedulesRepository", schedule_id: str,
) -> bool:
"""Flip a once-schedule to 'completed' after its run terminates.
Recurring schedules keep firing; once-schedules would otherwise read
'active forever' since next_run_at is already NULL. Returns ``True``
when the flip happened so the caller can publish a status event.
"""
schedule = schedules_repo.get_internal(schedule_id)
if schedule is None or schedule.get("trigger_type") != "once":
return False
if schedule.get("status") in {"completed", "cancelled"}:
return False
schedules_repo.update_internal(
schedule_id, {"status": "completed", "next_run_at": None},
)
return True
def _schedule_terminal_events(
run: Dict[str, Any],
*,
error_type: str,
error: str,
once_completed: bool,
) -> list:
"""Build the user-facing events for a reconciler-failed schedule run.
Always a ``schedule.run.failed`` (so a watching run-log updates live),
plus ``schedule.completed`` when a once-schedule flipped terminal — the
UI otherwise shows it 'active' with stale Edit/Cancel actions until a
manual refresh.
"""
user_id = run.get("user_id")
schedule_id = run.get("schedule_id")
if not user_id or not schedule_id:
return []
scope = {"kind": "schedule", "id": str(schedule_id)}
out: list = [
(
str(user_id),
"schedule.run.failed",
{
"run_id": str(run["id"]),
"schedule_id": str(schedule_id),
"status": "failed",
"error_type": error_type,
"error": error,
},
scope,
)
]
if once_completed:
out.append(
(
str(user_id),
"schedule.completed",
{"schedule_id": str(schedule_id), "status": "completed"},
scope,
)
)
return out
def _publish_events(events: list) -> None:
"""Fan out user-facing events after their DB writes have committed.
Each item is ``(user_id, event_type, payload, scope)``. Best-effort:
a publish miss is swallowed per event so one failure can't strand the
rest, and notifications never surface as a reconciler-task failure.
"""
if not events:
return
from application.events.publisher import publish_user_event
for user_id, event_type, payload, scope in events:
try:
publish_user_event(user_id, event_type, payload, scope=scope)
except Exception:
logger.exception(
"reconciler: failed to publish %s for user=%s",
event_type,
user_id,
)
def _emit_alert(
conn: Connection,
*,
name: str,
user_id: Optional[str],
detail: Dict[str, Any],
) -> None:
"""Structured ``logger.error`` plus a ``stack_logs`` row for operators."""
extra = {"alert": name, **detail}
logger.error("reconciler alert: %s", name, extra=extra)
try:
StackLogsRepository(conn).insert(
activity_id=str(uuid.uuid4()),
endpoint="reconciliation_worker",
level="ERROR",
user_id=user_id,
query=name,
stacks=[extra],
)
except Exception:
logger.exception("reconciler: failed to write stack_logs row for %s", name)
+79
View File
@@ -0,0 +1,79 @@
"""
Main user API routes - registers all namespace modules.
"""
from flask import Blueprint
from application.api import api
from .agents import (
agents_folders_ns,
agents_ns,
agents_portability_ns,
agents_sharing_ns,
agents_webhooks_ns,
)
from .analytics import analytics_ns
from .artifacts import artifacts_ns
from .attachments import attachments_ns
from .conversations import conversations_ns
from .me import me_ns
from .models import models_ns
from .prompts import prompts_ns
from .schedules import schedules_ns
from .sharing import sharing_ns
from .sources import sources_chunks_ns, sources_ns, sources_upload_ns
from .teams import teams_ns
from .tools import tools_mcp_ns, tools_ns
from .workflows import workflows_ns
user = Blueprint("user", __name__)
# Analytics
api.add_namespace(analytics_ns)
# Artifacts
api.add_namespace(artifacts_ns)
# Attachments
api.add_namespace(attachments_ns)
# Conversations
api.add_namespace(conversations_ns)
# Current user (identity + roles)
api.add_namespace(me_ns)
# Models
api.add_namespace(models_ns)
# Agents (main, sharing, webhooks, folders, import/export)
api.add_namespace(agents_ns)
api.add_namespace(agents_sharing_ns)
api.add_namespace(agents_webhooks_ns)
api.add_namespace(agents_folders_ns)
api.add_namespace(agents_portability_ns)
# Prompts
api.add_namespace(prompts_ns)
# Schedules
api.add_namespace(schedules_ns)
# Sharing
api.add_namespace(sharing_ns)
# Sources (main, chunks, upload)
api.add_namespace(sources_ns)
api.add_namespace(sources_chunks_ns)
api.add_namespace(sources_upload_ns)
# Teams (CRUD, membership, resource-sharing grants)
api.add_namespace(teams_ns)
# Tools (main, MCP)
api.add_namespace(tools_ns)
api.add_namespace(tools_mcp_ns)
# Workflows
api.add_namespace(workflows_ns)
@@ -0,0 +1,186 @@
"""Schedule dispatcher: poll Postgres, claim due rows under FOR UPDATE SKIP LOCKED,
advance next_run_at atomically with the run claim, then enqueue.
Per-schedule IANA tz semantics (croniter+zoneinfo) outside Celery's app-wide tz,
plus Postgres-native dedup avoid Redis visibility_timeout double-fires.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from application.agents.scheduler_utils import next_cron_run
from application.core.settings import settings
from application.storage.db.engine import get_engine
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
logger = logging.getLogger(__name__)
def _normalize_dt(value: Any) -> Optional[datetime]:
"""Accept a datetime / ISO string / None and return a tz-aware UTC dt."""
if value is None:
return None
if isinstance(value, datetime):
return value.astimezone(timezone.utc) if value.tzinfo else (
value.replace(tzinfo=timezone.utc)
)
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed.astimezone(timezone.utc) if parsed.tzinfo else (
parsed.replace(tzinfo=timezone.utc)
)
return None
def _compute_next(
schedule: Dict[str, Any],
*,
after: datetime,
) -> Optional[datetime]:
"""Next next_run_at for a recurring schedule, or None when past end_at."""
cron = schedule.get("cron")
if not cron:
return None
end_at = _normalize_dt(schedule.get("end_at"))
candidate = next_cron_run(cron, schedule.get("timezone"), after=after)
if end_at is not None and candidate > end_at:
return None
return candidate
def dispatch_due_runs() -> Dict[str, int]:
"""One dispatcher tick; returns counts for schedule_syncs-style logging."""
if not settings.POSTGRES_URI:
return {"enqueued": 0, "skipped": 0, "advanced": 0}
from application.api.user.tasks import execute_scheduled_run
now = datetime.now(timezone.utc)
grace = timedelta(seconds=max(0, settings.SCHEDULE_MISFIRE_GRACE))
engine = get_engine()
counts = {"enqueued": 0, "skipped": 0, "advanced": 0}
enqueue_args: List[str] = []
with engine.begin() as conn:
schedules_repo = SchedulesRepository(conn)
runs_repo = ScheduleRunsRepository(conn)
for schedule in schedules_repo.list_due():
scheduled_for = _normalize_dt(schedule.get("next_run_at"))
if scheduled_for is None:
continue
trigger_type = schedule.get("trigger_type")
agent_id_raw = schedule.get("agent_id")
agent_id = str(agent_id_raw) if agent_id_raw else None
# Misfire grace applies to recurring only — once-tasks fire late, not vanish.
if (
trigger_type == "recurring"
and grace > timedelta(0)
and (now - scheduled_for) > grace
):
runs_repo.record_skipped(
str(schedule["id"]),
schedule["user_id"],
agent_id,
scheduled_for,
error_type="missed",
error="misfire grace exceeded",
)
counts["skipped"] += 1
nxt = _compute_next(schedule, after=now)
if nxt is None:
schedules_repo.update_internal(
str(schedule["id"]),
{"status": "completed", "next_run_at": None,
"last_run_at": now},
)
else:
schedules_repo.update_internal(
str(schedule["id"]),
{"next_run_at": nxt, "last_run_at": now},
)
counts["advanced"] += 1
continue
# Overlap guard: never enqueue while a previous run is active.
if runs_repo.has_active_run(str(schedule["id"])):
runs_repo.record_skipped(
str(schedule["id"]),
schedule["user_id"],
agent_id,
scheduled_for,
error_type="overlap",
error="previous run still active",
)
counts["skipped"] += 1
if trigger_type == "recurring":
nxt = _compute_next(schedule, after=scheduled_for)
schedules_repo.update_internal(
str(schedule["id"]),
{"next_run_at": nxt, "last_run_at": now},
)
else:
# Once: null next_run_at so we don't re-pick; the in-flight
# run will terminal-flip the schedule when it finishes.
schedules_repo.update_internal(
str(schedule["id"]),
{"next_run_at": None, "last_run_at": now},
)
continue
# Dedup primitive: two racing dispatchers see exactly one row.
run = runs_repo.record_pending(
str(schedule["id"]),
schedule["user_id"],
agent_id,
scheduled_for,
trigger_source="cron",
)
if run is None:
counts["skipped"] += 1
else:
enqueue_args.append(str(run["id"]))
counts["enqueued"] += 1
# Advance: recurring picks next tick, once nulls next_run_at
# (worker terminal-flips status on completion).
if trigger_type == "recurring":
nxt = _compute_next(schedule, after=scheduled_for)
if nxt is None:
schedules_repo.update_internal(
str(schedule["id"]),
{"status": "completed", "next_run_at": None,
"last_run_at": now},
)
else:
schedules_repo.update_internal(
str(schedule["id"]),
{"next_run_at": nxt, "last_run_at": now},
)
else:
schedules_repo.update_internal(
str(schedule["id"]),
{"next_run_at": None, "last_run_at": now},
)
counts["advanced"] += 1
# Enqueue after commit so the worker sees the schedule_runs row on pick-up.
for run_id in enqueue_args:
try:
execute_scheduled_run.apply_async(args=[run_id], queue="docsgpt")
except Exception:
logger.exception(
"dispatcher: failed to enqueue execute_scheduled_run for %s",
run_id,
)
return counts
+434
View File
@@ -0,0 +1,434 @@
"""Body of ``execute_scheduled_run`` — runs a single agent execution.
Not a DURABLE_TASK: agent runs have side effects (messages, CRM writes)
and blind auto-retry would double them. Failures after agent.gen starts
are terminal and recorded; only the pre-start load is retry-safe.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import text as sql_text
from application.agents.headless_runner import run_agent_headless
from application.core.settings import settings
from application.events.publisher import publish_user_event
from application.storage.db.base_repository import row_to_dict
from application.storage.db.engine import get_engine
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
from application.storage.db.repositories.token_usage import TokenUsageRepository
logger = logging.getLogger(__name__)
# Cap output verbatim in the run log; beyond the cap we keep the head and stamp output_truncated.
_OUTPUT_CAP_CHARS = 24_000
def _agent_config_for_schedule(schedule: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve the agent row (agent-bound) or build an ephemeral classic config.
For agentless schedules (``agent_id IS NULL``), the worker constructs an
in-memory agent shape carrying just enough fields for ``run_agent_headless``:
classic agent type, system-default retriever/chunks/prompt, no source, and
the optional ``model_id`` override. The runtime toolset is rebuilt by
``ToolExecutor`` at fire time (current ``user_tools`` + non-disabled,
non-headless-excluded defaults), so a snapshot here would be dead code.
"""
if schedule.get("agent_id"):
engine = get_engine()
with engine.connect() as conn:
row = conn.execute(
sql_text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"),
{"id": str(schedule["agent_id"])},
).fetchone()
return row_to_dict(row) if row is not None else None
return _ephemeral_agent_for_agentless(schedule)
def _ephemeral_agent_for_agentless(
schedule: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Build an agent-shaped config for a schedule with no parent agent."""
# ``agent_config["tools"]`` is intentionally omitted: ``run_agent_headless``
# never reads it. The runtime toolset is rebuilt by
# ``ToolExecutor._get_user_tools(owner)`` at fire time — same dereference
# the agent-bound path uses, so a tool added/disabled after creation is
# reflected. Headless mode there filters chat-only tools (``scheduler``).
user_id = schedule.get("user_id")
if not user_id:
return None
return {
"id": None,
"user_id": user_id,
"agent_type": "classic",
"retriever": "classic",
"chunks": 2,
"prompt_id": "default",
"source_id": None,
"default_model_id": schedule.get("model_id") or "",
}
def _load_chat_history(schedule: Dict[str, Any]) -> list:
"""Originating conversation history (one-time only; recurring has none)."""
origin = schedule.get("origin_conversation_id")
if not origin or schedule.get("trigger_type") != "once":
return []
user_id = schedule.get("user_id")
if not user_id:
return []
try:
engine = get_engine()
with engine.connect() as conn:
conv = ConversationsRepository(conn).get_any(str(origin), user_id)
if conv is None:
return []
messages = ConversationsRepository(conn).get_messages(str(conv["id"]))
except Exception:
logger.exception("scheduler: failed loading chat history")
return []
history: list = []
for msg in messages:
if msg.get("prompt") and msg.get("response"):
history.append({
"prompt": msg["prompt"],
"response": msg["response"],
})
return history
def _publish_run_event(
event_type: str, run: Dict[str, Any], schedule: Dict[str, Any], **extra: Any
) -> None:
"""Best-effort SSE publish for a scheduler run state transition."""
user_id = run.get("user_id") or schedule.get("user_id")
if not user_id:
return
agent_id_raw = schedule.get("agent_id")
payload = {
"run_id": str(run["id"]),
"schedule_id": str(schedule["id"]),
"agent_id": str(agent_id_raw) if agent_id_raw else None,
"trigger_type": schedule.get("trigger_type"),
"status": run.get("status"),
**extra,
}
try:
publish_user_event(
user_id,
event_type,
payload,
scope={"kind": "schedule", "id": str(schedule["id"])},
)
except Exception:
logger.exception(
"scheduler: SSE publish failed event=%s run=%s",
event_type, run.get("id"),
)
def _publish_message_appended(
user_id: str,
conversation_id: str,
message: Dict[str, Any],
schedule_id: str,
run_id: str,
) -> None:
"""SSE message-appended event for a one-time run's chat turn."""
try:
publish_user_event(
user_id,
"schedule.message.appended",
{
"conversation_id": str(conversation_id),
"message_id": str(message["id"]),
"schedule_id": str(schedule_id),
"run_id": str(run_id),
"position": int(message.get("position", 0)),
},
scope={"kind": "conversation", "id": str(conversation_id)},
)
except Exception:
logger.exception(
"scheduler: message.appended publish failed run=%s", run_id,
)
def _append_one_time_turn(
schedule: Dict[str, Any],
run: Dict[str, Any],
outcome: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Insert an assistant turn in the originating conversation (once only)."""
origin = schedule.get("origin_conversation_id")
if not origin:
return None
engine = get_engine()
user_id = schedule.get("user_id")
metadata = {
"scheduled": True,
"schedule_id": str(schedule["id"]),
"run_id": str(run["id"]),
"scheduled_run_at": (
run.get("scheduled_for")
if isinstance(run.get("scheduled_for"), str)
else None
),
}
with engine.begin() as conn:
conv = ConversationsRepository(conn).get_any(str(origin), user_id)
if conv is None:
return None
message = ConversationsRepository(conn).append_message(
str(conv["id"]),
{
"prompt": schedule.get("instruction") or "",
"response": outcome.get("answer") or "",
"thought": outcome.get("thought") or "",
"sources": outcome.get("sources") or [],
"tool_calls": outcome.get("tool_calls") or [],
"model_id": outcome.get("model_id"),
"metadata": metadata,
},
)
return message
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
"""Execute one scheduled run by id; returns a result dict for tracing."""
if not settings.POSTGRES_URI:
return {"status": "skipped", "reason": "POSTGRES_URI not set"}
engine = get_engine()
with engine.connect() as conn:
run = ScheduleRunsRepository(conn).get_internal(run_id)
if run is None:
return {"status": "skipped", "reason": "run not found"}
schedule = SchedulesRepository(conn).get_internal(str(run["schedule_id"]))
if schedule is None:
return {"status": "skipped", "reason": "schedule not found"}
# Refuse non-runnable terminal states; manual run-now bypasses.
if run.get("status") != "pending":
return {"status": "skipped", "reason": f"run status={run.get('status')}"}
if schedule.get("status") in {"cancelled", "completed"} and run.get(
"trigger_source"
) != "manual":
with engine.begin() as conn:
ScheduleRunsRepository(conn).update(
run_id,
{
"status": "skipped",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": "schedule no longer active",
},
)
return {"status": "skipped", "reason": "schedule terminal"}
agent_config = _agent_config_for_schedule(schedule)
if agent_config is None:
with engine.begin() as conn:
updated = ScheduleRunsRepository(conn).update(
run_id,
{
"status": "failed",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": "agent missing",
},
)
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
_publish_run_event("schedule.run.failed", updated or run, schedule,
error="agent missing")
return {"status": "failed", "reason": "agent missing"}
with engine.begin() as conn:
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
return {"status": "skipped", "reason": "lost race to mark_running"}
started = datetime.now(timezone.utc)
instruction = schedule.get("instruction") or ""
allowlist = schedule.get("tool_allowlist") or []
chat_history = _load_chat_history(schedule)
outcome: Dict[str, Any]
error_type: Optional[str] = None
error_text: Optional[str] = None
timed_out = False
try:
outcome = run_agent_headless(
agent_config,
instruction,
tool_allowlist=allowlist,
model_id_override=schedule.get("model_id"),
endpoint="schedule",
conversation_id=schedule.get("origin_conversation_id"),
chat_history=chat_history,
)
except SoftTimeLimitExceeded:
timed_out = True
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "timeout"
error_text = "run exceeded soft time limit"
except Exception as exc:
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "agent_error"
error_text = str(exc)
logger.exception("scheduler: agent run failed run=%s", run_id)
finished = datetime.now(timezone.utc)
# Headless denial with no usable output → tool_not_allowed.
if (
error_type is None
and (outcome.get("denied") or [])
and not (outcome.get("answer") or "").strip()
):
error_type = "tool_not_allowed"
error_text = "headless allowlist blocked required tool"
prompt_tokens = int(outcome.get("prompt_tokens", 0) or 0)
generated_tokens = int(outcome.get("generated_tokens", 0) or 0)
used_tokens = prompt_tokens + generated_tokens
if (
schedule.get("token_budget") is not None
and int(schedule["token_budget"]) > 0
and used_tokens > int(schedule["token_budget"])
):
error_type = "budget_exceeded"
error_text = (
f"used {used_tokens} tokens exceeds budget "
f"{schedule['token_budget']}"
)
answer = outcome.get("answer") or ""
truncated = False
if len(answer) > _OUTPUT_CAP_CHARS:
answer = answer[:_OUTPUT_CAP_CHARS]
truncated = True
new_status = (
"timeout" if timed_out else ("failed" if error_type else "success")
)
with engine.begin() as conn:
update_fields: Dict[str, Any] = {
"status": new_status,
"started_at": started,
"finished_at": finished,
"output": answer or None,
"output_truncated": truncated,
"prompt_tokens": prompt_tokens,
"generated_tokens": generated_tokens,
}
if error_type:
update_fields["error_type"] = error_type
update_fields["error"] = error_text
updated_run = ScheduleRunsRepository(conn).update(run_id, update_fields)
if used_tokens > 0:
agent_id_raw = schedule.get("agent_id")
try:
TokenUsageRepository(conn).insert(
user_id=schedule.get("user_id"),
api_key=None,
prompt_tokens=prompt_tokens,
generated_tokens=generated_tokens,
timestamp=finished,
agent_id=str(agent_id_raw) if agent_id_raw else None,
source="schedule",
request_id=str(run_id),
model_id=outcome.get("model_id"),
)
except Exception:
logger.exception(
"scheduler: token_usage insert failed run=%s", run_id,
)
schedules_repo = SchedulesRepository(conn)
autopaused = False
if new_status == "success":
schedules_repo.reset_failure_count(str(schedule["id"]))
elif new_status in ("failed", "timeout"):
count = schedules_repo.bump_failure_count(str(schedule["id"]))
if (
settings.SCHEDULE_AUTOPAUSE_FAILURES > 0
and count >= settings.SCHEDULE_AUTOPAUSE_FAILURES
and schedule.get("trigger_type") == "recurring"
):
autopaused = schedules_repo.autopause(str(schedule["id"]))
# Once: terminal-flip on cron-fired runs only; manual runs on a
# still-active once-schedule leave the future cadence intact.
if (
schedule.get("trigger_type") == "once"
and run.get("trigger_source") != "manual"
and schedule.get("status") == "active"
):
schedules_repo.update_internal(
str(schedule["id"]),
{"status": "completed", "next_run_at": None},
)
appended: Optional[Dict[str, Any]] = None
if (
schedule.get("trigger_type") == "once"
and new_status == "success"
and schedule.get("origin_conversation_id")
):
try:
appended = _append_one_time_turn(schedule, updated_run or run, outcome)
except Exception:
logger.exception(
"scheduler: append turn failed run=%s", run_id,
)
if appended is not None:
with engine.begin() as conn:
ScheduleRunsRepository(conn).update(
run_id,
{
"conversation_id": str(appended["conversation_id"]),
"message_id": str(appended["id"]),
},
)
_publish_message_appended(
schedule.get("user_id"),
str(appended["conversation_id"]),
appended,
str(schedule["id"]),
run_id,
)
if new_status == "success":
_publish_run_event("schedule.run.completed", updated_run or run, schedule)
else:
_publish_run_event(
"schedule.run.failed",
updated_run or run,
schedule,
error_type=error_type,
error=error_text,
)
if autopaused:
_publish_run_event(
"schedule.autopaused",
updated_run or run,
schedule,
consecutive_failure_count=settings.SCHEDULE_AUTOPAUSE_FAILURES,
)
return {
"status": new_status,
"run_id": run_id,
"error_type": error_type,
}
@@ -0,0 +1,5 @@
"""Schedules module."""
from .routes import schedules_ns
__all__ = ["schedules_ns"]
+621
View File
@@ -0,0 +1,621 @@
"""Schedules REST API (owner-scoped via request.decoded_token)."""
from __future__ import annotations
import functools
import logging
from datetime import datetime, timezone
from typing import Any, Callable, Dict, Optional
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource, fields
from application.agents.scheduler_utils import (
ScheduleValidationError,
clamp_once_horizon,
cron_interval_seconds,
next_cron_run,
parse_cron,
parse_run_at,
resolve_timezone,
)
from application.api import api
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
schedules_ns = Namespace(
"schedules", description="Agent schedule management", path="/api",
)
def _ok(data: Any, status: int = 200):
return make_response(jsonify(data), status)
def _err(message: str, status: int = 400):
return make_response(jsonify({"success": False, "message": message}), status)
def _safe_route(func: Callable) -> Callable:
"""Decorator: log + mask exceptions that escape a route body as 500.
``ScheduleValidationError`` messages are explicitly surfaced via
``_err(str(exc))`` at each call site (deliberate, user-safe). This
decorator only fires for *unexpected* exceptions (DB driver errors,
NPEs, etc.) that escape the route body. The full trace is logged
server-side; the response body carries no internal detail.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as exc: # noqa: BLE001
try:
current_app.logger.exception(
"unhandled exception in schedules route %s: %s",
func.__qualname__, exc,
)
except RuntimeError:
# Out of Flask app context (rare in tests); use module logger.
logger.exception(
"unhandled exception in schedules route %s: %s",
func.__qualname__, exc,
)
return _err("internal error", 500)
return wrapper
def _format_schedule(row: Dict[str, Any]) -> Dict[str, Any]:
"""Render a schedule row for the API (id-as-string + ISO timestamps)."""
if not row:
return {}
out = dict(row)
for key in (
"id", "agent_id", "origin_conversation_id",
):
if out.get(key) is not None:
out[key] = str(out[key])
out.pop("_id", None) # drop dual-id legacy mirror
return out
def _format_run(row: Dict[str, Any]) -> Dict[str, Any]:
"""Render a schedule_run row for the API."""
if not row:
return {}
out = dict(row)
for key in (
"id", "schedule_id", "agent_id", "conversation_id", "message_id",
):
if out.get(key) is not None:
out[key] = str(out[key])
out.pop("_id", None)
return out
def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]:
if not looks_like_uuid(str(agent_id)):
return None
with db_readonly() as conn:
return AgentsRepository(conn).get_any(agent_id, user_id)
def _user_id() -> Optional[str]:
decoded = getattr(request, "decoded_token", None)
if not decoded:
return None
return decoded.get("sub")
def _publish_schedule_event(
user_id: str, event_type: str, schedule_id: str, *, status: str,
) -> None:
"""Best-effort SSE so other clients revoke a stale schedule state.
A resume/cancel must override an earlier ``schedule.autopaused``; that
envelope is durable and replays from the backlog on reconnect, so
without a newer event the schedule reads 'paused' even though it will
fire on the next tick.
"""
try:
from application.events.publisher import publish_user_event
publish_user_event(
user_id,
event_type,
{"schedule_id": str(schedule_id), "status": status},
scope={"kind": "schedule", "id": str(schedule_id)},
)
except Exception:
logger.exception(
"schedules: publish %s failed for %s", event_type, schedule_id,
)
@schedules_ns.route("/agents/<string:agent_id>/schedules")
class AgentSchedules(Resource):
@api.doc(description="List schedules for an agent (recurring + one-time).")
@_safe_route
def get(self, agent_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
agent = _agent_owned(agent_id, user_id)
if agent is None:
return _err("agent not found", 404)
try:
with db_readonly() as conn:
rows = SchedulesRepository(conn).list_for_agent(
str(agent["id"]), user_id,
)
except Exception as exc:
current_app.logger.error("list schedules failed: %s", exc, exc_info=True)
return _err("internal error", 500)
return _ok({"schedules": [_format_schedule(r) for r in rows]})
create_model = api.model(
"ScheduleCreate",
{
"instruction": fields.String(required=True),
"trigger_type": fields.String(
required=False,
description="'recurring' (default) or 'once'",
),
"cron": fields.String(
required=False,
description="Required when trigger_type == 'recurring'",
),
"run_at": fields.String(
required=False,
description="ISO 8601 — required when trigger_type == 'once'",
),
"timezone": fields.String(required=False),
"name": fields.String(required=False),
"end_at": fields.String(required=False, description="ISO 8601"),
"tool_allowlist": fields.List(fields.String, required=False),
"model_id": fields.String(required=False),
"token_budget": fields.Integer(required=False),
},
)
@api.expect(create_model)
@api.doc(description="Create a schedule (recurring or one-time) for an agent.")
@_safe_route
def post(self, agent_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
agent = _agent_owned(agent_id, user_id)
if agent is None:
return _err("agent not found", 404)
data = request.get_json(silent=True) or {}
instruction = (data.get("instruction") or "").strip()
tz_name = (data.get("timezone") or "UTC").strip() or "UTC"
trigger_type = (data.get("trigger_type") or "recurring").strip().lower()
if trigger_type not in ("recurring", "once"):
return _err("trigger_type must be 'recurring' or 'once'")
if not instruction:
return _err("instruction is required")
try:
resolve_timezone(tz_name)
except ScheduleValidationError as exc:
return _err(str(exc))
token_budget = data.get("token_budget")
if token_budget is not None:
try:
token_budget = int(token_budget)
if token_budget < 0:
raise ValueError
except (TypeError, ValueError):
return _err("token_budget must be a non-negative integer")
with db_readonly() as conn:
count = SchedulesRepository(conn).count_active_for_user(user_id)
if (
settings.SCHEDULE_MAX_PER_USER > 0
and count >= settings.SCHEDULE_MAX_PER_USER
):
return _err("max schedules per user reached", 429)
if trigger_type == "once":
run_at_raw = (data.get("run_at") or "").strip()
if not run_at_raw:
return _err("run_at is required for trigger_type 'once'")
try:
fire = parse_run_at(run_at_raw, tz_name)
clamp_once_horizon(
fire, settings.SCHEDULE_ONCE_MAX_HORIZON,
)
except ScheduleValidationError as exc:
return _err(str(exc))
try:
with db_session() as conn:
created = SchedulesRepository(conn).create(
user_id=user_id,
agent_id=str(agent["id"]),
trigger_type="once",
instruction=instruction,
run_at=fire,
next_run_at=fire,
timezone=tz_name,
name=(data.get("name") or "").strip() or None,
tool_allowlist=data.get("tool_allowlist") or [],
model_id=(data.get("model_id") or None),
token_budget=token_budget,
created_via="ui",
)
except Exception as exc:
current_app.logger.error(
"create one-time schedule failed: %s", exc, exc_info=True,
)
return _err("internal error", 500)
return _ok({"schedule": _format_schedule(created)}, status=201)
cron = (data.get("cron") or "").strip()
if not cron:
return _err("cron is required")
try:
parse_cron(cron)
except ScheduleValidationError as exc:
return _err(str(exc))
min_interval = max(0, int(settings.SCHEDULE_MIN_INTERVAL))
if min_interval > 0:
try:
cadence = cron_interval_seconds(cron, tz_name)
except ScheduleValidationError as exc:
return _err(str(exc))
if cadence < min_interval:
return _err(
"cadence below minimum interval "
f"({cadence}s < {min_interval}s)",
)
end_at = None
if data.get("end_at"):
try:
end_at = datetime.fromisoformat(
str(data["end_at"]).replace("Z", "+00:00"),
)
except ValueError:
return _err("invalid end_at")
try:
next_run = next_cron_run(cron, tz_name, after=datetime.now(timezone.utc))
except ScheduleValidationError as exc:
return _err(str(exc))
if end_at is not None and next_run > end_at:
return _err("end_at is before the first cron tick")
try:
with db_session() as conn:
created = SchedulesRepository(conn).create(
user_id=user_id,
agent_id=str(agent["id"]),
trigger_type="recurring",
instruction=instruction,
cron=cron,
timezone=tz_name,
next_run_at=next_run,
end_at=end_at,
name=(data.get("name") or "").strip() or None,
tool_allowlist=data.get("tool_allowlist") or [],
model_id=(data.get("model_id") or None),
token_budget=token_budget,
created_via="ui",
)
except Exception as exc:
current_app.logger.error(
"create schedule failed: %s", exc, exc_info=True,
)
return _err("internal error", 500)
return _ok({"schedule": _format_schedule(created)}, status=201)
@schedules_ns.route("/schedules/<string:schedule_id>")
class ScheduleResource(Resource):
@api.doc(description="Get schedule by id.")
@_safe_route
def get(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
with db_readonly() as conn:
row = SchedulesRepository(conn).get(schedule_id, user_id)
if row is None:
return _err("schedule not found", 404)
return _ok({"schedule": _format_schedule(row)})
@api.doc(description="Edit a schedule's editable fields.")
@_safe_route
def put(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
data = request.get_json(silent=True) or {}
fields_in: Dict[str, Any] = {}
if "instruction" in data:
inst = (data["instruction"] or "").strip()
if not inst:
return _err("instruction must not be empty")
fields_in["instruction"] = inst
if "cron" in data:
cron = (data["cron"] or "").strip()
try:
parse_cron(cron)
except ScheduleValidationError as exc:
return _err(str(exc))
fields_in["cron"] = cron
if "timezone" in data:
tz_name = (data["timezone"] or "UTC").strip() or "UTC"
try:
resolve_timezone(tz_name)
except ScheduleValidationError as exc:
return _err(str(exc))
fields_in["timezone"] = tz_name
if "tool_allowlist" in data:
fields_in["tool_allowlist"] = data["tool_allowlist"] or []
if "name" in data:
fields_in["name"] = (data["name"] or "").strip() or None
if "model_id" in data:
fields_in["model_id"] = (data["model_id"] or None)
if "token_budget" in data:
tb = data["token_budget"]
if tb is not None:
try:
tb = int(tb)
if tb < 0:
raise ValueError
except (TypeError, ValueError):
return _err("token_budget must be a non-negative integer")
fields_in["token_budget"] = tb
if "end_at" in data:
if data["end_at"]:
try:
fields_in["end_at"] = datetime.fromisoformat(
str(data["end_at"]).replace("Z", "+00:00"),
)
except ValueError:
return _err("invalid end_at")
else:
fields_in["end_at"] = None
# Recompute next_run_at when cron/tz changes.
with db_session() as conn:
existing = SchedulesRepository(conn).get(schedule_id, user_id)
if existing is None:
return _err("schedule not found", 404)
if (
("cron" in fields_in or "timezone" in fields_in)
and existing.get("trigger_type") == "recurring"
):
cron_eff = fields_in.get("cron") or existing.get("cron")
tz_eff = fields_in.get("timezone") or existing.get("timezone")
if cron_eff:
min_interval = max(0, int(settings.SCHEDULE_MIN_INTERVAL))
if min_interval > 0:
try:
cadence = cron_interval_seconds(cron_eff, tz_eff)
except ScheduleValidationError as exc:
return _err(str(exc))
if cadence < min_interval:
return _err(
"cadence below minimum interval "
f"({cadence}s < {min_interval}s)",
)
try:
fields_in["next_run_at"] = next_cron_run(
cron_eff, tz_eff, after=datetime.now(timezone.utc),
)
except ScheduleValidationError as exc:
return _err(str(exc))
updated = SchedulesRepository(conn).update(
schedule_id, user_id, fields_in,
)
return _ok({"schedule": _format_schedule(updated or {})})
@api.doc(description="Pause / resume a schedule.")
@_safe_route
def patch(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
data = request.get_json(silent=True) or {}
action = (data.get("action") or "").lower().strip()
if action not in {"pause", "resume"}:
return _err("action must be 'pause' or 'resume'")
with db_session() as conn:
existing = SchedulesRepository(conn).get(schedule_id, user_id)
if existing is None:
return _err("schedule not found", 404)
if existing.get("status") in ("cancelled", "completed"):
return _err("schedule is terminal", 409)
if action == "pause":
fields_in: Dict[str, Any] = {"status": "paused", "next_run_at": None}
else:
# Resume: recurring recomputes from now; once honours run_at if still future.
fields_in = {"status": "active"}
if existing.get("trigger_type") == "recurring":
try:
fields_in["next_run_at"] = next_cron_run(
existing["cron"],
existing["timezone"],
after=datetime.now(timezone.utc),
)
except ScheduleValidationError as exc:
return _err(str(exc))
else:
new_run_at = data.get("run_at")
if new_run_at:
try:
run_at_dt = datetime.fromisoformat(
str(new_run_at).replace("Z", "+00:00"),
)
except ValueError:
return _err("invalid run_at")
if run_at_dt <= datetime.now(timezone.utc):
return _err(
"run_at must be in the future to resume", 409,
)
fields_in["next_run_at"] = run_at_dt
fields_in["run_at"] = run_at_dt
else:
run_at = existing.get("run_at")
if run_at:
if isinstance(run_at, str):
try:
run_at_dt = datetime.fromisoformat(
run_at.replace("Z", "+00:00"),
)
except ValueError:
return _err("schedule run_at is invalid")
else:
run_at_dt = run_at
if run_at_dt <= datetime.now(timezone.utc):
return _err(
"the once schedule has elapsed; recreate "
"it or supply a new run_at",
409,
)
fields_in["next_run_at"] = run_at_dt
updated = SchedulesRepository(conn).update(
schedule_id, user_id, fields_in,
)
if action == "resume":
SchedulesRepository(conn).reset_failure_count(schedule_id)
if action == "resume" and updated:
_publish_schedule_event(
user_id, "schedule.resumed", schedule_id, status="active",
)
return _ok({"schedule": _format_schedule(updated or {})})
@api.doc(description="Cancel / delete a schedule.")
@_safe_route
def delete(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
with db_session() as conn:
ok = SchedulesRepository(conn).delete(schedule_id, user_id)
if not ok:
return _err("schedule not found", 404)
_publish_schedule_event(
user_id, "schedule.cancelled", schedule_id, status="cancelled",
)
return _ok({"success": True})
@schedules_ns.route("/schedules/<string:schedule_id>/run")
class ScheduleRunNow(Resource):
@api.doc(description="Run a schedule immediately (trigger_source='manual').")
@_safe_route
def post(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
# FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique
# scheduled_for values would otherwise sneak past the unique index).
with db_session() as conn:
schedule = SchedulesRepository(conn).get_for_update(
schedule_id, user_id,
)
if schedule is None:
return _err("schedule not found", 404)
if schedule.get("status") == "cancelled":
return _err("schedule is cancelled", 409)
if ScheduleRunsRepository(conn).has_active_run(schedule_id):
return _err("a run is already in flight", 409)
scheduled_for = datetime.now(timezone.utc)
agent_id_raw = schedule.get("agent_id")
run = ScheduleRunsRepository(conn).record_pending(
schedule_id,
user_id,
str(agent_id_raw) if agent_id_raw else None,
scheduled_for,
trigger_source="manual",
)
if run is None:
return _err("could not claim run (concurrent dispatch)", 409)
# Import inside the handler to avoid a circular tasks <-> routes import.
try:
from application.api.user.tasks import execute_scheduled_run
execute_scheduled_run.apply_async(args=[str(run["id"])], queue="docsgpt")
except Exception as exc:
current_app.logger.error(
"run-now enqueue failed: %s", exc, exc_info=True,
)
return _err("enqueue failed", 500)
return _ok({"run": _format_run(run)}, status=202)
@schedules_ns.route("/schedules/<string:schedule_id>/runs")
class ScheduleRunList(Resource):
@api.doc(
description="Paginated run log for a schedule.",
params={"limit": "Page size (default 50)", "offset": "Page offset"},
)
@_safe_route
def get(self, schedule_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
try:
limit = max(1, min(int(request.args.get("limit", 50)), 200))
except (TypeError, ValueError):
limit = 50
try:
offset = max(0, int(request.args.get("offset", 0)))
except (TypeError, ValueError):
offset = 0
with db_readonly() as conn:
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
if schedule is None:
return _err("schedule not found", 404)
rows = ScheduleRunsRepository(conn).list_runs(
schedule_id, user_id, limit=limit, offset=offset,
)
return _ok(
{
"runs": [_format_run(r) for r in rows],
"limit": limit,
"offset": offset,
}
)
@schedules_ns.route("/schedules/<string:schedule_id>/runs/<string:run_id>")
class ScheduleRunDetail(Resource):
@api.doc(description="Full output / error for a single run.")
@_safe_route
def get(self, schedule_id, run_id):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id):
return _err("invalid id", 400)
with db_readonly() as conn:
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
if schedule is None:
return _err("schedule not found", 404)
run = ScheduleRunsRepository(conn).get(run_id, user_id)
if run is None or str(run.get("schedule_id")) != str(
schedule["id"]
):
return _err("run not found", 404)
return _ok({"run": _format_run(run)})
+5
View File
@@ -0,0 +1,5 @@
"""Sharing module."""
from .routes import sharing_ns
__all__ = ["sharing_ns"]
+363
View File
@@ -0,0 +1,363 @@
"""Conversation sharing routes."""
import uuid
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, inputs, Namespace, Resource
from sqlalchemy import text as _sql_text
from application.api import api
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.attachments import AttachmentsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.shared_conversations import (
SharedConversationsRepository,
)
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
sharing_ns = Namespace(
"sharing", description="Conversation sharing operations", path="/api"
)
def _resolve_prompt_pg_id(conn, prompt_id_raw, user_id):
"""Translate an incoming prompt id (UUID or legacy Mongo ObjectId) to a PG UUID.
Scoped by ``user_id`` so a caller can't link another user's prompt
into their share record. Returns ``None`` for sentinel values
(``"default"``) or unresolved ids.
"""
if not prompt_id_raw or prompt_id_raw == "default":
return None
value = str(prompt_id_raw)
# Already UUID — trust it but still require ownership. A shape-gate
# (rather than a loose ``len == 36 and '-' in value`` check) keeps
# non-UUID input out of ``CAST(:pid AS uuid)``; the cast would raise
# and poison the readonly transaction otherwise.
if looks_like_uuid(value):
row = conn.execute(
_sql_text(
"SELECT id FROM prompts WHERE id = CAST(:pid AS uuid) "
"AND user_id = :uid"
),
{"pid": value, "uid": user_id},
).fetchone()
return str(row[0]) if row else None
# Legacy Mongo ObjectId fallback.
row = conn.execute(
_sql_text(
"SELECT id FROM prompts WHERE legacy_mongo_id = :pid "
"AND user_id = :uid"
),
{"pid": value, "uid": user_id},
).fetchone()
return str(row[0]) if row else None
def _resolve_source_pg_id(conn, source_raw):
"""Translate a source id (UUID or legacy Mongo ObjectId) to a PG UUID."""
if not source_raw:
return None
value = str(source_raw)
# See ``_resolve_prompt_pg_id`` for the shape-gate rationale.
if looks_like_uuid(value):
row = conn.execute(
_sql_text(
"SELECT id FROM sources WHERE id = CAST(:sid AS uuid)"
),
{"sid": value},
).fetchone()
return str(row[0]) if row else None
row = conn.execute(
_sql_text("SELECT id FROM sources WHERE legacy_mongo_id = :sid"),
{"sid": value},
).fetchone()
return str(row[0]) if row else None
def _find_reusable_share_agent(
conn, user_id, *, prompt_pg_id, chunks, source_pg_id, retriever,
):
"""Find an existing share-as-agent key row matching these parameters.
Mirrors the legacy Mongo ``agents_collection.find_one`` pre-existence
check. Used to reuse an api key across repeated shares of the same
conversation with the same prompt/chunks/source/retriever.
"""
clauses = ["user_id = :uid", "key IS NOT NULL"]
params: dict = {"uid": user_id}
if prompt_pg_id is None:
clauses.append("prompt_id IS NULL")
else:
clauses.append("prompt_id = CAST(:pid AS uuid)")
params["pid"] = prompt_pg_id
if chunks is None:
clauses.append("chunks IS NULL")
else:
clauses.append("chunks = :chunks")
params["chunks"] = int(chunks)
if source_pg_id is None:
clauses.append("source_id IS NULL")
else:
clauses.append("source_id = CAST(:sid AS uuid)")
params["sid"] = source_pg_id
if retriever is None:
clauses.append("retriever IS NULL")
else:
clauses.append("retriever = :retr")
params["retr"] = retriever
sql = (
"SELECT * FROM agents WHERE "
+ " AND ".join(clauses)
+ " LIMIT 1"
)
row = conn.execute(_sql_text(sql), params).fetchone()
if row is None:
return None
mapping = dict(row._mapping)
mapping["id"] = str(mapping["id"]) if mapping.get("id") else None
return mapping
@sharing_ns.route("/share")
class ShareConversation(Resource):
share_conversation_model = api.model(
"ShareConversationModel",
{
"conversation_id": fields.String(
required=True, description="Conversation ID"
),
"user": fields.String(description="User ID (optional)"),
"prompt_id": fields.String(description="Prompt ID (optional)"),
"chunks": fields.Integer(description="Chunks count (optional)"),
},
)
@api.expect(share_conversation_model)
@api.doc(description="Share a conversation")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["conversation_id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
is_promptable = request.args.get("isPromptable", type=inputs.boolean)
if is_promptable is None:
return make_response(
jsonify({"success": False, "message": "isPromptable is required"}), 400
)
conversation_id = data["conversation_id"]
try:
with db_session() as conn:
conv_repo = ConversationsRepository(conn)
shared_repo = SharedConversationsRepository(conn)
agents_repo = AgentsRepository(conn)
conversation = conv_repo.get_any(conversation_id, user)
if conversation is None:
return make_response(
jsonify(
{
"status": "error",
"message": "Conversation does not exist",
}
),
404,
)
conv_pg_id = str(conversation["id"])
current_n_queries = conv_repo.message_count(conv_pg_id)
if is_promptable:
prompt_id_raw = data.get("prompt_id", "default")
chunks_raw = data.get("chunks", "2")
try:
chunks_int = int(chunks_raw) if chunks_raw not in (None, "") else None
except (TypeError, ValueError):
chunks_int = None
prompt_pg_id = _resolve_prompt_pg_id(conn, prompt_id_raw, user)
source_pg_id = _resolve_source_pg_id(conn, data.get("source"))
retriever = data.get("retriever")
reusable = _find_reusable_share_agent(
conn, user,
prompt_pg_id=prompt_pg_id,
chunks=chunks_int,
source_pg_id=source_pg_id,
retriever=retriever,
)
if reusable:
api_uuid = reusable.get("key")
else:
api_uuid = str(uuid.uuid4())
name = (conversation.get("name") or "") + "(shared)"
agents_repo.create(
user,
name,
"published",
key=api_uuid,
retriever=retriever,
chunks=chunks_int,
prompt_id=prompt_pg_id,
source_id=source_pg_id,
)
share = shared_repo.get_or_create(
conv_pg_id,
user,
is_promptable=True,
first_n_queries=current_n_queries,
api_key=api_uuid,
prompt_id=prompt_pg_id,
chunks=chunks_int,
)
return make_response(
jsonify(
{
"success": True,
"identifier": str(share["uuid"]),
}
),
201 if reusable is None else 200,
)
# Non-promptable share path.
share = shared_repo.get_or_create(
conv_pg_id,
user,
is_promptable=False,
first_n_queries=current_n_queries,
api_key=None,
)
return make_response(
jsonify(
{
"success": True,
"identifier": str(share["uuid"]),
}
),
201,
)
except Exception as err:
current_app.logger.error(
f"Error sharing conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
@sharing_ns.route("/shared_conversation/<string:identifier>")
class GetPubliclySharedConversations(Resource):
@api.doc(description="Get publicly shared conversations by identifier")
def get(self, identifier: str):
try:
with db_readonly() as conn:
shared_repo = SharedConversationsRepository(conn)
conv_repo = ConversationsRepository(conn)
attach_repo = AttachmentsRepository(conn)
shared = shared_repo.find_by_uuid(identifier)
if not shared or not shared.get("conversation_id"):
return make_response(
jsonify(
{
"success": False,
"error": "might have broken url or the conversation does not exist",
}
),
404,
)
conv_pg_id = str(shared["conversation_id"])
owner_user = shared.get("user_id")
conversation = conv_repo.get_owned(conv_pg_id, owner_user) if owner_user else None
if conversation is None:
# Fall back to any-user lookup in case shared row's
# user_id is missing — still keyed by PG UUID.
row = conn.execute(
_sql_text(
"SELECT * FROM conversations WHERE id = CAST(:id AS uuid)"
),
{"id": conv_pg_id},
).fetchone()
if row is None:
return make_response(
jsonify(
{
"success": False,
"error": "might have broken url or the conversation does not exist",
}
),
404,
)
conversation = dict(row._mapping)
messages = conv_repo.get_messages(conv_pg_id)
first_n = shared.get("first_n_queries") or 0
conversation_queries = []
for msg in messages[:first_n]:
query = {
"prompt": msg.get("prompt"),
"response": msg.get("response"),
"thought": msg.get("thought"),
"sources": msg.get("sources") or [],
"tool_calls": msg.get("tool_calls") or [],
"timestamp": (
msg["timestamp"].isoformat()
if hasattr(msg.get("timestamp"), "isoformat")
else msg.get("timestamp")
),
"feedback": msg.get("feedback"),
}
attachments = msg.get("attachments") or []
if attachments:
attachment_details = []
for attachment_id in attachments:
try:
attachment = attach_repo.get_any(
str(attachment_id), owner_user,
) if owner_user else None
if attachment:
attachment_details.append(
{
"id": str(attachment["id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
conversation_queries.append(query)
created = conversation.get("created_at") or conversation.get("date")
date_iso = (
created.isoformat()
if hasattr(created, "isoformat")
else (str(created) if created is not None else None)
)
res = {
"success": True,
"queries": conversation_queries,
"title": conversation.get("name"),
"timestamp": date_iso,
}
if shared.get("is_promptable") and shared.get("api_key"):
res["api_key"] = shared["api_key"]
return make_response(jsonify(res), 200)
except Exception as err:
current_app.logger.error(
f"Error getting shared conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
+7
View File
@@ -0,0 +1,7 @@
"""Sources module."""
from .chunks import sources_chunks_ns
from .routes import sources_ns
from .upload import sources_upload_ns
__all__ = ["sources_ns", "sources_chunks_ns", "sources_upload_ns"]
+311
View File
@@ -0,0 +1,311 @@
"""Source document management chunk management."""
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import get_vector_store
from application.api.user.team_sharing import effective_write_owner
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.session import db_readonly
from application.utils import check_required_fields, num_tokens_from_string
sources_chunks_ns = Namespace(
"sources", description="Source document management operations", path="/api"
)
def _resolve_source(doc_id: str, user: str):
"""Resolve a source (UUID or legacy ObjectId) for the caller.
Returns the row dict (with PG UUID in ``id``) or ``None`` if missing.
"""
with db_readonly() as conn:
return SourcesRepository(conn).get_any(doc_id, user)
def _resolve_source_for_write(doc_id: str, user: str):
"""Resolve a source the caller may WRITE chunks on.
Returns the row dict when ``user`` owns the source, or when they hold a
team ``editor`` grant (adding/removing/editing documents is editor-allowed
— the vector partition is keyed by source_id, owner-agnostic). Returns
``None`` for viewer-only / no access. Source deletion stays owner-only and
is handled elsewhere.
"""
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(doc_id, user)
if doc is not None:
return doc
owner = effective_write_owner(conn, "source", doc_id, user)
if not owner:
return None
return SourcesRepository(conn).get_any(doc_id, owner)
@sources_chunks_ns.route("/get_chunks")
class GetChunks(Resource):
@api.doc(
description="Retrieves chunks from a document, optionally filtered by file path and search term",
params={
"id": "The document ID",
"page": "Page number for pagination",
"per_page": "Number of chunks per page",
"path": "Optional: Filter chunks by relative file path",
"search": "Optional: Search term to filter chunks by title or content",
},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
path = request.args.get("path")
search_term = request.args.get("search", "").strip().lower()
if not doc_id:
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
try:
doc = _resolve_source(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
resolved_id = str(doc["id"])
try:
store = get_vector_store(resolved_id)
chunks = store.get_chunks()
filtered_chunks = []
for chunk in chunks:
metadata = chunk.get("metadata", {})
if path:
chunk_source = metadata.get("source", "")
chunk_file_path = metadata.get("file_path", "")
source_match = chunk_source and chunk_source.endswith(path)
file_path_match = chunk_file_path and chunk_file_path.endswith(path)
if not (source_match or file_path_match):
continue
if search_term:
text_match = search_term in chunk.get("text", "").lower()
title_match = search_term in metadata.get("title", "").lower()
if not (text_match or title_match):
continue
filtered_chunks.append(chunk)
chunks = filtered_chunks
total_chunks = len(chunks)
start = (page - 1) * per_page
end = start + per_page
paginated_chunks = chunks[start:end]
return make_response(
jsonify(
{
"page": page,
"per_page": per_page,
"total": total_chunks,
"chunks": paginated_chunks,
"path": path if path else None,
"search": search_term if search_term else None,
}
),
200,
)
except Exception as e:
current_app.logger.error(f"Error getting chunks: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/add_chunk")
class AddChunk(Resource):
@api.expect(
api.model(
"AddChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"text": fields.String(required=True, description="Text of the chunk"),
"metadata": fields.Raw(
required=False,
description="Metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Adds a new chunk to the document",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "text"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
text = data.get("text")
metadata = data.get("metadata", {})
token_count = num_tokens_from_string(text)
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunk_id = store.add_chunk(text, metadata)
return make_response(
jsonify({"message": "Chunk added successfully", "chunk_id": chunk_id}),
201,
)
except Exception as e:
current_app.logger.error(f"Error adding chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/delete_chunk")
class DeleteChunk(Resource):
@api.doc(
description="Deletes a specific chunk from the document.",
params={"id": "The document ID", "chunk_id": "The ID of the chunk to delete"},
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
chunk_id = request.args.get("chunk_id")
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
deleted = store.delete_chunk(chunk_id)
if deleted:
return make_response(
jsonify({"message": "Chunk deleted successfully"}), 200
)
else:
return make_response(
jsonify({"message": "Chunk not found or could not be deleted"}),
404,
)
except Exception as e:
current_app.logger.error(f"Error deleting chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/update_chunk")
class UpdateChunk(Resource):
@api.expect(
api.model(
"UpdateChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"chunk_id": fields.String(
required=True, description="Chunk ID to update"
),
"text": fields.String(
required=False, description="New text of the chunk"
),
"metadata": fields.Raw(
required=False,
description="Updated metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Updates an existing chunk in the document.",
)
def put(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "chunk_id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
chunk_id = data.get("chunk_id")
text = data.get("text")
metadata = data.get("metadata")
if text is not None:
token_count = num_tokens_from_string(text)
if metadata is None:
metadata = {}
metadata["token_count"] = token_count
try:
doc = _resolve_source_for_write(doc_id, user)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
if not doc:
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunks = store.get_chunks()
existing_chunk = next((c for c in chunks if c["doc_id"] == chunk_id), None)
if not existing_chunk:
return make_response(jsonify({"error": "Chunk not found"}), 404)
new_text = text if text is not None else existing_chunk["text"]
if metadata is not None:
new_metadata = existing_chunk["metadata"].copy()
new_metadata.update(metadata)
else:
new_metadata = existing_chunk["metadata"].copy()
if text is not None:
new_metadata["token_count"] = num_tokens_from_string(new_text)
try:
new_chunk_id = store.add_chunk(new_text, new_metadata)
deleted = store.delete_chunk(chunk_id)
if not deleted:
current_app.logger.warning(
f"Failed to delete old chunk {chunk_id}, but new chunk {new_chunk_id} was created"
)
return make_response(
jsonify(
{
"message": "Chunk updated successfully",
"chunk_id": new_chunk_id,
"original_chunk_id": chunk_id,
}
),
200,
)
except Exception as add_error:
current_app.logger.error(f"Failed to add updated chunk: {add_error}")
return make_response(
jsonify({"error": "Failed to update chunk - addition failed"}), 500
)
except Exception as e:
current_app.logger.error(f"Error updating chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+650
View File
@@ -0,0 +1,650 @@
import logging
from datetime import timedelta
from application.api.user.idempotency import with_idempotency
from application.celery_init import celery
from application.worker import (
agent_webhook_worker,
attachment_worker,
ingest_worker,
mcp_oauth,
parse_document_worker,
reembed_wiki_page_worker,
remote_worker,
sync,
sync_worker,
)
# Shared decorator config for long-running, side-effecting tasks. ``acks_late``
# is also the celeryconfig default but stays explicit here so each task's
# durability story is grep-able next to the body. Combined with
# ``autoretry_for=(Exception,)`` and a bounded ``max_retries`` so a poison
# message can't loop forever.
DURABLE_TASK = dict(
bind=True,
acks_late=True,
autoretry_for=(Exception,),
retry_kwargs={"max_retries": 3, "countdown": 60},
retry_backoff=True,
)
# operation tag for the poison-path source.ingest.failed event, per task.
_INGEST_POISON_OPERATION = {
"ingest": "upload",
"ingest_remote": "upload",
"ingest_connector_task": "upload",
"reingest_source_task": "reingest",
}
def _emit_ingest_poison_event(task_name, bound):
"""Publish a terminal ``source.ingest.failed`` when the poison-guard trips.
The guard returns before the worker runs, so the worker's own failed
event never fires — without this the upload toast spins on "training".
"""
user = bound.get("user")
source_id = bound.get("source_id")
if not user or not source_id:
return
from application.events.publisher import publish_user_event
publish_user_event(
user,
"source.ingest.failed",
{
"source_id": str(source_id),
"filename": bound.get("filename") or "",
"operation": _INGEST_POISON_OPERATION.get(task_name, "upload"),
"error": "Ingestion stopped after repeated failures.",
},
scope={"kind": "source", "id": str(source_id)},
)
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="ingest", on_poison=_emit_ingest_poison_event)
def ingest(
self,
directory,
formats,
job_name,
user,
file_path,
filename,
file_name_map=None,
config=None,
idempotency_key=None,
source_id=None,
):
resp = ingest_worker(
self,
directory,
formats,
job_name,
file_path,
filename,
user,
file_name_map=file_name_map,
config=config,
idempotency_key=idempotency_key,
source_id=source_id,
)
return resp
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="ingest_remote", on_poison=_emit_ingest_poison_event)
def ingest_remote(
self, source_data, job_name, user, loader,
config=None, idempotency_key=None, source_id=None,
):
resp = remote_worker(
self, source_data, job_name, user, loader,
config=config,
idempotency_key=idempotency_key,
source_id=source_id,
)
return resp
@celery.task(**DURABLE_TASK)
@with_idempotency(
task_name="reingest_source_task", on_poison=_emit_ingest_poison_event,
)
def reingest_source_task(self, source_id, user, idempotency_key=None):
from application.worker import reingest_source_worker
resp = reingest_source_worker(self, source_id, user)
return resp
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="reembed_wiki_page")
def reembed_wiki_page(
self, source_id, path, content_hash, user, idempotency_key=None,
):
resp = reembed_wiki_page_worker(self, source_id, path, content_hash, user)
return resp
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="convert_source_to_wiki")
def convert_source_to_wiki(self, source_id, user, idempotency_key=None):
from application.worker import convert_source_to_wiki_worker
resp = convert_source_to_wiki_worker(self, source_id, user)
return resp
def _emit_graph_poison_event(task_name, bound):
"""Publish a terminal ``graph.extract.failed`` when the poison-guard trips.
The guard returns before the worker runs, so the worker's own failed event
never fires — without this the build UI spins forever.
"""
user = bound.get("user")
source_id = bound.get("source_id")
if not user or not source_id:
return
from application.events.publisher import publish_user_event
publish_user_event(
user,
"graph.extract.failed",
{
"source_id": str(source_id),
"error": "Graph extraction stopped after repeated failures.",
},
scope={"kind": "source", "id": str(source_id)},
)
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="extract_graph", on_poison=_emit_graph_poison_event)
def extract_graph(self, source_id, user, idempotency_key=None):
from application.worker import extract_graph_worker
resp = extract_graph_worker(self, source_id, user)
return resp
# Beat-driven dispatch tasks default to ``acks_late=False``: a SIGKILL
# of a beat tick is harmless to redeliver only if the dispatch itself is
# idempotent. We keep these early-ACK so the broker doesn't replay a
# dispatch that already enqueued downstream work.
@celery.task(bind=True, acks_late=False)
def schedule_syncs(self, frequency):
resp = sync_worker(self, frequency)
return resp
@celery.task(bind=True)
def sync_source(
self,
source_data,
job_name,
user,
loader,
sync_frequency,
retriever,
doc_id,
):
resp = sync(
self,
source_data,
job_name,
user,
loader,
sync_frequency,
retriever,
doc_id,
)
return resp
def _emit_attachment_poison_event(task_name, bound):
"""Publish a terminal ``attachment.failed`` when the poison-guard trips.
Mirrors ``_emit_ingest_poison_event``: the guard returns before the
worker runs, so ``attachment_worker``'s own events never fire and the
upload toast would otherwise spin on "processing" forever.
"""
user = bound.get("user")
file_info = bound.get("file_info") or {}
attachment_id = file_info.get("attachment_id")
if not user or not attachment_id:
return
from application.events.publisher import publish_user_event
publish_user_event(
user,
"attachment.failed",
{
"attachment_id": str(attachment_id),
"filename": file_info.get("filename") or "",
"error": "Attachment processing stopped after repeated failures.",
},
scope={"kind": "attachment", "id": str(attachment_id)},
)
@celery.task(**DURABLE_TASK)
@with_idempotency(
task_name="store_attachment", on_poison=_emit_attachment_poison_event,
)
def store_attachment(self, file_info, user, idempotency_key=None):
resp = attachment_worker(self, file_info, user)
return resp
@celery.task(**DURABLE_TASK)
@with_idempotency(task_name="process_agent_webhook")
def process_agent_webhook(self, agent_id, payload, idempotency_key=None):
resp = agent_webhook_worker(self, agent_id, payload)
return resp
# Not DURABLE: the read_document tool awaits this synchronously with a timeout, so a
# blind autoretry would double-parse and the caller would already have degraded. The
# task is routed to the dedicated ``parsing`` queue (celeryconfig task_routes) so a
# parse enqueued from inside a Celery worker (headless/scheduled agent) is served by a
# separate parsing worker and never self-deadlocks the awaiting worker.
@celery.task(bind=True, acks_late=False, autoretry_for=())
def parse_document(self, artifact_id, parent, user_id, options=None):
"""Parse an input artifact on the parsing queue; self-terminate at the soft time limit."""
from celery.exceptions import SoftTimeLimitExceeded
try:
return parse_document_worker(self, artifact_id, parent, user_id, options or {})
except SoftTimeLimitExceeded:
# A pathological/malicious document must not pin a parsing-worker slot past the
# window the caller already abandoned. Return the worker's clean error shape so
# the slot frees and the Redis result backend still gets a terminal result.
limit = getattr(self, "soft_time_limit", None)
suffix = f" after {int(limit)}s" if limit else ""
return {"status": "error", "error": f"document parsing timed out{suffix}."}
# Bind the soft limit to DOCUMENT_PARSE_TIMEOUT (the same window read_document awaits) so
# the prefork worker self-terminates a runaway parse instead of pinning the slot; the hard
# limit is the SIGKILL backstop if the soft handler can't unwind in time.
try:
from application.core.settings import settings as _parse_settings
parse_document.soft_time_limit = int(_parse_settings.DOCUMENT_PARSE_TIMEOUT)
parse_document.time_limit = parse_document.soft_time_limit + 30
except Exception:
pass
@celery.task(**DURABLE_TASK)
@with_idempotency(
task_name="ingest_connector_task", on_poison=_emit_ingest_poison_event,
)
def ingest_connector_task(
self,
job_name,
user,
source_type,
session_token=None,
file_ids=None,
folder_ids=None,
recursive=True,
retriever="classic",
operation_mode="upload",
doc_id=None,
sync_frequency="never",
config=None,
idempotency_key=None,
source_id=None,
):
from application.worker import ingest_connector
resp = ingest_connector(
self,
job_name,
user,
source_type,
session_token=session_token,
file_ids=file_ids,
folder_ids=folder_ids,
recursive=recursive,
retriever=retriever,
operation_mode=operation_mode,
doc_id=doc_id,
sync_frequency=sync_frequency,
config=config,
idempotency_key=idempotency_key,
source_id=source_id,
)
return resp
@celery.task(bind=True, acks_late=False)
def dispatch_scheduled_runs(self):
"""Beat-driven scheduler poller (body in scheduler_dispatcher)."""
from application.api.user.scheduler_dispatcher import dispatch_due_runs
return dispatch_due_runs()
@celery.task(
bind=True,
acks_late=True,
# Not DURABLE_TASK: agent runs have side effects; blind retry would double them.
autoretry_for=(),
max_retries=0,
)
def execute_scheduled_run(self, run_id):
"""Execute one scheduled run; soft-time-limit honors SCHEDULE_RUN_TIMEOUT."""
from application.api.user.scheduler_worker import execute_scheduled_run_body
return execute_scheduled_run_body(run_id, getattr(self.request, "id", None))
# Bind runtime soft-time-limit so the prefork worker can raise mid-agent.
try:
from application.core.settings import settings as _scheduler_settings
execute_scheduled_run.soft_time_limit = max(
30, int(_scheduler_settings.SCHEDULE_RUN_TIMEOUT),
)
execute_scheduled_run.time_limit = (
execute_scheduled_run.soft_time_limit + 60
)
except Exception:
pass
@celery.task(bind=True, acks_late=False)
def cleanup_schedule_runs(self):
"""Trim ``schedule_runs`` per ``SCHEDULE_RUN_OUTPUT_RETENTION_DAYS``."""
from application.core.settings import settings
if not settings.POSTGRES_URI:
return {"deleted": 0, "skipped": "POSTGRES_URI not set"}
from application.storage.db.engine import get_engine
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
ttl_days = settings.SCHEDULE_RUN_OUTPUT_RETENTION_DAYS
engine = get_engine()
with engine.begin() as conn:
deleted = ScheduleRunsRepository(conn).cleanup_older_than(ttl_days)
return {"deleted": deleted, "ttl_days": ttl_days}
@celery.task(bind=True, acks_late=False)
def reap_sandbox_sessions(self):
"""Close sandbox sessions idle past their TTL in this worker process.
The SandboxManager registry is per-process, so this reaps only sessions
bound in THIS worker; the API processes reap their own opportunistically on
``open``. Artifacts are persisted eagerly, so reaping only closes idle
kernels and never loses a user-facing artifact.
"""
try:
from application.sandbox.sandbox_creator import SandboxCreator
reaped = SandboxCreator.get_manager().reap_expired()
except Exception: # noqa: BLE001 - housekeeping must never crash the beat loop
logging.getLogger(__name__).exception("reap_sandbox_sessions failed")
return {"reaped": 0, "error": True}
return {"reaped": len(reaped)}
@celery.task(bind=True, acks_late=False)
def reap_stale_workflow_runs(self):
"""Fail workflow runs stranded in ``running`` past the stale deadline.
A run row is pre-created as ``running`` and finalized when its generator
finishes; a client disconnect or worker crash can leave it ``running``
forever. This closes those rows out so the UI/API stop showing a run that
will never complete.
"""
from datetime import datetime, timezone
from application.core.settings import settings
from application.storage.db.engine import get_engine
from application.storage.db.repositories.workflow_runs import WorkflowRunsRepository
try:
stale_seconds = max(60, int(settings.WORKFLOW_RUN_STALE_SECONDS))
cutoff = datetime.now(timezone.utc) - timedelta(seconds=stale_seconds)
engine = get_engine()
with engine.begin() as conn:
reaped = WorkflowRunsRepository(conn).mark_stale_running_failed(cutoff)
except Exception: # noqa: BLE001 - housekeeping must never crash the beat loop
logging.getLogger(__name__).exception("reap_stale_workflow_runs failed")
return {"reaped": 0, "error": True}
return {"reaped": reaped}
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
from application.core.settings import settings
sender.add_periodic_task(
timedelta(days=1),
schedule_syncs.s("daily"),
)
sender.add_periodic_task(
timedelta(weeks=1),
schedule_syncs.s("weekly"),
)
sender.add_periodic_task(
timedelta(days=30),
schedule_syncs.s("monthly"),
)
# Replaces Mongo's TTL index on pending_tool_state.expires_at.
sender.add_periodic_task(
timedelta(seconds=60),
cleanup_pending_tool_state.s(),
name="cleanup-pending-tool-state",
)
# Pure housekeeping for ``task_dedup`` / ``webhook_dedup`` — the
# upsert paths already handle stale rows, so cadence only bounds
# table size. Hourly is plenty for typical traffic.
sender.add_periodic_task(
timedelta(hours=1),
cleanup_idempotency_dedup.s(),
name="cleanup-idempotency-dedup",
)
sender.add_periodic_task(
timedelta(seconds=30),
reconciliation_task.s(),
name="reconciliation",
)
sender.add_periodic_task(
timedelta(hours=7),
version_check_task.s(),
name="version-check",
)
# Bound ``message_events`` growth — every streamed SSE chunk writes
# one row, so retained chats accumulate hundreds of rows per
# message. Reconnect-replay is only meaningful for streams the user
# could plausibly still be waiting on, so 14 days is generous.
sender.add_periodic_task(
timedelta(hours=24),
cleanup_message_events.s(),
name="cleanup-message-events",
)
sender.add_periodic_task(
timedelta(hours=24),
cleanup_orphan_memories.s(),
name="cleanup-orphan-memories",
)
# Scheduler dispatcher and run-log trim.
sender.add_periodic_task(
timedelta(seconds=max(15, settings.SCHEDULE_DISPATCHER_INTERVAL)),
dispatch_scheduled_runs.s(),
name="dispatch-scheduled-runs",
)
sender.add_periodic_task(
timedelta(hours=24),
cleanup_schedule_runs.s(),
name="cleanup-schedule-runs",
)
# Close idle-past-TTL sandbox sessions roughly every minute. The on-open
# opportunistic reap still runs in the API processes; this covers worker
# processes (and quiet periods where no new session is opened).
sender.add_periodic_task(
timedelta(seconds=60),
reap_sandbox_sessions.s(),
name="reap-sandbox-sessions",
)
# Fail workflow runs stranded in ``running`` (client disconnect / crash) so
# they don't linger forever. Every few minutes is plenty; the cutoff is hours.
sender.add_periodic_task(
timedelta(seconds=300),
reap_stale_workflow_runs.s(),
name="reap-stale-workflow-runs",
)
# Bound time limits so a hung OAuth discovery (user never finishes the
# consent flow, upstream never redirects) self-terminates instead of
# stranding the ``mcp.oauth.awaiting_redirect`` envelope forever. The
# soft limit raises inside ``mcp_oauth``'s ``try`` so it publishes a
# terminal ``mcp.oauth.failed``; the hard limit is the prefork backstop.
# Generous so a human actively clicking through OAuth isn't cut off.
@celery.task(bind=True, soft_time_limit=600, time_limit=660)
def mcp_oauth_task(self, config, user):
resp = mcp_oauth(self, config, user)
return resp
@celery.task(bind=True, acks_late=False)
def cleanup_pending_tool_state(self):
"""Revert stale ``resuming`` rows, then delete TTL-expired rows."""
from application.core.settings import settings
if not settings.POSTGRES_URI:
return {"deleted": 0, "reverted": 0, "skipped": "POSTGRES_URI not set"}
from application.storage.db.engine import get_engine
from application.storage.db.repositories.pending_tool_state import (
PendingToolStateRepository,
)
engine = get_engine()
with engine.begin() as conn:
repo = PendingToolStateRepository(conn)
reverted = repo.revert_stale_resuming(grace_seconds=600)
cleared = repo.cleanup_expired()
# Reaping the resumable state retires any awaiting-approval prompt
# tied to it. Without a clearing event the durable
# ``tool.approval.required`` envelope replays on reconnect and the UI
# toast lingers for a conversation that can no longer be resumed.
from application.events.publisher import publish_user_event
for row in cleared:
user_id = row.get("user_id")
conversation_id = row.get("conversation_id")
if not user_id or not conversation_id:
continue
publish_user_event(
str(user_id),
"tool.approval.cleared",
{"conversation_id": str(conversation_id), "reason": "expired"},
scope={"kind": "conversation", "id": str(conversation_id)},
)
return {"deleted": len(cleared), "reverted": reverted}
@celery.task(bind=True, acks_late=False)
def cleanup_idempotency_dedup(self):
"""Delete TTL-expired rows from ``task_dedup`` and ``webhook_dedup``.
Pure housekeeping — the upsert paths already ignore stale rows
(TTL-aware ``ON CONFLICT DO UPDATE``), so this only bounds table
growth and keeps SELECT planning tight on large deployments.
"""
from application.core.settings import settings
if not settings.POSTGRES_URI:
return {
"task_dedup_deleted": 0,
"webhook_dedup_deleted": 0,
"skipped": "POSTGRES_URI not set",
}
from application.storage.db.engine import get_engine
from application.storage.db.repositories.idempotency import (
IdempotencyRepository,
)
engine = get_engine()
with engine.begin() as conn:
return IdempotencyRepository(conn).cleanup_expired()
@celery.task(bind=True, acks_late=False)
def reconciliation_task(self):
"""Sweep stuck durability rows and escalate them to terminal status + alert."""
from application.api.user.reconciliation import run_reconciliation
return run_reconciliation()
@celery.task(bind=True, acks_late=False)
def cleanup_message_events(self):
"""Delete ``message_events`` rows older than the retention window.
Streamed answer responses write one journal row per SSE yield,
so unbounded growth would dominate Postgres for any retained-
conversations deployment. The reconnect-replay path only needs
rows for in-flight streams; 14 days covers paused/tool-action
flows comfortably.
"""
from application.core.settings import settings
if not settings.POSTGRES_URI:
return {"deleted": 0, "skipped": "POSTGRES_URI not set"}
from application.storage.db.engine import get_engine
from application.storage.db.repositories.message_events import (
MessageEventsRepository,
)
ttl_days = settings.MESSAGE_EVENTS_RETENTION_DAYS
engine = get_engine()
with engine.begin() as conn:
deleted = MessageEventsRepository(conn).cleanup_older_than(ttl_days)
return {"deleted": deleted, "ttl_days": ttl_days}
@celery.task(bind=True, acks_late=False)
def cleanup_orphan_memories(self):
"""Sweep orphan memories left by the 0009 FK-to-trigger orphan window.
A ``memories`` INSERT for a real ``tool_id`` racing a ``user_tools``
DELETE leaves a permanent orphan the dropped FK would have rejected.
Default-tool synthetic ids are preserved (legitimate built-in data).
"""
from application.core.settings import settings
if not settings.POSTGRES_URI:
return {"deleted": 0, "skipped": "POSTGRES_URI not set"}
from application.agents.default_tools import default_tool_ids
from application.storage.db.engine import get_engine
from application.storage.db.repositories.memories import MemoriesRepository
keep_tool_ids = list(default_tool_ids().values())
engine = get_engine()
with engine.begin() as conn:
deleted = MemoriesRepository(conn).delete_orphans(keep_tool_ids)
return {"deleted": deleted}
@celery.task(bind=True, acks_late=False)
def version_check_task(self):
"""Periodic anonymous version check.
Complements the ``worker_ready`` boot trigger so long-running
deployments (>6h cache TTL) still refresh advisories. ``run_check``
is fail-silent and coordinates across replicas via Redis lock +
cache (see ``application.updates.version_check``).
"""
from application.updates.version_check import run_check
run_check()
+109
View File
@@ -0,0 +1,109 @@
"""Team-scoped authorization — the SECOND authz plane beside global RBAC.
Mirrors ``application/api/user/authz.py`` (the global admin/user plane) but for
per-team roles. The two planes never mix: a global ``admin`` is a superuser over
all teams (short-circuit below), but a ``team_admin`` is NOT a global admin.
Key differences from the global resolver, all deliberate:
- Team roles are resolved PER ROUTE (lazy), not eagerly in the app.py chokepoint
— most requests never touch a team route, so we don't pay a team query on the
universal path.
- Team roles are NEVER read from the JWT (same reason as global RBAC: simple_jwt
/ session_jwt are self-mintable).
- ``team_id`` is read ONLY from ``request.view_args`` (the URL path), NEVER the
request body — a body-supplied team_id would be a trivial escalation vector.
- Resolution FAILS CLOSED: a DB error while reading membership denies access
(the inverse of the global resolver, which fails open — here, failing open
would grant team access during an outage).
"""
from __future__ import annotations
import logging
from functools import wraps
from flask import jsonify, make_response, request
from application.api.user.authz import ROLE_ADMIN, has_role
from application.storage.db.repositories.team_members import (
ROLE_TEAM_ADMIN,
ROLE_TEAM_MEMBER,
TeamMembersRepository,
)
from application.storage.db.session import db_readonly
logger = logging.getLogger(__name__)
def team_role_for(token: dict | None, team_id: str | None) -> str | None:
"""Strongest team role the principal holds in ``team_id``, or None.
Fails closed to None on a DB error so a membership-read outage denies team
access rather than granting it.
"""
if not token or not team_id:
return None
sub = token.get("sub")
if not sub:
return None
try:
with db_readonly() as conn:
return TeamMembersRepository(conn).role_for(sub, team_id)
except Exception:
logger.error(
"team_role_for: team_members read failed for sub=%s team=%s",
sub,
team_id,
exc_info=True,
)
return None
def has_team_role(token: dict | None, team_id: str | None, name: str) -> bool:
"""True if the principal satisfies ``name`` for ``team_id``.
A global ``admin`` satisfies any team role (superuser over all teams).
Otherwise ``team_admin`` implies ``team_member`` (admin ⊇ member).
"""
if has_role(token, ROLE_ADMIN):
return True
role = team_role_for(token, team_id)
if role is None:
return False
if name == ROLE_TEAM_MEMBER:
return True
return role == name
def require_team_role(name: str):
"""Decorator factory: 401 when unauthenticated, 403 when lacking ``name``.
Reads ``team_id`` from the route kwargs (URL path) ONLY — never the body.
Fails closed: a missing token, missing team_id, or membership-read error
never passes through.
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
token = getattr(request, "decoded_token", None)
if not token:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
team_id = kwargs.get("team_id")
if not team_id:
return make_response(
jsonify({"success": False, "message": "Team not specified"}), 400
)
if not has_team_role(token, team_id, name):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
return func(*args, **kwargs)
return wrapper
return decorator
team_admin_required = require_team_role(ROLE_TEAM_ADMIN)
team_member_required = require_team_role(ROLE_TEAM_MEMBER)
+124
View File
@@ -0,0 +1,124 @@
"""Cross-cutting helpers for team resource sharing.
Centralises the polymorphic dispatch over the four shareable resource types so
the grant endpoint and the per-entity list/get/update paths share one source of
truth for "does this user own resource R?" and "what can this user see/edit via
their teams?". Every shareable repo exposes the same ``get_any(id, user_id)``
ownership accessor, which is what makes the dispatch uniform.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.team_scope import TeamScopeRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
RESOURCE_TYPES = ("agent", "source", "prompt", "tool")
_REPO_FOR_TYPE = {
"agent": AgentsRepository,
"source": SourcesRepository,
"prompt": PromptsRepository,
"tool": UserToolsRepository,
}
def is_valid_resource_type(resource_type: str) -> bool:
return resource_type in _REPO_FOR_TYPE
def owns_resource(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> bool:
"""True if ``user_id`` is the OWNER of the resource.
Dispatches to the correct repo by ``resource_type`` — this is the guard that
stops a caller registering a grant with a mismatched ``resource_type`` /
``resource_id`` (the polymorphic grant table has no FK to catch it). Owners
only — team-granted ``editor`` access does NOT make you an owner.
"""
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None:
return False
return repo_cls(conn).get_any(resource_id, user_id) is not None
def team_access_for(
conn: Connection, user_id: str, resource_type: str, resource_id: str
) -> Optional[str]:
"""Strongest team access (``editor``/``viewer``) ``user_id`` has on a resource.
None when no team grant reaches the user (they may still be the owner — that
is a separate dual-key check at the repo).
"""
return TeamScopeRepository(conn).effective_access(user_id, resource_type, resource_id)
def visible_ids_via_teams(
conn: Connection, user_id: str, resource_type: str
) -> set[str]:
"""The id set of ``resource_type`` shared to any team ``user_id`` belongs to."""
return TeamScopeRepository(conn).visible_resource_ids(user_id, resource_type)
def visible_with_access(
conn: Connection, user_id: str, resource_type: str
) -> dict[str, str]:
"""Map ``resource_id -> strongest access`` shared to the user's teams."""
return TeamScopeRepository(conn).visible_with_access(user_id, resource_type)
def effective_write_owner(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> Optional[str]:
"""The owner id to write a resource AS, or None if the caller can't write.
Returns ``user_id`` when the caller owns the resource, or the resource's
real ``owner_id`` when the caller holds a team ``editor`` grant — so callers
can pass the result straight to the existing owner-scoped ``update(id, owner,
...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without
a separate ownerless write path. None means viewer-only or no access → the
route should answer 403/404. Delete is never authorized here — owner-only.
"""
if owns_resource(conn, resource_type, resource_id, user_id):
return user_id
# Past the ownership check, only canonical-UUID resources can carry a team
# grant; a legacy/non-UUID id can't, and casting it would poison the txn.
if not looks_like_uuid(resource_id):
return None
grants = TeamResourceGrantsRepository(conn).list_for_resource(
resource_type, resource_id
)
if not grants:
return None
if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id):
# All grant rows carry the same denormalised owner_id.
return grants[0].get("owner_id")
return None
def can_access(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> bool:
"""True if ``user_id`` owns the resource OR has any team grant on it (read).
This is the write-path gate for *referencing* a resource (e.g. attaching a
``source_id`` to an agent): you may reference what you own or what a team has
shared with you directly. Transitive access *through* a shared agent is a
separate, run-time concept and is intentionally NOT gated here.
"""
if not resource_id:
return True
if owns_resource(conn, resource_type, resource_id, user_id):
return True
return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id)
+5
View File
@@ -0,0 +1,5 @@
"""Teams API namespace: team CRUD, membership, and resource-sharing grants."""
from .routes import teams_ns
__all__ = ["teams_ns"]
+636
View File
@@ -0,0 +1,636 @@
"""Team management API: CRUD, membership, and resource-sharing grants.
Authorization model (two planes, see ``team_authz.py``):
- Any authenticated user may create a team (self-serve); the creator becomes a
``team_admin`` in the same transaction.
- Team detail / member list / grant list require team membership.
- Member management and team edit require ``team_admin``.
- Team deletion and owner transfer are owner-only (a global ``admin`` overrides).
- Sharing a resource requires the caller to OWN it (dispatched by resource_type)
and be a member of the target team. Sharing is additive visibility — the
resource's owner is never changed.
``team_id`` always comes from the URL path (never the body) — enforced by
``require_team_role`` and by reading ``team_id`` as a route kwarg here.
"""
from __future__ import annotations
import logging
import re
import uuid
from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
from application.api.user.authz import ROLE_ADMIN, has_role
from application.api.user.team_authz import (
has_team_role,
team_admin_required,
team_member_required,
)
from application.api.user.team_sharing import is_valid_resource_type, owns_resource
from application.events.publisher import publish_user_event
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.team_members import (
ROLE_TEAM_ADMIN,
ROLE_TEAM_MEMBER,
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
teams_ns = Namespace("teams", description="Team management and resource sharing", path="/api")
_VALID_TEAM_ROLES = (ROLE_TEAM_ADMIN, ROLE_TEAM_MEMBER)
_VALID_ACCESS_LEVELS = ("viewer", "editor")
def _current_user() -> str | None:
token = getattr(request, "decoded_token", None)
return token.get("sub") if isinstance(token, dict) else None
def _audit(conn, actor: str | None, event: str, **metadata) -> None:
"""Append a team management event to the audit trail (best-effort).
Runs inside the action's transaction so the audit row commits atomically
with the change. Never raises into the request path — an audit failure must
not fail the operation.
"""
try:
# SAVEPOINT: a failed audit insert poisons the surrounding txn, so
# nest it — on failure only the audit rolls back, not the action.
with conn.begin_nested():
AuthEventsRepository(conn).insert(
user_id=actor or "unknown",
event=event,
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={k: v for k, v in metadata.items() if v is not None},
)
except Exception:
logger.warning("team audit insert failed for event=%s", event, exc_info=True)
def _slugify(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
return base or "team"
def _unique_slug(repo: TeamsRepository, base: str) -> str:
"""A slug not yet taken: ``base`` if free, else ``base-<4hex>`` until unique."""
if not repo.slug_exists(base):
return base
for _ in range(8):
candidate = f"{base}-{uuid.uuid4().hex[:4]}"
if not repo.slug_exists(candidate):
return candidate
return f"{base}-{uuid.uuid4().hex}"
# --- Notifications (best-effort, fire-and-forget; never fail the request) ----
# Emitted AFTER the mutation commits, over their own read connection, so a
# name-lookup or Redis hiccup can never roll back the share/membership change.
def _resource_display_name(conn, resource_type: str, resource_id: str) -> str | None:
"""Best-effort human label for a shared resource (ownerless fetch)."""
try:
if resource_type == "agent":
row = AgentsRepository(conn).get_by_id(resource_id)
elif resource_type == "source":
row = SourcesRepository(conn).get_by_id(resource_id)
elif resource_type == "prompt":
row = PromptsRepository(conn).get_for_rendering(resource_id)
elif resource_type == "tool":
row = UserToolsRepository(conn).get_by_id(resource_id)
else:
return None
if not row:
return None
return row.get("custom_name") or row.get("display_name") or row.get("name")
except Exception:
logger.warning("resource name resolve failed (%s)", resource_type, exc_info=True)
return None
def _notify_member_added(
team_id: str, new_user: str | None, role: str, actor: str | None
) -> None:
"""Toast the new member that they were added to a team."""
if not new_user or new_user == actor:
return
try:
with db_readonly() as conn:
team = TeamsRepository(conn).get(team_id)
publish_user_event(
new_user,
"team.member_added",
{
"team_id": str(team_id),
"team_name": team.get("name") if team else None,
"role": role,
"added_by": actor,
},
scope={"kind": "team", "id": str(team_id)},
)
except Exception:
logger.warning("member_added notify failed", exc_info=True)
def _notify_resource_shared(
actor: str | None,
team_id: str,
resource_type: str,
resource_id: str,
access_level: str,
target_user_id: str | None,
) -> None:
"""Toast the recipient(s) of a share.
Per-member share notifies just that member; whole-team share notifies every
current member except the sharer.
"""
try:
with db_readonly() as conn:
resource_name = _resource_display_name(conn, resource_type, resource_id)
team = TeamsRepository(conn).get(team_id)
if target_user_id:
recipients = [target_user_id]
else:
members = TeamMembersRepository(conn).list_members(team_id)
recipients = list({m["user_id"] for m in members})
payload = {
"resource_type": resource_type,
"resource_id": str(resource_id),
"resource_name": resource_name,
"access_level": access_level,
"team_id": str(team_id),
"team_name": team.get("name") if team else None,
"shared_by": actor,
}
for recipient in recipients:
if recipient and recipient != actor:
publish_user_event(
recipient,
"resource.shared",
payload,
scope={"kind": "resource", "id": str(resource_id)},
)
except Exception:
logger.warning("resource_shared notify failed", exc_info=True)
@teams_ns.route("/teams")
class Teams(Resource):
def get(self):
"""List the teams the caller belongs to, each annotated with their role."""
user = _current_user()
if not user:
return {"success": False}, 401
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_for_user(user)
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List teams failed: %s", err, exc_info=True)
return {"success": False}, 400
def post(self):
"""Create a team (self-serve). The creator becomes its first team_admin."""
user = _current_user()
if not user:
return {"success": False}, 401
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return {"success": False, "message": "name required"}, 400
description = data.get("description")
try:
with db_session() as conn:
teams = TeamsRepository(conn)
slug = _unique_slug(teams, _slugify(name))
team = teams.create(name, slug, owner_id=user, description=description)
TeamMembersRepository(conn).add_member(
team["id"], user, role=ROLE_TEAM_ADMIN, source="manual", granted_by=user
)
_audit(conn, user, "team.create", team_id=team["id"], name=name)
team["member_role"] = ROLE_TEAM_ADMIN
return make_response(jsonify({"success": True, "team": team}), 201)
except Exception as err:
logger.error("Create team failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>")
class Team(Resource):
@team_member_required
def get(self, team_id):
"""Team detail with members and the caller's role. Requires membership."""
user = _current_user()
try:
with db_readonly() as conn:
team = TeamsRepository(conn).get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
members = TeamMembersRepository(conn)
team["members"] = members.list_members(team_id)
team["member_role"] = members.role_for(user, team_id)
return make_response(jsonify({"success": True, "team": team}), 200)
except Exception as err:
logger.error("Get team failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_admin_required
def put(self, team_id):
"""Update team name/description. Requires team_admin."""
data = request.get_json(silent=True) or {}
fields = {k: data[k] for k in ("name", "description") if k in data}
if not fields:
return {"success": False, "message": "nothing to update"}, 400
try:
with db_session() as conn:
updated = TeamsRepository(conn).update(team_id, fields)
return make_response(jsonify({"success": bool(updated)}), 200)
except Exception as err:
logger.error("Update team failed: %s", err, exc_info=True)
return {"success": False}, 400
def delete(self, team_id):
"""Delete the team. Owner-only (a global admin overrides)."""
user = _current_user()
if not user:
return {"success": False}, 401
try:
with db_session() as conn:
team = TeamsRepository(conn).get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
token = getattr(request, "decoded_token", None)
if team["owner_id"] != user and not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Only the team owner can delete"}, 403
TeamsRepository(conn).delete(team_id)
_audit(conn, user, "team.delete", team_id=team_id)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Delete team failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/members")
class TeamMembers(Resource):
@team_member_required
def get(self, team_id):
"""List members. Requires membership."""
try:
with db_readonly() as conn:
members = TeamMembersRepository(conn).list_members(team_id)
return make_response(jsonify({"success": True, "members": members}), 200)
except Exception as err:
logger.error("List members failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_admin_required
def post(self, team_id):
"""Add a member by email (preferred) or raw user_id. Requires team_admin."""
data = request.get_json(silent=True) or {}
new_user = (data.get("user_id") or "").strip()
email = (data.get("email") or "").strip()
role = data.get("role", ROLE_TEAM_MEMBER)
if role not in _VALID_TEAM_ROLES:
return {"success": False, "message": "invalid role"}, 400
if not new_user and not email:
return {"success": False, "message": "email or user_id required"}, 400
try:
with db_session() as conn:
# Resolve an email to its sub (the user must have logged in at
# least once for their email to be on file).
if not new_user and email:
user_row = UsersRepository(conn).find_by_email(email)
if not user_row:
return {
"success": False,
"message": "No user found with that email (they must sign in once first)",
}, 404
new_user = user_row["user_id"]
TeamMembersRepository(conn).set_manual_role(
team_id, new_user, role, granted_by=_current_user()
)
_audit(
conn,
_current_user(),
"team.member_add",
team_id=team_id,
target_user=new_user,
role=role,
)
# Post-commit, best-effort: tell the new member they were added.
_notify_member_added(team_id, new_user, role, _current_user())
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Add member failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/members/<string:member_id>")
class TeamMember(Resource):
@team_admin_required
def put(self, team_id, member_id):
"""Change a member's role. Requires team_admin. Guards the last admin."""
data = request.get_json(silent=True) or {}
role = data.get("role")
if role not in _VALID_TEAM_ROLES:
return {"success": False, "message": "invalid role"}, 400
try:
with db_session() as conn:
members = TeamMembersRepository(conn)
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
members, team_id, member_id
):
return {
"success": False,
"message": "Cannot demote the last team admin",
}, 409
members.set_manual_role(team_id, member_id, role, granted_by=_current_user())
_audit(
conn,
_current_user(),
"team.member_role",
team_id=team_id,
target_user=member_id,
role=role,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Update member role failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_member_required
def delete(self, team_id, member_id):
"""Remove a member. team_admin removes anyone; a member may remove self
(leave). Guards the last admin."""
user = _current_user()
token = getattr(request, "decoded_token", None)
is_self = member_id == user
if not is_self and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_session() as conn:
members = TeamMembersRepository(conn)
if self._would_orphan_admins(members, team_id, member_id):
return {
"success": False,
"message": "Cannot remove the last team admin",
}, 409
members.remove_member(team_id, member_id)
_audit(
conn,
user,
"team.member_remove",
team_id=team_id,
target_user=member_id,
self_leave=is_self,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Remove member failed: %s", err, exc_info=True)
return {"success": False}, 400
@staticmethod
def _would_orphan_admins(
members: TeamMembersRepository, team_id: str, member_id: str
) -> bool:
"""True if removing/demoting ``member_id`` leaves the team with no admin.
Locks the admin rows (FOR UPDATE) so concurrent demote/remove calls
serialize — preventing two simultaneous removals of distinct admins from
both passing the guard and orphaning the team.
"""
admins = members.lock_admins(team_id)
if member_id not in admins:
return False
return len(admins) <= 1
@teams_ns.route("/teams/<string:team_id>/grants")
class TeamGrants(Resource):
@team_member_required
def get(self, team_id):
"""List resources shared with this team. Requires membership."""
resource_type = request.args.get("resource_type")
try:
with db_readonly() as conn:
grants = TeamResourceGrantsRepository(conn).list_for_team(
team_id, resource_type
)
return make_response(jsonify({"success": True, "grants": grants}), 200)
except Exception as err:
logger.error("List grants failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_member_required
def post(self, team_id):
"""Share a resource the caller OWNS with this team (additive visibility)."""
user = _current_user()
data = request.get_json(silent=True) or {}
resource_type = data.get("resource_type")
resource_id = data.get("resource_id")
access_level = data.get("access_level", "viewer")
# None → share with the whole team; a sub → share with that one member.
target_user_id = (data.get("target_user_id") or "").strip() or None
if (
not is_valid_resource_type(resource_type)
or not resource_id
or not looks_like_uuid(resource_id)
):
return {"success": False, "message": "invalid resource"}, 400
if access_level not in _VALID_ACCESS_LEVELS:
return {"success": False, "message": "invalid access_level"}, 400
try:
with db_session() as conn:
# Ownership is the security boundary: dispatch by resource_type so
# a mismatched type/id can't register a bogus grant.
if not owns_resource(conn, resource_type, resource_id, user):
return {"success": False, "message": "Not the resource owner"}, 403
# A per-member share target must actually be a member of the team.
if target_user_id and not TeamMembersRepository(conn).is_member(
target_user_id, team_id
):
return {"success": False, "message": "Target is not a team member"}, 400
grant = TeamResourceGrantsRepository(conn).grant(
team_id,
resource_type,
resource_id,
owner_id=user,
granted_by=user,
access_level=access_level,
target_user_id=target_user_id,
)
_audit(
conn,
user,
"team.share",
team_id=team_id,
resource_type=resource_type,
resource_id=resource_id,
access_level=access_level,
target_user_id=target_user_id,
)
# Post-commit, best-effort: tell the recipient(s) it was shared.
_notify_resource_shared(
user, team_id, resource_type, resource_id, access_level, target_user_id
)
return make_response(jsonify({"success": True, "grant": grant}), 201)
except Exception as err:
logger.error("Share resource failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_member_required
def delete(self, team_id):
"""Unshare a resource. Allowed for the resource owner or a team_admin.
Identifiers come from query params (some proxies strip DELETE bodies),
with a JSON-body fallback for older clients.
"""
user = _current_user()
token = getattr(request, "decoded_token", None)
data = request.get_json(silent=True) or {}
resource_type = request.args.get("resource_type") or data.get("resource_type")
resource_id = request.args.get("resource_id") or data.get("resource_id")
# Which grant to remove: whole-team (None) or a specific member's.
target_user_id = (
request.args.get("target_user_id") or data.get("target_user_id") or ""
).strip() or None
if (
not is_valid_resource_type(resource_type)
or not resource_id
or not looks_like_uuid(resource_id)
):
return {"success": False, "message": "invalid resource"}, 400
try:
with db_session() as conn:
is_owner = owns_resource(conn, resource_type, resource_id, user)
if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
return {"success": False, "message": "Forbidden"}, 403
revoked = TeamResourceGrantsRepository(conn).revoke(
team_id, resource_type, resource_id, target_user_id=target_user_id
)
if revoked:
_audit(
conn,
user,
"team.unshare",
team_id=team_id,
resource_type=resource_type,
resource_id=resource_id,
target_user_id=target_user_id,
)
return make_response(jsonify({"success": bool(revoked)}), 200)
except Exception as err:
logger.error("Unshare resource failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/transfer_owner")
class TeamOwnerTransfer(Resource):
def post(self, team_id):
"""Transfer team ownership. Owner-only (global admin overrides).
The new owner must already be a member; they are promoted to team_admin.
"""
user = _current_user()
if not user:
return {"success": False}, 401
data = request.get_json(silent=True) or {}
new_owner = (data.get("user_id") or "").strip()
if not new_owner:
return {"success": False, "message": "user_id required"}, 400
token = getattr(request, "decoded_token", None)
try:
with db_session() as conn:
teams = TeamsRepository(conn)
team = teams.get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
if team["owner_id"] != user and not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Only the team owner can transfer"}, 403
members = TeamMembersRepository(conn)
if not members.is_member(new_owner, team_id):
return {"success": False, "message": "New owner must be a member"}, 400
members.set_manual_role(team_id, new_owner, ROLE_TEAM_ADMIN, granted_by=user)
teams.reassign_owner(team_id, new_owner)
_audit(
conn,
user,
"team.transfer_owner",
team_id=team_id,
new_owner=new_owner,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Transfer owner failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/resource_shares")
class ResourceShares(Resource):
def get(self):
"""List the teams a resource the caller OWNS is shared with.
Powers the share dialog (show current shares + unshare). Owner-only so a
non-owner can't enumerate a resource's sharing graph.
"""
user = _current_user()
if not user:
return {"success": False}, 401
resource_type = request.args.get("resource_type")
resource_id = request.args.get("resource_id")
if (
not is_valid_resource_type(resource_type)
or not resource_id
or not looks_like_uuid(resource_id)
):
return {"success": False, "message": "invalid resource"}, 400
try:
with db_readonly() as conn:
if not owns_resource(conn, resource_type, resource_id, user):
return {"success": False, "message": "Not the resource owner"}, 403
shares = TeamResourceGrantsRepository(conn).list_for_resource(
resource_type, resource_id
)
return make_response(jsonify({"success": True, "shares": shares}), 200)
except Exception as err:
logger.error("List resource shares failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/admin/teams")
class AllTeams(Resource):
method_decorators = []
def get(self):
"""Global-admin oversight: every team with member counts."""
token = getattr(request, "decoded_token", None)
if not token:
return {"success": False}, 401
if not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_all()
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List all teams failed: %s", err, exc_info=True)
return {"success": False}, 400
+6
View File
@@ -0,0 +1,6 @@
"""Tools module."""
from .mcp import tools_mcp_ns
from .routes import tools_ns
__all__ = ["tools_ns", "tools_mcp_ns"]
+516
View File
@@ -0,0 +1,516 @@
"""Tool management MCP server integration."""
from urllib.parse import urlencode, urlparse
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import Namespace, Resource, fields
from application.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
from application.api import api
from application.api.user.tools.routes import transform_actions
from application.cache import get_redis_instance
from application.core.url_validation import SSRFError, validate_url
from application.security.encryption import decrypt_credentials, encrypt_credentials
from application.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
tools_mcp_ns = Namespace("tools", description="Tool management operations", path="/api")
_ALLOWED_TRANSPORTS = {"auto", "sse", "http"}
def _sanitize_mcp_transport(config):
"""Normalise and validate the transport_type field.
Strips ``command`` / ``args`` keys that are only valid for local STDIO
transports and returns the cleaned transport type string.
"""
transport_type = (config.get("transport_type") or "auto").lower()
if transport_type not in _ALLOWED_TRANSPORTS:
raise ValueError(f"Unsupported transport_type: {transport_type}")
config.pop("command", None)
config.pop("args", None)
config["transport_type"] = transport_type
return transport_type
def _extract_auth_credentials(config):
"""Build an ``auth_credentials`` dict from the raw MCP config."""
auth_credentials = {}
auth_type = config.get("auth_type", "none")
if auth_type == "api_key":
if config.get("api_key"):
auth_credentials["api_key"] = config["api_key"]
if config.get("api_key_header"):
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer":
if config.get("bearer_token"):
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if config.get("username"):
auth_credentials["username"] = config["username"]
if config.get("password"):
auth_credentials["password"] = config["password"]
return auth_credentials
def _validate_mcp_server_url(config: dict) -> None:
"""Validate the server_url in an MCP config to prevent SSRF.
Raises:
ValueError: If the URL is missing or points to a blocked address.
"""
server_url = (config.get("server_url") or "").strip()
if not server_url:
raise ValueError("server_url is required")
try:
validate_url(server_url)
except SSRFError as exc:
raise ValueError(f"Invalid server URL: {exc}") from exc
@tools_mcp_ns.route("/mcp_server/test")
class TestMCPServerConfig(Resource):
@api.expect(
api.model(
"MCPServerTestModel",
{
"config": fields.Raw(
required=True, description="MCP server configuration to test"
),
},
)
)
@api.doc(description="Test MCP server connection with provided configuration")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = data["config"]
try:
_sanitize_mcp_transport(config)
except ValueError:
return make_response(
jsonify({"success": False, "error": "Unsupported transport_type"}),
400,
)
_validate_mcp_server_url(config)
auth_credentials = _extract_auth_credentials(config)
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
mcp_tool = MCPTool(config=test_config, user_id=user)
result = mcp_tool.test_connection()
if result.get("requires_oauth"):
safe_result = {
k: v
for k, v in result.items()
if k in ("success", "requires_oauth", "auth_url")
}
return make_response(jsonify(safe_result), 200)
if not result.get("success"):
current_app.logger.error(
f"MCP connection test failed: {result.get('message')}"
)
return make_response(
jsonify(
{
"success": False,
"message": "Connection test failed",
"tools_count": 0,
}
),
200,
)
safe_result = {
"success": True,
"message": result.get("message", "Connection successful"),
"tools_count": result.get("tools_count", 0),
"tools": result.get("tools", []),
}
return make_response(jsonify(safe_result), 200)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server test request: {e}")
return make_response(
jsonify({"success": False, "error": "Invalid MCP server configuration"}),
400,
)
except Exception as e:
current_app.logger.error(f"Error testing MCP server: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "error": "Connection test failed"}),
500,
)
@tools_mcp_ns.route("/mcp_server/save")
class MCPServerSave(Resource):
@api.expect(
api.model(
"MCPServerSaveModel",
{
"id": fields.String(
required=False, description="Tool ID for updates (optional)"
),
"displayName": fields.String(
required=True, description="Display name for the MCP server"
),
"config": fields.Raw(
required=True, description="MCP server configuration"
),
"status": fields.Boolean(
required=False, default=True, description="Tool status"
),
},
)
)
@api.doc(description="Create or update MCP server with automatic tool discovery")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["displayName", "config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = data["config"]
try:
_sanitize_mcp_transport(config)
except ValueError:
return make_response(
jsonify({"success": False, "error": "Unsupported transport_type"}),
400,
)
_validate_mcp_server_url(config)
auth_credentials = _extract_auth_credentials(config)
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = auth_credentials
if auth_type == "oauth":
if not config.get("oauth_task_id"):
return make_response(
jsonify(
{
"success": False,
"error": "Connection not authorized. Please complete the OAuth authorization first.",
}
),
400,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(
config["oauth_task_id"], user
)
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif auth_type == "none" or auth_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=user)
mcp_tool.discover_tools()
actions_metadata = mcp_tool.get_actions_metadata()
else:
raise Exception(
"No valid credentials provided for the selected authentication type"
)
storage_config = config.copy()
tool_id = data.get("id")
existing_doc = None
existing_encrypted = None
if tool_id:
with db_readonly() as conn:
repo = UserToolsRepository(conn)
existing_doc = repo.get_any(tool_id, user)
if existing_doc and existing_doc.get("name") == "mcp_tool":
existing_encrypted = (existing_doc.get("config") or {}).get(
"encrypted_credentials"
)
else:
existing_doc = None
if auth_credentials:
if existing_encrypted:
existing_secrets = decrypt_credentials(existing_encrypted, user)
existing_secrets.update(auth_credentials)
auth_credentials = existing_secrets
storage_config["encrypted_credentials"] = encrypt_credentials(
auth_credentials, user
)
elif existing_encrypted:
storage_config["encrypted_credentials"] = existing_encrypted
for field in [
"api_key",
"bearer_token",
"username",
"password",
"api_key_header",
"redirect_uri",
]:
storage_config.pop(field, None)
transformed_actions = transform_actions(actions_metadata)
display_name = data["displayName"]
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
status_bool = bool(data.get("status", True))
with db_session() as conn:
repo = UserToolsRepository(conn)
if existing_doc:
repo.update(
str(existing_doc["id"]), user,
{
"display_name": display_name,
"custom_name": display_name,
"description": description,
"config": storage_config,
"actions": transformed_actions,
"status": status_bool,
},
)
saved_id = str(existing_doc["id"])
response_data = {
"success": True,
"id": saved_id,
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
else:
# Fall back to find_by_user_and_name — the original
# dual-write path also ran an existence check before
# deciding between insert and update.
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
if tool_id is None and existing_by_name and (
(existing_by_name.get("config") or {}).get("server_url")
== storage_config.get("server_url")
):
repo.update(
str(existing_by_name["id"]), user,
{
"display_name": display_name,
"custom_name": display_name,
"description": description,
"config": storage_config,
"actions": transformed_actions,
"status": status_bool,
},
)
saved_id = str(existing_by_name["id"])
response_data = {
"success": True,
"id": saved_id,
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
else:
created = repo.create(
user, "mcp_tool",
config=storage_config,
custom_name=display_name,
display_name=display_name,
description=description,
config_requirements={},
actions=transformed_actions,
status=status_bool,
)
saved_id = str(created["id"])
response_data = {
"success": True,
"id": saved_id,
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
if tool_id and existing_doc is None:
# Client requested update on a non-existent tool id.
return make_response(
jsonify(
{
"success": False,
"error": "Tool not found or access denied",
}
),
404,
)
return make_response(jsonify(response_data), 200)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server save request: {e}")
return make_response(
jsonify({"success": False, "error": "Invalid MCP server configuration"}),
400,
)
except Exception as e:
current_app.logger.error(f"Error saving MCP server: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "error": "Failed to save MCP server"}),
500,
)
@tools_mcp_ns.route("/mcp_server/callback")
class MCPOAuthCallback(Resource):
@api.expect(
api.model(
"MCPServerCallbackModel",
{
"code": fields.String(required=True, description="Authorization code"),
"state": fields.String(required=True, description="State parameter"),
"error": fields.String(
required=False, description="Error message (if any)"
),
},
)
)
@api.doc(
description="Handle OAuth callback by providing the authorization code and state"
)
def get(self):
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
params = {
"status": "error",
"message": f"OAuth error: {error}. Please try again and make sure to grant all requested permissions, including offline access.",
"provider": "mcp_tool",
}
return redirect(f"/api/connectors/callback-status?{urlencode(params)}")
if not code or not state:
return redirect(
"/api/connectors/callback-status?status=error&message=Authorization+code+or+state+not+provided.+Please+complete+the+authorization+process+and+make+sure+to+grant+offline+access.&provider=mcp_tool"
)
try:
redis_client = get_redis_instance()
if not redis_client:
return redirect(
"/api/connectors/callback-status?status=error&message=Internal+server+error:+Redis+not+available.&provider=mcp_tool"
)
manager = MCPOAuthManager(redis_client)
success = manager.handle_oauth_callback(state, code, error)
if success:
return redirect(
"/api/connectors/callback-status?status=success&message=Authorization+code+received+successfully.+You+can+close+this+window.&provider=mcp_tool"
)
else:
return redirect(
"/api/connectors/callback-status?status=error&message=OAuth+callback+failed.&provider=mcp_tool"
)
except Exception as e:
current_app.logger.error(
f"Error handling MCP OAuth callback: {str(e)}", exc_info=True
)
return redirect(
"/api/connectors/callback-status?status=error&message=Internal+server+error.&provider=mcp_tool"
)
@tools_mcp_ns.route("/mcp_server/auth_status")
class MCPAuthStatus(Resource):
@api.doc(
description="Batch check auth status for all MCP tools. "
"Lightweight DB-only check — no network calls to MCP servers."
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
tools_repo = UserToolsRepository(conn)
sessions_repo = ConnectorSessionsRepository(conn)
all_tools = tools_repo.list_for_user(user)
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
if not mcp_tools:
return make_response(
jsonify({"success": True, "statuses": {}}), 200
)
oauth_server_urls: dict = {}
statuses: dict = {}
for tool in mcp_tools:
tool_id = str(tool["id"])
config = tool.get("config") or {}
auth_type = config.get("auth_type", "none")
if auth_type == "oauth":
server_url = config.get("server_url", "")
if server_url:
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
oauth_server_urls[tool_id] = base_url
else:
statuses[tool_id] = "needs_auth"
else:
statuses[tool_id] = "configured"
if oauth_server_urls:
# Look up a session per distinct base URL. MCP sessions
# are stored with ``provider = "mcp:<server_url>"``
# and the URL in ``server_url``; reuse the repo's
# per-URL accessor rather than an ad-hoc $in query.
url_has_tokens: dict = {}
for base_url in set(oauth_server_urls.values()):
session = sessions_repo.get_by_user_and_server_url(
user, base_url,
)
tokens = (
(session or {}).get("session_data", {}) or {}
).get("tokens", {}) or {}
# MCP code also stashes tokens into token_info on
# the row; consider either present as "connected".
token_info = (session or {}).get("token_info") or {}
url_has_tokens[base_url] = bool(
tokens.get("access_token")
or token_info.get("access_token")
)
for tool_id, base_url in oauth_server_urls.items():
if url_has_tokens.get(base_url):
statuses[tool_id] = "connected"
else:
statuses[tool_id] = "needs_auth"
return make_response(jsonify({"success": True, "statuses": statuses}), 200)
except Exception as e:
current_app.logger.error(
"Error checking MCP auth status: %s", e, exc_info=True
)
return make_response(
jsonify({"success": False, "error": "Failed to check auth status"}),
500,
)
+995
View File
@@ -0,0 +1,995 @@
"""Tool management routes."""
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.agents.default_tools import (
builtin_agent_tools_for_management,
BUILTIN_AGENT_TOOLS,
default_tool_name_for_id,
default_tools_for_management,
is_builtin_agent_tool_id,
is_default_tool_id,
is_synthesized_tool_id,
WORKFLOW_ONLY_BUILTINS,
)
from application.agents.tools.spec_parser import parse_spec
from application.agents.tools.tool_manager import ToolManager
from application.api import api
from application.api.user.artifacts.authz import Principal, authorize_artifact
from application.api.user.team_sharing import effective_write_owner, visible_with_access
from application.core.url_validation import SSRFError, validate_url
from application.security.encryption import decrypt_credentials, encrypt_credentials
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.artifacts import ArtifactsRepository
from application.storage.db.repositories.notes import NotesRepository
from application.storage.db.repositories.todos import TodosRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields, validate_function_name
tool_config = {}
tool_manager = ToolManager(config=tool_config)
# ---------------------------------------------------------------------------
# Shape translation helpers
# ---------------------------------------------------------------------------
# The frontend speaks camelCase (``displayName`` / ``customName`` /
# ``configRequirements``). The PG ``user_tools`` table stores snake_case
# (``display_name`` / ``custom_name`` / ``config_requirements``). Keep the
# translation localized to this module so repositories stay pure.
_CAMEL_TO_SNAKE = {
"displayName": "display_name",
"customName": "custom_name",
"configRequirements": "config_requirements",
}
_SNAKE_TO_CAMEL = {v: k for k, v in _CAMEL_TO_SNAKE.items()}
def _row_to_api(row: dict) -> dict:
"""Rename DB-native snake_case keys to the camelCase shape the frontend expects."""
out = dict(row)
for snake, camel in _SNAKE_TO_CAMEL.items():
if snake in out:
out[camel] = out.pop(snake)
# ``user_id`` is exposed as ``user`` in the legacy API shape.
if "user_id" in out:
out["user"] = out.pop("user_id")
return out
def _api_to_update_fields(data: dict) -> dict:
"""Rename incoming camelCase update keys to the repo's snake_case columns."""
fields_out: dict = {}
for key, value in data.items():
fields_out[_CAMEL_TO_SNAKE.get(key, key)] = value
return fields_out
def _encrypt_secret_fields(config, config_requirements, user_id):
secret_keys = [
key for key, spec in config_requirements.items()
if spec.get("secret") and key in config and config[key]
]
if not secret_keys:
return config
storage_config = config.copy()
secret_values = {k: config[k] for k in secret_keys}
storage_config["encrypted_credentials"] = encrypt_credentials(secret_values, user_id)
for key in secret_keys:
storage_config.pop(key, None)
return storage_config
def _validate_config(config, config_requirements, has_existing_secrets=False):
errors = {}
for key, spec in config_requirements.items():
depends_on = spec.get("depends_on")
if depends_on:
if not all(config.get(dk) == dv for dk, dv in depends_on.items()):
continue
if spec.get("required") and not config.get(key):
if has_existing_secrets and spec.get("secret"):
continue
errors[key] = f"{spec.get('label', key)} is required"
value = config.get(key)
if value is not None and value != "":
if spec.get("type") == "number":
try:
num = float(value)
if key == "timeout" and (num < 1 or num > 300):
errors[key] = "Timeout must be between 1 and 300"
except (ValueError, TypeError):
errors[key] = f"{spec.get('label', key)} must be a number"
if spec.get("enum") and value not in spec["enum"]:
errors[key] = f"Invalid value for {spec.get('label', key)}"
return errors
def _merge_secrets_on_update(new_config, existing_config, config_requirements, user_id):
"""Merge incoming config with existing encrypted secrets and re-encrypt.
For updates, the client may omit unchanged secret values. This helper
decrypts any previously stored secrets, overlays whatever the client *did*
send, strips plain-text secrets from the stored config, and re-encrypts
the merged result.
Returns the final ``config`` dict ready for persistence.
"""
secret_keys = [
key for key, spec in config_requirements.items()
if spec.get("secret")
]
if not secret_keys:
return new_config
existing_secrets = {}
if "encrypted_credentials" in existing_config:
existing_secrets = decrypt_credentials(
existing_config["encrypted_credentials"], user_id
)
merged_secrets = existing_secrets.copy()
for key in secret_keys:
if key in new_config and new_config[key]:
merged_secrets[key] = new_config[key]
# Start from existing non-secret values, then overlay incoming non-secrets
storage_config = {
k: v for k, v in existing_config.items()
if k not in secret_keys and k != "encrypted_credentials"
}
storage_config.update(
{k: v for k, v in new_config.items() if k not in secret_keys}
)
if merged_secrets:
storage_config["encrypted_credentials"] = encrypt_credentials(
merged_secrets, user_id
)
else:
storage_config.pop("encrypted_credentials", None)
storage_config.pop("has_encrypted_credentials", None)
return storage_config
def transform_actions(actions_metadata):
"""Set default flags on action metadata for storage.
Marks each action as active, sets ``filled_by_llm`` and ``value`` on every
parameter property. Used by both the generic create_tool and MCP save routes.
"""
transformed = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
props = action["parameters"].get("properties", {})
for param_details in props.values():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed.append(action)
return transformed
tools_ns = Namespace("tools", description="Tool management operations", path="/api")
@tools_ns.route("/available_tools")
class AvailableTools(Resource):
@api.doc(description="Get available tools for a user")
def get(self):
if not request.decoded_token:
return make_response(jsonify({"success": False}), 401)
try:
tools_metadata = []
for tool_name, tool_instance in tool_manager.tools.items():
doc = tool_instance.__doc__.strip()
lines = doc.split("\n", 1)
name = lines[0].strip()
description = lines[1].strip() if len(lines) > 1 else ""
config_req = tool_instance.get_config_requirements()
actions = tool_instance.get_actions_metadata()
tools_metadata.append(
{
"name": tool_name,
"displayName": name,
"description": description,
"configRequirements": config_req,
"actions": actions,
}
)
except Exception as err:
current_app.logger.error(
f"Error getting available tools: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "data": tools_metadata}), 200)
@tools_ns.route("/get_tools")
class GetTools(Resource):
@api.doc(description="Get tools created by a user")
def get(self):
try:
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
with db_readonly() as conn:
tools_repo = UserToolsRepository(conn)
rows = tools_repo.list_for_user(user)
user_doc = UsersRepository(conn).get(user)
owned_ids = {str(r["id"]) for r in rows}
# Tools shared with the caller's teams. Secrets are stripped
# unconditionally below — a grantee never sees the owner's
# credentials (they only run with the owner's creds server-side).
team_shared = visible_with_access(conn, user, "tool")
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
shared_rows = tools_repo.list_by_ids(shared_ids)
user_tools = []
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
tool_copy = _row_to_api(row)
config_req = tool_copy.get("configRequirements", {})
if not config_req:
tool_instance = tool_manager.tools.get(tool_copy.get("name"))
if tool_instance:
config_req = tool_instance.get_config_requirements()
tool_copy["configRequirements"] = config_req
has_secrets = any(
spec.get("secret") for spec in config_req.values()
) if config_req else False
if (has_secrets or force_strip_secret) and "encrypted_credentials" in tool_copy.get(
"config", {}
):
tool_copy["config"]["has_encrypted_credentials"] = True
tool_copy["config"].pop("encrypted_credentials", None)
tool_copy["ownership"] = ownership
return tool_copy
for row in rows:
user_tools.append(_shape_tool(row))
for row in shared_rows:
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
shaped["team_access"] = team_shared.get(str(row["id"]))
user_tools.append(shaped)
# ``scheduler`` is dual-registered (default chat tool + agent-
# selectable builtin) and resolves to the same synthetic uuid5 id.
# Surface a single row with both flags so the frontend can show it
# in the management page (toggle) and the agent picker.
seen_ids: set = set()
for default_row in default_tools_for_management(user_doc):
default_copy = _row_to_api(default_row)
default_copy["default"] = True
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
default_copy["builtin"] = True
seen_ids.add(str(default_copy["id"]))
user_tools.append(default_copy)
# Builtins (e.g. scheduler) hidden from Add-Tool catalog, visible
# to the agent picker. Skip ones already added via the default
# path — both registries share ``_DEFAULT_TOOL_NAMESPACE``.
# ``workflow_only`` builtins (e.g. ``read_document``) carry that
# flag so the classic picker can hide them and the workflow node
# picker can keep them.
for builtin_row in builtin_agent_tools_for_management():
builtin_copy = _row_to_api(builtin_row)
if str(builtin_copy["id"]) in seen_ids:
continue
builtin_copy["builtin"] = True
builtin_copy["default"] = False
builtin_copy["workflow_only"] = (
builtin_copy.get("name") in WORKFLOW_ONLY_BUILTINS
)
user_tools.append(builtin_copy)
except Exception as err:
current_app.logger.error(f"Error getting user tools: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "tools": user_tools}), 200)
@tools_ns.route("/create_tool")
class CreateTool(Resource):
@api.expect(
api.model(
"CreateToolModel",
{
"name": fields.String(required=True, description="Name of the tool"),
"displayName": fields.String(
required=True, description="Display name for the tool"
),
"description": fields.String(
required=True, description="Tool description"
),
"config": fields.Raw(
required=True, description="Configuration of the tool"
),
"customName": fields.String(
required=False, description="Custom name for the tool"
),
"status": fields.Boolean(
required=True, description="Status of the tool"
),
},
)
)
@api.doc(description="Create a new tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = [
"name",
"displayName",
"description",
"config",
"status",
]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
if data["name"] == "mcp_tool":
server_url = (data.get("config", {}).get("server_url") or "").strip()
if server_url:
try:
validate_url(server_url)
except SSRFError:
return make_response(
jsonify({"success": False, "message": "Invalid server URL"}),
400,
)
tool_instance = tool_manager.tools.get(data["name"])
if not tool_instance:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404
)
actions_metadata = tool_instance.get_actions_metadata()
transformed_actions = transform_actions(actions_metadata)
except Exception as err:
current_app.logger.error(
f"Error getting tool actions: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
try:
config_requirements = tool_instance.get_config_requirements()
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements
)
if validation_errors:
return make_response(
jsonify(
{
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}
),
400,
)
storage_config = _encrypt_secret_fields(
data["config"], config_requirements, user
)
with db_session() as conn:
created = UserToolsRepository(conn).create(
user,
data["name"],
config=storage_config,
custom_name=data.get("customName", ""),
display_name=data["displayName"],
description=data["description"],
config_requirements=config_requirements,
actions=transformed_actions,
status=bool(data.get("status", True)),
)
new_id = str(created["id"])
except Exception as err:
current_app.logger.error(f"Error creating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id}), 200)
@tools_ns.route("/update_tool")
class UpdateTool(Resource):
@api.expect(
api.model(
"UpdateToolModel",
{
"id": fields.String(required=True, description="Tool ID"),
"name": fields.String(description="Name of the tool"),
"displayName": fields.String(description="Display name for the tool"),
"customName": fields.String(description="Custom name for the tool"),
"description": fields.String(description="Tool description"),
"config": fields.Raw(description="Configuration of the tool"),
"actions": fields.List(
fields.Raw, description="Actions the tool can perform"
),
"status": fields.Boolean(description="Status of the tool"),
},
)
)
@api.doc(description="Update a tool by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
# Default-tool branch first: a dual-registered tool (e.g. ``scheduler``)
# matches BOTH ``is_default_tool_id`` and ``is_builtin_agent_tool_id``.
# The toggle in Tools settings is the per-user opt-out for the
# agentless default — it must reach the ``set_default_tool_enabled``
# path, not the builtin "not editable" reject.
if is_default_tool_id(data["id"]):
if "status" not in data:
return make_response(
jsonify(
{
"success": False,
"message": "Default tools are not editable; "
"only their on/off status can be changed.",
}
),
400,
)
tool_name = default_tool_name_for_id(data["id"])
try:
with db_session() as conn:
UsersRepository(conn).set_default_tool_enabled(
user, tool_name, bool(data["status"])
)
except Exception as err:
current_app.logger.error(
f"Error updating default tool: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
if is_builtin_agent_tool_id(data["id"]):
return make_response(
jsonify(
{
"success": False,
"message": "Built-in agent tools are not editable; "
"add them to an agent via the agent picker.",
}
),
400,
)
try:
update_data: dict = {}
for key in ("name", "displayName", "customName", "description", "actions"):
if key in data:
update_data[key] = data[key]
if "config" in data:
if "actions" in data["config"]:
for action_name in list(data["config"]["actions"].keys()):
if not validate_function_name(action_name):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
"param": "tools[].function.name",
}
),
400,
)
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
tool_name = tool_doc.get("name", data.get("name"))
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
tool_instance.get_config_requirements()
if tool_instance
else {}
)
existing_config = tool_doc.get("config", {}) or {}
has_existing_secrets = "encrypted_credentials" in existing_config
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
has_existing_secrets=has_existing_secrets,
)
if validation_errors:
return make_response(
jsonify({
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}),
400,
)
update_data["config"] = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
)
if "status" in data:
update_data["status"] = bool(data["status"])
repo.update(
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
)
else:
if "status" in data:
update_data["status"] = bool(data["status"])
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
repo.update(
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
)
except Exception as err:
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_config")
class UpdateToolConfig(Resource):
@api.expect(
api.model(
"UpdateToolConfigModel",
{
"id": fields.String(required=True, description="Tool ID"),
"config": fields.Raw(
required=True, description="Configuration of the tool"
),
},
)
)
@api.doc(description="Update the configuration of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
if is_synthesized_tool_id(data["id"]):
return make_response(
jsonify(
{
"success": False,
"message": "Default and built-in tools are config-free "
"and cannot be configured.",
}
),
400,
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(jsonify({"success": False}), 404)
tool_name = tool_doc.get("name")
if tool_name == "mcp_tool":
server_url = (data["config"].get("server_url") or "").strip()
if server_url:
try:
validate_url(server_url)
except SSRFError:
return make_response(
jsonify({"success": False, "message": "Invalid server URL"}),
400,
)
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
tool_instance.get_config_requirements() if tool_instance else {}
)
existing_config = tool_doc.get("config", {}) or {}
has_existing_secrets = "encrypted_credentials" in existing_config
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
has_existing_secrets=has_existing_secrets,
)
if validation_errors:
return make_response(
jsonify({
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}),
400,
)
final_config = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
)
repo.update(str(tool_doc["id"]), user, {"config": final_config})
except Exception as err:
current_app.logger.error(
f"Error updating tool config: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_actions")
class UpdateToolActions(Resource):
@api.expect(
api.model(
"UpdateToolActionsModel",
{
"id": fields.String(required=True, description="Tool ID"),
"actions": fields.List(
fields.Raw,
required=True,
description="Actions the tool can perform",
),
},
)
)
@api.doc(description="Update the actions of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "actions"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
if is_synthesized_tool_id(data["id"]):
return make_response(
jsonify(
{
"success": False,
"message": "Default and built-in tools' actions are not editable.",
}
),
400,
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if tool_doc:
repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
else:
# Team editor write path (secrets stay owner-only — actions
# carry no credentials, so editing them is safe).
owner = effective_write_owner(conn, "tool", data["id"], user)
if not owner:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
repo.update(data["id"], owner, {"actions": data["actions"]})
except Exception as err:
current_app.logger.error(
f"Error updating tool actions: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_status")
class UpdateToolStatus(Resource):
@api.expect(
api.model(
"UpdateToolStatusModel",
{
"id": fields.String(required=True, description="Tool ID"),
"status": fields.Boolean(
required=True, description="Status of the tool"
),
},
)
)
@api.doc(description="Update the status of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "status"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
# Default branch first so a dual-registered id (e.g. ``scheduler``)
# writes the per-user opt-out instead of being rejected as a
# not-editable builtin (both predicates match the same uuid5).
if is_default_tool_id(data["id"]):
tool_name = default_tool_name_for_id(data["id"])
with db_session() as conn:
UsersRepository(conn).set_default_tool_enabled(
user, tool_name, bool(data["status"])
)
return make_response(jsonify({"success": True}), 200)
if is_builtin_agent_tool_id(data["id"]):
return make_response(
jsonify(
{
"success": False,
"message": "Built-in agent tools have no per-user "
"toggle; add them to an agent via the agent picker.",
}
),
400,
)
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
repo.update(
str(tool_doc["id"]), user, {"status": bool(data["status"])},
)
except Exception as err:
current_app.logger.error(
f"Error updating tool status: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/delete_tool")
class DeleteTool(Resource):
@api.expect(
api.model(
"DeleteToolModel",
{"id": fields.String(required=True, description="Tool ID")},
)
)
@api.doc(description="Delete a tool by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
if is_synthesized_tool_id(data["id"]):
return make_response(
jsonify(
{
"success": False,
"message": "Built-in tools cannot be deleted; disable them instead.",
}
),
400,
)
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
tool_doc = repo.get_any(data["id"], user)
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404
)
repo.delete(str(tool_doc["id"]), user)
except Exception as err:
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/parse_spec")
class ParseSpec(Resource):
@api.doc(
description="Parse an API specification (OpenAPI 3.x or Swagger 2.0) and return actions"
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
if "file" in request.files:
file = request.files["file"]
if not file.filename:
return make_response(
jsonify({"success": False, "message": "No file selected"}), 400
)
try:
spec_content = file.read().decode("utf-8")
except UnicodeDecodeError:
return make_response(
jsonify({"success": False, "message": "Invalid file encoding"}), 400
)
elif request.is_json:
data = request.get_json()
spec_content = data.get("spec_content", "")
else:
return make_response(
jsonify({"success": False, "message": "No spec provided"}), 400
)
if not spec_content or not spec_content.strip():
return make_response(
jsonify({"success": False, "message": "Empty spec content"}), 400
)
try:
metadata, actions = parse_spec(spec_content)
return make_response(
jsonify(
{
"success": True,
"metadata": metadata,
"actions": actions,
}
),
200,
)
except ValueError as e:
current_app.logger.error(f"Spec validation error: {e}")
return make_response(jsonify({"success": False, "error": "Invalid specification format"}), 400)
except Exception as err:
current_app.logger.error(f"Error parsing spec: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to parse specification"}), 500)
@tools_ns.route("/artifact/<artifact_id>")
class GetArtifact(Resource):
@api.doc(description="Get artifact data by artifact ID. Returns all todos for the tool when fetching a todo artifact.")
def get(self, artifact_id: str):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
with db_readonly() as conn:
notes_repo = NotesRepository(conn)
todos_repo = TodosRepository(conn)
# Artifact IDs may be PG UUIDs (post-cutover) or legacy
# Mongo ObjectIds embedded in older conversation history.
# Both repos' ``get_any`` handles the id-shape branching
# internally so a non-UUID input never reaches
# ``CAST(:id AS uuid)`` (which would poison the readonly
# transaction and break the fallback below).
note_doc = notes_repo.get_any(artifact_id, user_id)
if note_doc:
content = note_doc.get("note", "") or note_doc.get("content", "")
line_count = len(content.split("\n")) if content else 0
updated = note_doc.get("updated_at")
artifact = {
"artifact_type": "note",
"data": {
"content": content,
"line_count": line_count,
"updated_at": (
updated.isoformat()
if hasattr(updated, "isoformat")
else updated
),
},
}
return make_response(
jsonify({"success": True, "artifact": artifact}), 200
)
todo_doc = todos_repo.get_any(artifact_id, user_id)
if todo_doc:
tool_id = todo_doc.get("tool_id")
all_todos = todos_repo.list_for_tool(user_id, tool_id) if tool_id else []
items = []
open_count = 0
completed_count = 0
for t in all_todos:
# PG ``todos`` stores a ``completed BOOLEAN`` column;
# the legacy Mongo shape used a ``status`` string.
# Keep the response shape stable by translating here.
status = "completed" if t.get("completed") else "open"
if status == "open":
open_count += 1
else:
completed_count += 1
created = t.get("created_at")
updated = t.get("updated_at")
items.append({
"todo_id": t.get("todo_id"),
"title": t.get("title", ""),
"status": status,
"created_at": (
created.isoformat()
if hasattr(created, "isoformat")
else created
),
"updated_at": (
updated.isoformat()
if hasattr(updated, "isoformat")
else updated
),
})
artifact = {
"artifact_type": "todo_list",
"data": {
"items": items,
"total_count": len(items),
"open_count": open_count,
"completed_count": completed_count,
},
}
return make_response(
jsonify({"success": True, "artifact": artifact}), 200
)
# Generalized document/file artifacts live in the
# ``artifacts`` store, authorized by their parent (not user_id).
# Shape-gate the UUID lookup so a non-UUID id (legacy note/todo
# ObjectId already handled above) never reaches the CAST that
# would poison this read-only transaction.
artifact_doc = None
if looks_like_uuid(artifact_id):
artifacts_repo = ArtifactsRepository(conn)
artifact_doc = artifacts_repo.get_artifact(artifact_id)
if artifact_doc and authorize_artifact(
conn, artifact_doc, Principal(user_id=user_id)
):
current = artifacts_repo.get_version(
artifact_id, artifact_doc.get("current_version")
)
artifact = {
"artifact_type": (
"document"
if artifact_doc.get("kind") != "file"
else "file"
),
"data": {
"id": str(artifact_doc.get("id")),
"kind": artifact_doc.get("kind"),
"title": artifact_doc.get("title"),
"current_version": artifact_doc.get("current_version"),
"mime_type": current.get("mime_type") if current else None,
"filename": current.get("filename") if current else None,
"size": current.get("size") if current else None,
"download_url": (
f"/api/artifacts/{artifact_id}/download"
if current and current.get("storage_path")
else None
),
},
}
return make_response(
jsonify({"success": True, "artifact": artifact}), 200
)
except Exception as err:
current_app.logger.error(
f"Error retrieving artifact: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify({"success": False, "message": "Artifact not found"}), 404
)
+75
View File
@@ -0,0 +1,75 @@
"""Centralized utilities for API routes.
Post-Mongo-cutover slim: the old Mongo-shaped helpers (``validate_object_id``,
``check_resource_ownership``, ``paginated_response``, ``serialize_object_id``,
``safe_db_operation``, ``validate_enum``, ``extract_sort_params``) have been
removed — they carried ``bson`` / ``pymongo`` imports and had zero callers.
"""
from functools import wraps
from typing import Callable, Optional
from flask import (
Response,
jsonify,
make_response,
request,
)
def get_user_id() -> Optional[str]:
"""Extract user ID from decoded JWT token, or None if unauthenticated."""
decoded_token = getattr(request, "decoded_token", None)
return decoded_token.get("sub") if decoded_token else None
def require_auth(func: Callable) -> Callable:
"""Decorator to require authentication. Returns 401 when absent."""
@wraps(func)
def wrapper(*args, **kwargs):
user_id = get_user_id()
if not user_id:
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
return func(*args, **kwargs)
return wrapper
def success_response(
data=None, message: Optional[str] = None, status: int = 200
) -> Response:
"""Shape a successful JSON response."""
body = {"success": True}
if data is not None:
body["data"] = data
if message is not None:
body["message"] = message
return make_response(jsonify(body), status)
def error_response(message: str, status: int = 400, **kwargs) -> Response:
"""Shape an error JSON response; any kwargs are merged into the body."""
body = {"success": False, "error": message, **kwargs}
return make_response(jsonify(body), status)
def require_fields(required: list) -> Callable:
"""Decorator: return 400 if any listed field is missing/falsy in the JSON body."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
data = request.get_json()
if not data:
return error_response("Request body required")
missing = [field for field in required if not data.get(field)]
if missing:
return error_response(
f"Missing required fields: {', '.join(missing)}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@@ -0,0 +1,3 @@
from .routes import workflows_ns
__all__ = ["workflows_ns"]
+532
View File
@@ -0,0 +1,532 @@
"""Workflow management routes."""
from typing import Any, Dict, List, Optional, Set
from flask import current_app, request
from flask_restx import Namespace, Resource
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
from application.storage.db.repositories.workflows import WorkflowsRepository
from application.storage.db.session import db_readonly, db_session
from application.core.json_schema_utils import (
JsonSchemaValidationError,
normalize_json_schema_payload,
)
from application.core.model_utils import get_model_capabilities
from application.api.user.utils import (
error_response,
get_user_id,
require_auth,
require_fields,
success_response,
)
workflows_ns = Namespace("workflows", path="/api")
def _workflow_error_response(message: str, err: Exception):
current_app.logger.error(f"{message}: {err}", exc_info=True)
return error_response(message)
def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str):
"""Resolve a workflow by UUID or legacy Mongo id, scoped to user."""
if not workflow_id:
return None
if looks_like_uuid(workflow_id):
row = repo.get(workflow_id, user_id)
if row is not None:
return row
return repo.get_by_legacy_id(workflow_id, user_id)
def _write_graph(
conn,
pg_workflow_id: str,
graph_version: int,
nodes_data: List[Dict],
edges_data: List[Dict],
) -> List[Dict]:
"""Bulk-create nodes + edges for one graph version. Uses ON CONFLICT upsert.
Edges arrive with source/target as user-provided node-id strings. We
insert nodes first, capture their ``node_id → UUID`` map, then
translate edges before insertion. Edges referencing missing nodes are
dropped with a warning.
"""
nodes_repo = WorkflowNodesRepository(conn)
edges_repo = WorkflowEdgesRepository(conn)
if nodes_data:
created_nodes = nodes_repo.bulk_create(
pg_workflow_id, graph_version,
[
{
"node_id": n["id"],
"node_type": n["type"],
"title": n.get("title", ""),
"description": n.get("description", ""),
"position": n.get("position", {"x": 0, "y": 0}),
"config": n.get("data", {}),
}
for n in nodes_data
],
)
node_uuid_by_str = {n["node_id"]: n["id"] for n in created_nodes}
else:
created_nodes = []
node_uuid_by_str = {}
if edges_data:
translated_edges: List[Dict] = []
for e in edges_data:
src = e.get("source")
tgt = e.get("target")
from_uuid = node_uuid_by_str.get(src)
to_uuid = node_uuid_by_str.get(tgt)
if not from_uuid or not to_uuid:
current_app.logger.warning(
"Workflow graph write: dropping edge %s; node refs unresolved "
"(source=%s, target=%s)",
e.get("id"), src, tgt,
)
continue
translated_edges.append({
"edge_id": e["id"],
"from_node_id": from_uuid,
"to_node_id": to_uuid,
"source_handle": e.get("sourceHandle"),
"target_handle": e.get("targetHandle"),
})
if translated_edges:
edges_repo.bulk_create(
pg_workflow_id, graph_version, translated_edges,
)
return created_nodes
def serialize_workflow(w: Dict) -> Dict:
"""Serialize workflow row to API response format."""
created_at = w.get("created_at")
updated_at = w.get("updated_at")
return {
"id": str(w["id"]),
"name": w.get("name"),
"description": w.get("description"),
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at,
"updated_at": updated_at.isoformat() if hasattr(updated_at, "isoformat") else updated_at,
}
def serialize_node(n: Dict) -> Dict:
"""Serialize workflow node row to API response format."""
return {
"id": n["node_id"],
"type": n["node_type"],
"title": n.get("title"),
"description": n.get("description"),
"position": n.get("position"),
"data": n.get("config", {}) or {},
}
def serialize_edge(e: Dict) -> Dict:
"""Serialize workflow edge row to API response format."""
return {
"id": e["edge_id"],
"source": e.get("source_id"),
"target": e.get("target_id"),
"sourceHandle": e.get("source_handle"),
"targetHandle": e.get("target_handle"),
}
def get_workflow_graph_version(workflow: Dict) -> int:
"""Get current graph version with fallback."""
raw_version = workflow.get("current_graph_version", 1)
try:
version = int(raw_version)
return version if version > 0 else 1
except (ValueError, TypeError):
return 1
def validate_json_schema_payload(
json_schema: Any,
) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Validate and normalize optional JSON schema payload for structured output."""
if json_schema is None:
return None, None
try:
return normalize_json_schema_payload(json_schema), None
except JsonSchemaValidationError as exc:
return None, str(exc)
def normalize_agent_node_json_schemas(nodes: List[Dict]) -> List[Dict]:
"""Normalize agent-node JSON schema payloads before persistence."""
normalized_nodes: List[Dict] = []
for node in nodes:
if not isinstance(node, dict):
normalized_nodes.append(node)
continue
normalized_node = dict(node)
if normalized_node.get("type") != "agent":
normalized_nodes.append(normalized_node)
continue
raw_config = normalized_node.get("data")
if not isinstance(raw_config, dict) or "json_schema" not in raw_config:
normalized_nodes.append(normalized_node)
continue
normalized_config = dict(raw_config)
try:
normalized_config["json_schema"] = normalize_json_schema_payload(
raw_config.get("json_schema")
)
except JsonSchemaValidationError:
# Validation runs before normalization; keep original on unexpected shape.
normalized_config["json_schema"] = raw_config.get("json_schema")
normalized_node["data"] = normalized_config
normalized_nodes.append(normalized_node)
return normalized_nodes
def validate_workflow_structure(
nodes: List[Dict], edges: List[Dict], user_id: str | None = None
) -> List[str]:
"""Validate workflow graph structure.
``user_id`` is required so per-user BYOM custom-model UUIDs resolve
when checking each agent node's structured-output capability.
"""
errors = []
if not nodes:
errors.append("Workflow must have at least one node")
return errors
start_nodes = [n for n in nodes if n.get("type") == "start"]
if len(start_nodes) != 1:
errors.append("Workflow must have exactly one start node")
end_nodes = [n for n in nodes if n.get("type") == "end"]
if not end_nodes:
errors.append("Workflow must have at least one end node")
node_ids = {n.get("id") for n in nodes}
node_map = {n.get("id"): n for n in nodes}
end_ids = {n.get("id") for n in end_nodes}
for edge in edges:
source_id = edge.get("source")
target_id = edge.get("target")
if source_id not in node_ids:
errors.append(f"Edge references non-existent source: {source_id}")
if target_id not in node_ids:
errors.append(f"Edge references non-existent target: {target_id}")
if start_nodes:
start_id = start_nodes[0].get("id")
if not any(e.get("source") == start_id for e in edges):
errors.append("Start node must have at least one outgoing edge")
condition_nodes = [n for n in nodes if n.get("type") == "condition"]
for cnode in condition_nodes:
cnode_id = cnode.get("id")
cnode_title = cnode.get("title", cnode_id)
outgoing = [e for e in edges if e.get("source") == cnode_id]
if len(outgoing) < 2:
errors.append(
f"Condition node '{cnode_title}' must have at least 2 outgoing edges"
)
node_data = cnode.get("data", {}) or {}
cases = node_data.get("cases", [])
if not isinstance(cases, list):
cases = []
if not cases or not any(
isinstance(c, dict) and str(c.get("expression", "")).strip() for c in cases
):
errors.append(
f"Condition node '{cnode_title}' must have at least one case with an expression"
)
case_handles: Set[str] = set()
duplicate_case_handles: Set[str] = set()
for case in cases:
if not isinstance(case, dict):
continue
raw_handle = case.get("sourceHandle", "")
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
if not handle:
errors.append(
f"Condition node '{cnode_title}' has a case without a branch handle"
)
continue
if handle in case_handles:
duplicate_case_handles.add(handle)
case_handles.add(handle)
for handle in duplicate_case_handles:
errors.append(
f"Condition node '{cnode_title}' has duplicate case handle '{handle}'"
)
outgoing_by_handle: Dict[str, List[Dict]] = {}
for out_edge in outgoing:
raw_handle = out_edge.get("sourceHandle", "")
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
outgoing_by_handle.setdefault(handle, []).append(out_edge)
for handle, handle_edges in outgoing_by_handle.items():
if not handle:
errors.append(
f"Condition node '{cnode_title}' has an outgoing edge without sourceHandle"
)
continue
if handle != "else" and handle not in case_handles:
errors.append(
f"Condition node '{cnode_title}' has a connection from unknown branch '{handle}'"
)
if len(handle_edges) > 1:
errors.append(
f"Condition node '{cnode_title}' has multiple outgoing edges from branch '{handle}'"
)
if "else" not in outgoing_by_handle:
errors.append(f"Condition node '{cnode_title}' must have an 'else' branch")
for case in cases:
if not isinstance(case, dict):
continue
raw_handle = case.get("sourceHandle", "")
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
if not handle:
continue
raw_expression = case.get("expression", "")
has_expression = isinstance(raw_expression, str) and bool(
raw_expression.strip()
)
has_outgoing = bool(outgoing_by_handle.get(handle))
if has_expression and not has_outgoing:
errors.append(
f"Condition node '{cnode_title}' case '{handle}' has an expression but no outgoing edge"
)
if not has_expression and has_outgoing:
errors.append(
f"Condition node '{cnode_title}' case '{handle}' has an outgoing edge but no expression"
)
for handle, handle_edges in outgoing_by_handle.items():
if not handle:
continue
for out_edge in handle_edges:
target = out_edge.get("target")
if target and not _can_reach_end(target, edges, node_map, end_ids):
errors.append(
f"Branch '{handle}' of condition '{cnode_title}' "
f"must eventually reach an end node"
)
agent_nodes = [n for n in nodes if n.get("type") == "agent"]
for agent_node in agent_nodes:
agent_title = agent_node.get("title", agent_node.get("id", "unknown"))
raw_config = agent_node.get("data", {}) or {}
if not isinstance(raw_config, dict):
errors.append(f"Agent node '{agent_title}' has invalid configuration")
continue
normalized_schema, schema_error = validate_json_schema_payload(
raw_config.get("json_schema")
)
has_json_schema = normalized_schema is not None
model_id = raw_config.get("model_id")
if has_json_schema and isinstance(model_id, str) and model_id.strip():
capabilities = get_model_capabilities(model_id.strip(), user_id=user_id)
if capabilities and not capabilities.get("supports_structured_output", False):
errors.append(
f"Agent node '{agent_title}' selected model does not support structured output"
)
if schema_error:
errors.append(f"Agent node '{agent_title}' JSON schema {schema_error}")
code_nodes = [n for n in nodes if n.get("type") == "code"]
for code_node in code_nodes:
code_title = code_node.get("title", code_node.get("id", "unknown"))
raw_config = code_node.get("data", {}) or {}
if not isinstance(raw_config, dict):
errors.append(f"Code node '{code_title}' has invalid configuration")
continue
if not str(raw_config.get("code", "")).strip():
errors.append(f"Code node '{code_title}' must have code to execute")
_, schema_error = validate_json_schema_payload(raw_config.get("json_schema"))
if schema_error:
errors.append(f"Code node '{code_title}' JSON schema {schema_error}")
for node in nodes:
if not node.get("id"):
errors.append("All nodes must have an id")
if not node.get("type"):
errors.append(f"Node {node.get('id', 'unknown')} must have a type")
return errors
def _can_reach_end(
node_id: str, edges: List[Dict], node_map: Dict, end_ids: set, visited: set = None
) -> bool:
if visited is None:
visited = set()
if node_id in end_ids:
return True
if node_id in visited or node_id not in node_map:
return False
visited.add(node_id)
outgoing = [e.get("target") for e in edges if e.get("source") == node_id]
return any(_can_reach_end(t, edges, node_map, end_ids, visited) for t in outgoing if t)
@workflows_ns.route("/workflows")
class WorkflowList(Resource):
@require_auth
@require_fields(["name"])
def post(self):
"""Create a new workflow with nodes and edges."""
user_id = get_user_id()
data = request.get_json()
name = data.get("name", "").strip()
description = data.get("description", "")
nodes_data = data.get("nodes", [])
edges_data = data.get("edges", [])
validation_errors = validate_workflow_structure(
nodes_data, edges_data, user_id=user_id
)
if validation_errors:
return error_response(
"Workflow validation failed", errors=validation_errors
)
nodes_data = normalize_agent_node_json_schemas(nodes_data)
try:
with db_session() as conn:
repo = WorkflowsRepository(conn)
workflow = repo.create(user_id, name, description=description)
pg_workflow_id = str(workflow["id"])
_write_graph(conn, pg_workflow_id, 1, nodes_data, edges_data)
except Exception as err:
return _workflow_error_response("Failed to create workflow", err)
return success_response({"id": pg_workflow_id}, 201)
@workflows_ns.route("/workflows/<string:workflow_id>")
class WorkflowDetail(Resource):
@require_auth
def get(self, workflow_id: str):
"""Get workflow details with nodes and edges."""
user_id = get_user_id()
try:
with db_readonly() as conn:
repo = WorkflowsRepository(conn)
workflow = _resolve_workflow(repo, workflow_id, user_id)
if workflow is None:
return error_response("Workflow not found", 404)
pg_workflow_id = str(workflow["id"])
graph_version = get_workflow_graph_version(workflow)
nodes = WorkflowNodesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
return success_response(
{
"workflow": serialize_workflow(workflow),
"nodes": [serialize_node(n) for n in nodes],
"edges": [serialize_edge(e) for e in edges],
}
)
@require_auth
@require_fields(["name"])
def put(self, workflow_id: str):
"""Update workflow and replace nodes/edges."""
user_id = get_user_id()
data = request.get_json()
name = data.get("name", "").strip()
description = data.get("description", "")
nodes_data = data.get("nodes", [])
edges_data = data.get("edges", [])
validation_errors = validate_workflow_structure(
nodes_data, edges_data, user_id=user_id
)
if validation_errors:
return error_response(
"Workflow validation failed", errors=validation_errors
)
nodes_data = normalize_agent_node_json_schemas(nodes_data)
try:
with db_session() as conn:
repo = WorkflowsRepository(conn)
workflow = _resolve_workflow(repo, workflow_id, user_id)
if workflow is None:
return error_response("Workflow not found", 404)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
next_graph_version = current_graph_version + 1
_write_graph(
conn, pg_workflow_id, next_graph_version,
nodes_data, edges_data,
)
repo.update(
pg_workflow_id, user_id,
{
"name": name,
"description": description,
"current_graph_version": next_graph_version,
},
)
WorkflowNodesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
)
WorkflowEdgesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
)
except Exception as err:
return _workflow_error_response("Failed to update workflow", err)
return success_response()
@require_auth
def delete(self, workflow_id: str):
"""Delete workflow and its graph."""
user_id = get_user_id()
try:
with db_session() as conn:
repo = WorkflowsRepository(conn)
workflow = _resolve_workflow(repo, workflow_id, user_id)
if workflow is None:
return error_response("Workflow not found", 404)
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
repo.delete(str(workflow["id"]), user_id)
except Exception as err:
return _workflow_error_response("Failed to delete workflow", err)
return success_response()