Merge pull request #2690 from arc53/workflow-export

Workflow export
This commit is contained in:
Alex authored and GitHub committed 2026-08-23 11:46:26 +01:00
commit 5c55d2610b
23 files changed
+2625 -3780

No files matched your search

File diff suppressed because it is too large. Load diff
+119 -11
View File
@@ -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