mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 16:15:16 +00:00
23 files changed
+2625
-3780
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -29,8 +29,13 @@ from application.api.user.team_sharing import (
|
||||
team_access_for,
|
||||
visible_with_access,
|
||||
)
|
||||
from application.agents.default_tools import is_synthesized_tool_id
|
||||
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.user_custom_models import (
|
||||
UserCustomModelsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from application.storage.db.repositories.conversations import ConversationsRepository
|
||||
from application.storage.db.repositories.shared_conversations import (
|
||||
SharedConversationsRepository,
|
||||
@@ -38,8 +43,6 @@ from application.storage.db.repositories.shared_conversations import (
|
||||
from application.storage.db.repositories.stack_logs import StackLogsRepository
|
||||
from application.storage.db.repositories.token_usage import TokenUsageRepository
|
||||
from application.storage.db.repositories.users import UsersRepository
|
||||
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.utils import (
|
||||
@@ -1473,15 +1476,13 @@ class DeleteAgent(Resource):
|
||||
workflow_id = agent.get("workflow_id")
|
||||
# For workflow-type agents, delete the owned workflow in the
|
||||
# same transaction. workflow_nodes/workflow_edges cascade
|
||||
# via ON DELETE CASCADE so a single workflow delete suffices.
|
||||
# via ON DELETE CASCADE so the single owner-scoped delete
|
||||
# suffices — and it must stay the ONLY delete: an explicit
|
||||
# (unscoped) node/edge cleanup here once let deleting an agent
|
||||
# whose ``workflow_id`` pointed at another user's workflow gut
|
||||
# that user's graph.
|
||||
if agent.get("agent_type") == "workflow" and workflow_id:
|
||||
try:
|
||||
WorkflowNodesRepository(conn).delete_by_workflow(
|
||||
str(workflow_id),
|
||||
)
|
||||
WorkflowEdgesRepository(conn).delete_by_workflow(
|
||||
str(workflow_id),
|
||||
)
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), user)
|
||||
except Exception as wf_err:
|
||||
current_app.logger.warning(
|
||||
@@ -1619,6 +1620,71 @@ class GetTemplateAgents(Resource):
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
def _filter_adoptable_tool_ids(conn, user: str, tool_ids) -> list:
|
||||
"""Keep only tool ids the adopter can actually run.
|
||||
|
||||
Builtin synthetic ids resolve for everyone; a ``user_tools`` row must be
|
||||
the adopter's own — the runtime resolves tools strictly owner-scoped, so
|
||||
a template owner's tool id would just be dropped (with a log line) on
|
||||
every run. Filtering at adopt time makes the gap visible in the builder
|
||||
instead of at run time.
|
||||
"""
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
kept = []
|
||||
for tid in tool_ids or []:
|
||||
tid = str(tid)
|
||||
if is_synthesized_tool_id(tid) or (
|
||||
looks_like_uuid(tid) and tools_repo.get_any(tid, user)
|
||||
):
|
||||
kept.append(tid)
|
||||
return kept
|
||||
|
||||
|
||||
def _strip_foreign_node_refs(conn, user: str):
|
||||
"""Build a ``clone_to_user`` node-config transform for adoption.
|
||||
|
||||
Agent nodes carry raw tool/source/model ids from the template owner. The
|
||||
engine resolves all three owner-scoped at run time (dropping misses with
|
||||
only a log line), so unresolvable refs would survive the clone as ghost
|
||||
references — shown in the builder, never used. Strip them instead, so
|
||||
the adopter sees empty fields to fill in.
|
||||
"""
|
||||
|
||||
def transform(node_type: str, config: dict) -> dict:
|
||||
if node_type != "agent":
|
||||
return config
|
||||
# Node settings may sit nested under ``config`` — mirror the engine,
|
||||
# which reads ``node.config.get("config", node.config)``.
|
||||
nested = config.get("config")
|
||||
cfg = nested if isinstance(nested, dict) else config
|
||||
if cfg.get("tools"):
|
||||
cfg["tools"] = _filter_adoptable_tool_ids(conn, user, cfg["tools"])
|
||||
if cfg.get("sources"):
|
||||
sources = cfg["sources"]
|
||||
if not isinstance(sources, list):
|
||||
sources = [sources]
|
||||
cfg["sources"] = [
|
||||
str(s)
|
||||
for s in sources
|
||||
if s and can_access(conn, "source", str(s), user)
|
||||
]
|
||||
model_id = cfg.get("model_id")
|
||||
if (
|
||||
model_id
|
||||
and looks_like_uuid(str(model_id))
|
||||
and not UserCustomModelsRepository(conn).get(str(model_id), user)
|
||||
):
|
||||
cfg["model_id"] = None
|
||||
if cfg is not config:
|
||||
# Flat leftovers of these keys are ignored by the engine but would
|
||||
# carry the template owner's raw ids along; drop them.
|
||||
for key in ("tools", "sources", "model_id"):
|
||||
config.pop(key, None)
|
||||
return config
|
||||
|
||||
return transform
|
||||
|
||||
|
||||
@agents_ns.route("/adopt_agent")
|
||||
class AdoptAgent(Resource):
|
||||
@api.doc(params={"id": "Agent ID"}, description="Adopt an agent by ID")
|
||||
@@ -1667,13 +1733,48 @@ class AdoptAgent(Resource):
|
||||
for col in (
|
||||
"description", "agent_type", "retriever",
|
||||
"default_model_id",
|
||||
"source_id", "prompt_id", "folder_id", "workflow_id",
|
||||
"source_id", "prompt_id", "folder_id",
|
||||
"extra_source_ids",
|
||||
):
|
||||
val = template.get(col)
|
||||
if val not in (None, ""):
|
||||
create_kwargs[col] = val
|
||||
|
||||
# ``workflow_id`` must NOT be copied by reference: the runtime
|
||||
# resolves it strictly owner-scoped, so the adopter would get
|
||||
# "Workflow not found or inaccessible" on every run. Deep-copy
|
||||
# the template's graph into a workflow the adopter owns,
|
||||
# stripping node refs (tools/sources/models) the adopter can't
|
||||
# resolve — they are owner-scoped at run time too.
|
||||
if template.get("workflow_id"):
|
||||
cloned_workflow = WorkflowsRepository(conn).clone_to_user(
|
||||
str(template["workflow_id"]),
|
||||
user,
|
||||
from_owner=template.get("user_id"),
|
||||
node_config_transform=_strip_foreign_node_refs(conn, user),
|
||||
)
|
||||
if cloned_workflow is None:
|
||||
# A workflow agent without its graph can never run —
|
||||
# fail the adopt instead of publishing a broken agent.
|
||||
current_app.logger.error(
|
||||
"Adopt: template %s references workflow %s not owned "
|
||||
"by %s; adoption refused",
|
||||
template.get("id"),
|
||||
template.get("workflow_id"),
|
||||
template.get("user_id"),
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "This template's workflow is "
|
||||
"unavailable; it cannot be adopted",
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
create_kwargs["workflow_id"] = str(cloned_workflow["id"])
|
||||
|
||||
# The avatar must live under the adopter's own upload
|
||||
# directory: image paths are validated against their owner,
|
||||
# so a copied reference to the template owner's blob would
|
||||
@@ -1683,9 +1784,16 @@ class AdoptAgent(Resource):
|
||||
)
|
||||
if adopted_image:
|
||||
create_kwargs["image"] = adopted_image
|
||||
for col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
for col in ("json_schema", "models", "shared_metadata", "config"):
|
||||
if template.get(col) is not None:
|
||||
create_kwargs[col] = template[col]
|
||||
# Tool ids resolve owner-scoped at run time, so the template
|
||||
# owner's rows would silently drop on every run — keep only
|
||||
# what the adopter can actually use (builtins, own tools).
|
||||
if template.get("tools") is not None:
|
||||
create_kwargs["tools"] = _filter_adoptable_tool_ids(
|
||||
conn, user, template["tools"]
|
||||
)
|
||||
for col in ("chunks", "token_limit", "request_limit"):
|
||||
if template.get(col) is not None:
|
||||
create_kwargs[col] = template[col]
|
||||
|
||||
@@ -160,6 +160,19 @@ class AgentsRepository:
|
||||
)
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def count_by_workflow(self, workflow_id: str, user_id: str) -> int:
|
||||
"""Number of this user's agents linked to ``workflow_id``."""
|
||||
if not looks_like_uuid(workflow_id):
|
||||
return 0
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM agents "
|
||||
"WHERE workflow_id = CAST(:wf AS uuid) AND user_id = :user_id"
|
||||
),
|
||||
{"wf": workflow_id, "user_id": user_id},
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
def get_by_id(self, agent_id: str) -> Optional[dict]:
|
||||
"""Fetch an agent by id with NO ownership scoping.
|
||||
|
||||
|
||||
@@ -149,16 +149,6 @@ class WorkflowEdgesRepository:
|
||||
row = result.fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def delete_by_workflow(self, workflow_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_edges "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid)"
|
||||
),
|
||||
{"wf_id": workflow_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
|
||||
@@ -143,16 +143,6 @@ class WorkflowNodesRepository:
|
||||
row = result.fetchone()
|
||||
return row_to_dict(row) if row is not None else None
|
||||
|
||||
def delete_by_workflow(self, workflow_id: str) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
"DELETE FROM workflow_nodes "
|
||||
"WHERE workflow_id = CAST(:wf_id AS uuid)"
|
||||
),
|
||||
{"wf_id": workflow_id},
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
|
||||
@@ -8,6 +8,7 @@ Covers CRUD on workflow metadata:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
@@ -115,6 +116,106 @@ class WorkflowsRepository:
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def clone_to_user(
|
||||
self,
|
||||
workflow_id: str,
|
||||
new_owner: str,
|
||||
*,
|
||||
from_owner: Optional[str] = None,
|
||||
node_config_transform=None,
|
||||
) -> Optional[dict]:
|
||||
"""Deep-copy a workflow's current graph into a new workflow owned by ``new_owner``.
|
||||
|
||||
The runtime resolves ``agents.workflow_id`` strictly owner-scoped, so a
|
||||
workflow reference can never be shared across users by id — it must be
|
||||
copied. Only the current graph version is copied; the clone starts at
|
||||
version 1. The source is resolved scoped to ``from_owner`` when given
|
||||
(callers pass the expected owner so a stray id can't clone another
|
||||
tenant's graph). Omitting ``from_owner`` resolves the source with NO
|
||||
ownership check — only do that when ``workflow_id`` comes from trusted
|
||||
server-side state, never from client input. Returns the new workflow
|
||||
row, or None if the source is missing.
|
||||
|
||||
``node_config_transform(node_type, config) -> dict`` lets the caller
|
||||
rewrite each node's config as it is copied (it receives a deep copy) —
|
||||
e.g. adoption strips refs the new owner can't resolve, since node
|
||||
configs carry raw tool/source/model ids that are owner-scoped at run
|
||||
time and would otherwise ride along as ghost references.
|
||||
"""
|
||||
from application.storage.db.repositories.workflow_edges import (
|
||||
WorkflowEdgesRepository,
|
||||
)
|
||||
from application.storage.db.repositories.workflow_nodes import (
|
||||
WorkflowNodesRepository,
|
||||
)
|
||||
|
||||
if not looks_like_uuid(workflow_id):
|
||||
return None
|
||||
source = (
|
||||
self.get(workflow_id, from_owner)
|
||||
if from_owner is not None
|
||||
else self.get_by_id(workflow_id)
|
||||
)
|
||||
if source is None:
|
||||
return None
|
||||
try:
|
||||
version = int(source.get("current_graph_version") or 1)
|
||||
except (TypeError, ValueError):
|
||||
version = 1
|
||||
if version <= 0:
|
||||
version = 1
|
||||
|
||||
clone = self.create(
|
||||
new_owner, source.get("name") or "", description=source.get("description")
|
||||
)
|
||||
clone_id = str(clone["id"])
|
||||
|
||||
def _cloned_config(n: dict) -> dict:
|
||||
config = n.get("config") or {}
|
||||
if node_config_transform is not None:
|
||||
config = node_config_transform(n["node_type"], copy.deepcopy(config))
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
nodes_repo = WorkflowNodesRepository(self._conn)
|
||||
node_rows = nodes_repo.find_by_version(workflow_id, version)
|
||||
created_nodes = nodes_repo.bulk_create(
|
||||
clone_id,
|
||||
1,
|
||||
[
|
||||
{
|
||||
"node_id": n["node_id"],
|
||||
"node_type": n["node_type"],
|
||||
"title": n.get("title"),
|
||||
"description": n.get("description"),
|
||||
"position": n.get("position") or {"x": 0, "y": 0},
|
||||
"config": _cloned_config(n),
|
||||
}
|
||||
for n in node_rows
|
||||
],
|
||||
)
|
||||
uuid_by_node_id = {n["node_id"]: n["id"] for n in created_nodes}
|
||||
|
||||
edges_repo = WorkflowEdgesRepository(self._conn)
|
||||
edge_rows = edges_repo.find_by_version(workflow_id, version)
|
||||
translated = []
|
||||
for e in edge_rows:
|
||||
from_uuid = uuid_by_node_id.get(e.get("source_id"))
|
||||
to_uuid = uuid_by_node_id.get(e.get("target_id"))
|
||||
if not from_uuid or not to_uuid:
|
||||
continue
|
||||
translated.append(
|
||||
{
|
||||
"edge_id": e["edge_id"],
|
||||
"from_node_id": from_uuid,
|
||||
"to_node_id": to_uuid,
|
||||
"source_handle": e.get("source_handle"),
|
||||
"target_handle": e.get("target_handle"),
|
||||
"config": e.get("config") or {},
|
||||
}
|
||||
)
|
||||
edges_repo.bulk_create(clone_id, 1, translated)
|
||||
return clone
|
||||
|
||||
def delete(self, workflow_id: str, user_id: str) -> bool:
|
||||
# Run artifacts parent to ``workflow_runs`` (which cascade-delete with the
|
||||
# workflow), but ``artifacts.workflow_run_id`` is a bare uuid with no FK, so
|
||||
|
||||
Reference in new issue
Block a user