mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 20:15:26 +00:00
fixes 3
This commit is contained in:
1 parent
7553e74a96
commit
33d93ba0ca
13 files changed
+612
-85
No files matched your search
@@ -68,6 +68,10 @@ class AgentImportError(Exception):
|
||||
"""Raised when an agent YAML document is malformed or unsupported."""
|
||||
|
||||
|
||||
class AgentExportError(Exception):
|
||||
"""Raised when an agent cannot be exported as a document import would accept."""
|
||||
|
||||
|
||||
class _SafeNoAliasLoader(yaml.SafeLoader):
|
||||
"""SafeLoader that also rejects anchors/aliases (billion-laughs guard)."""
|
||||
|
||||
@@ -158,12 +162,14 @@ def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
|
||||
|
||||
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``.
|
||||
Anything not described by requirements — e.g. 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``. (``api_tool`` is the one exception: its
|
||||
user-authored ``config["actions"]`` travel via
|
||||
``_export_api_tool_actions``, redacted under the same posture.)
|
||||
"""
|
||||
requirements = config_requirements or {}
|
||||
safe: dict[str, Any] = {}
|
||||
@@ -180,9 +186,121 @@ def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
|
||||
# types routinely hold credentials (an ``Authorization`` header fixed as a
|
||||
# value), so for those only the ``filled_by_llm`` flag travels — same posture
|
||||
# as ``_safe_export_config`` dropping free-form config.
|
||||
#
|
||||
# Scope note: ``actions``-column rows come from ``transform_actions`` over live
|
||||
# tool metadata, which today only ever emits ``parameters`` sections — the
|
||||
# HTTP types appear only in ``api_tool``'s ``config["actions"]``, which travel
|
||||
# through ``_export_api_tool_actions`` / ``_sanitize_api_tool_actions`` below.
|
||||
# The full tuple is kept here so a hand-edited file naming those sections is
|
||||
# handled (flags applied, values refused) rather than silently ignored.
|
||||
_ACTION_PARAM_TYPES = ("parameters", "query_params", "headers", "body")
|
||||
_ACTION_VALUE_SAFE_TYPES = frozenset({"parameters"})
|
||||
|
||||
# Action keys copied verbatim by ``_export_api_tool_actions`` — everything
|
||||
# except ``url`` (query-string-stripped) and the param sections (redacted).
|
||||
_API_ACTION_SAFE_KEYS = (
|
||||
"name",
|
||||
"description",
|
||||
"method",
|
||||
"active",
|
||||
"require_approval",
|
||||
"body_content_type",
|
||||
"body_encoding_rules",
|
||||
)
|
||||
|
||||
|
||||
def _export_api_tool_actions(stored_config: dict) -> dict:
|
||||
"""Redacted copy of an ``api_tool``'s ``config["actions"]``.
|
||||
|
||||
``api_tool`` is the one tool type whose actions are user-authored data in
|
||||
``config["actions"]`` (a dict keyed by action name) rather than the
|
||||
``actions`` column (the executor reads exactly that — see ToolExecutor).
|
||||
The action *shape* — endpoint, method, param schemas, ``filled_by_llm``
|
||||
flags, fixed ``parameters`` values — is what makes the tool work, so it
|
||||
travels. Fixed values in the free-form HTTP sections and URL query
|
||||
strings routinely hold credentials, so those are blanked — same posture
|
||||
as ``_safe_export_config``.
|
||||
"""
|
||||
actions = stored_config.get("actions")
|
||||
if not isinstance(actions, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for name, action in actions.items():
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
k: copy.deepcopy(action[k]) for k in _API_ACTION_SAFE_KEYS if k in action
|
||||
}
|
||||
entry["url"] = str(action.get("url") or "").split("?", 1)[0]
|
||||
for param_type in _ACTION_PARAM_TYPES:
|
||||
section = action.get(param_type)
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
section = copy.deepcopy(section)
|
||||
props = section.get("properties")
|
||||
if param_type not in _ACTION_VALUE_SAFE_TYPES and isinstance(props, dict):
|
||||
for details in props.values():
|
||||
if isinstance(details, dict) and details.get("value"):
|
||||
details["value"] = ""
|
||||
entry[param_type] = section
|
||||
out[str(name)] = entry
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_api_tool_actions(raw, tool_label: str, warnings: list) -> dict:
|
||||
"""Vet a hand-editable ``config["actions"]`` block before storing it.
|
||||
|
||||
Unlike column actions — whose schema comes from live tool code, so the
|
||||
YAML may contribute flags and scalar values only — ``api_tool`` actions
|
||||
ARE user-authored schema, and the whole definition is accepted. Each
|
||||
action's URL goes through the same SSRF gate as the tool-config routes
|
||||
(``_validate_tool_urls``); an action with a missing or unsafe URL is
|
||||
dropped with a warning rather than failing the import.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
if len(raw) > MAX_LIST_ITEMS:
|
||||
raise AgentImportError(f"Too many api_tool actions (max {MAX_LIST_ITEMS})")
|
||||
out: dict[str, Any] = {}
|
||||
for name, action in raw.items():
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
url = str(action.get("url") or "").strip()
|
||||
if not url:
|
||||
warnings.append(
|
||||
f"Tool '{tool_label}' action '{name}' has no URL; dropped"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
validate_url(url)
|
||||
except SSRFError:
|
||||
warnings.append(
|
||||
f"Tool '{tool_label}' action '{name}' has an unsafe URL; dropped"
|
||||
)
|
||||
continue
|
||||
# Export blanks fixed values in the free-form HTTP sections (they
|
||||
# routinely hold credentials); a param left pinned with an empty
|
||||
# value is silently omitted from the request at run time, so
|
||||
# surface it now instead.
|
||||
pinned_empty = [
|
||||
f"{param_type}.{pname}"
|
||||
for param_type in _ACTION_PARAM_TYPES
|
||||
if param_type not in _ACTION_VALUE_SAFE_TYPES
|
||||
and isinstance(action.get(param_type), dict)
|
||||
for pname, details in (action[param_type].get("properties") or {}).items()
|
||||
if isinstance(details, dict)
|
||||
and details.get("filled_by_llm") is False
|
||||
and not details.get("value")
|
||||
]
|
||||
if pinned_empty:
|
||||
warnings.append(
|
||||
f"Tool '{tool_label}' action '{name}': fixed values for "
|
||||
f"{', '.join(pinned_empty)} don't travel in the file; set them "
|
||||
"in the tool's settings or the request will omit them"
|
||||
)
|
||||
out[str(name)] = action
|
||||
return out
|
||||
|
||||
|
||||
def _export_action_overrides(actions) -> list:
|
||||
"""Compact per-action customizations that deviate from factory defaults.
|
||||
@@ -266,6 +384,17 @@ def _apply_action_overrides(base_actions: list, overrides, tool_type: str, warni
|
||||
value = entry.get("value", "")
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
details["value"] = "" if value is None else value
|
||||
elif not details["filled_by_llm"] and not details.get("value"):
|
||||
# Pinned but valueless: the executor omits the param from
|
||||
# the LLM schema AND skips the empty fixed value, so the
|
||||
# request silently goes out without it. Values for the
|
||||
# free-form HTTP types never travel in the file, so this
|
||||
# can only be repaired in the tool's settings.
|
||||
warnings.append(
|
||||
f"Tool '{tool_type}' action '{name}': fixed value for "
|
||||
f"{param_type} '{pname}' doesn't travel in the file; set "
|
||||
"it in the tool's settings or the request will omit it"
|
||||
)
|
||||
return base_actions
|
||||
|
||||
|
||||
@@ -314,13 +443,26 @@ def _serialize_tools(conn, agent: dict, user: str) -> list:
|
||||
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})
|
||||
out.append(
|
||||
{
|
||||
"type": synthesized_tool_name_for_id(tid_str),
|
||||
"builtin": True,
|
||||
"ref": tid_str,
|
||||
}
|
||||
)
|
||||
continue
|
||||
row = repo.get_any(tid_str, user)
|
||||
if not row:
|
||||
continue
|
||||
requirements = row.get("config_requirements") or {}
|
||||
requires_secrets = _secret_field_names(requirements)
|
||||
config = _safe_export_config(row.get("config") or {}, requirements)
|
||||
if (row.get("name") or "") == "api_tool":
|
||||
# api_tool's actions live in config, not the actions column — an
|
||||
# export without them imports as a dead tool.
|
||||
exported_actions = _export_api_tool_actions(row.get("config") or {})
|
||||
if exported_actions:
|
||||
config["actions"] = exported_actions
|
||||
entry: dict[str, Any] = {
|
||||
"type": row.get("name") or "",
|
||||
# Raw custom_name (may be "") so import matching is symmetric and
|
||||
@@ -328,7 +470,7 @@ def _serialize_tools(conn, agent: dict, user: str) -> list:
|
||||
"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),
|
||||
"config": config,
|
||||
"ref": tid_str,
|
||||
}
|
||||
action_overrides = _export_action_overrides(row.get("actions"))
|
||||
@@ -389,6 +531,19 @@ def _effective_node_config(config: dict) -> dict:
|
||||
return nested if isinstance(nested, dict) else config
|
||||
|
||||
|
||||
def _strip_shadowed_ref_keys(config: dict) -> None:
|
||||
"""Drop flat ``tools``/``sources``/``model_id`` shadowed by a nested config.
|
||||
|
||||
When a node stores its settings under ``config["config"]``, the engine
|
||||
reads only the nested dict — flat leftovers of those keys are inert, but
|
||||
on export they would leak the exporter's raw ids into the file, and on
|
||||
import they would store a hand-edited file's raw ids verbatim.
|
||||
"""
|
||||
if isinstance(config.get("config"), dict):
|
||||
for key in ("tools", "sources", "model_id"):
|
||||
config.pop(key, None)
|
||||
|
||||
|
||||
def _serialize_workflow(conn, agent: dict, user: str):
|
||||
"""Build the portable ``spec.workflow`` block plus its reference sections.
|
||||
|
||||
@@ -431,26 +586,21 @@ def _serialize_workflow(conn, agent: dict, user: str):
|
||||
if mid and str(mid) not in model_ids:
|
||||
model_ids.append(str(mid))
|
||||
|
||||
# Serialize each reference through the classic per-resource serializers so
|
||||
# Serialize the references through the classic per-resource serializers so
|
||||
# secret redaction and identity rules stay in one place. Unresolvable ids
|
||||
# (dangling rows) are dropped from both the section and the node config.
|
||||
# (dangling rows) are dropped from both the section and the node config;
|
||||
# every emitted entry carries its ``ref``, which keys the maps below.
|
||||
tools: list = []
|
||||
tool_key_by_id: dict[str, str] = {}
|
||||
for tid in tool_ids:
|
||||
entries = _serialize_tools(conn, {"tools": [tid]}, user)
|
||||
if not entries:
|
||||
continue
|
||||
tool_key_by_id[tid] = _tool_key(len(tools))
|
||||
tools.append(entries[0])
|
||||
for entry in _serialize_tools(conn, {"tools": tool_ids}, user):
|
||||
tool_key_by_id[str(entry["ref"])] = _tool_key(len(tools))
|
||||
tools.append(entry)
|
||||
|
||||
sources: list = []
|
||||
source_name_by_id: dict[str, str] = {}
|
||||
for sid in source_ids:
|
||||
entries = _serialize_sources(conn, {"source_id": sid}, user)
|
||||
if not entries:
|
||||
continue
|
||||
source_name_by_id[sid] = entries[0]["name"]
|
||||
sources.append(entries[0])
|
||||
for entry in _serialize_sources(conn, {"extra_source_ids": source_ids}, user):
|
||||
source_name_by_id[str(entry["ref"])] = entry["name"]
|
||||
sources.append(entry)
|
||||
|
||||
model = _serialize_models(conn, {"models": model_ids, "default_model_id": ""}, user)
|
||||
model_name_by_id: dict[str, str] = {}
|
||||
@@ -460,8 +610,22 @@ def _serialize_workflow(conn, agent: dict, user: str):
|
||||
elif isinstance(entry, str):
|
||||
model_name_by_id[entry] = entry
|
||||
|
||||
# Pass 2: emit nodes/edges in the workflow API's wire shape, with agent
|
||||
# node configs rewritten to the portable keys.
|
||||
# ``parse_agent_yaml`` bounds these sections at import (DoS cap), so a
|
||||
# graph referencing more would export a file that import then rejects —
|
||||
# refuse here with a clear message instead.
|
||||
if (
|
||||
len(tools) > MAX_LIST_ITEMS
|
||||
or len(sources) > MAX_LIST_ITEMS
|
||||
or len(model["available"]) > MAX_LIST_ITEMS
|
||||
):
|
||||
raise AgentExportError(
|
||||
"This workflow references more than "
|
||||
f"{MAX_LIST_ITEMS} tools, sources, or models and cannot be exported"
|
||||
)
|
||||
|
||||
# Pass 2: emit nodes/edges with agent node configs rewritten to the
|
||||
# portable keys. (Node settings sit under ``config`` here; the workflow
|
||||
# API's wire shape calls that field ``data`` — import converts.)
|
||||
nodes_out = []
|
||||
for n in node_rows:
|
||||
config = copy.deepcopy(n.get("config") or {})
|
||||
@@ -479,6 +643,7 @@ def _serialize_workflow(conn, agent: dict, user: str):
|
||||
]
|
||||
if cfg.get("model_id"):
|
||||
cfg["model_id"] = model_name_by_id.get(str(cfg["model_id"]))
|
||||
_strip_shadowed_ref_keys(config)
|
||||
nodes_out.append(
|
||||
{
|
||||
"id": n["node_id"],
|
||||
@@ -922,6 +1087,19 @@ def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings:
|
||||
config_requirements = inst.get_config_requirements() or {}
|
||||
config = dict(tool.get("config") or {})
|
||||
config.update(secrets or {})
|
||||
if tool_type == "api_tool":
|
||||
label = tool.get("display_name") or tool.get("name") or tool_type
|
||||
config["actions"] = _sanitize_api_tool_actions(
|
||||
config.get("actions"), label, warnings
|
||||
)
|
||||
if not config["actions"]:
|
||||
# Zero actions means a dead tool — the executor resolves api_tool
|
||||
# calls exclusively through config["actions"]. Reachable via older
|
||||
# exports (which couldn't carry actions) or a fully-dropped block.
|
||||
warnings.append(
|
||||
f"API tool '{label}' was created without any actions; "
|
||||
"configure its actions in the tool's settings before use"
|
||||
)
|
||||
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")
|
||||
@@ -1025,7 +1203,22 @@ def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list)
|
||||
name_to_id: dict[str, str] = {}
|
||||
models: list[str] = []
|
||||
|
||||
for entry in model_spec.get("available") or []:
|
||||
available = model_spec.get("available") or []
|
||||
# ``name_to_id`` (and a workflow node's ``model_id`` reference) keys on
|
||||
# display_name alone, while custom-model identity is the full
|
||||
# (display_name, upstream_model_id, base_url) triple — duplicates
|
||||
# collapse onto the last entry, so surface that instead of silently
|
||||
# rewiring nodes.
|
||||
custom_names = [
|
||||
e.get("display_name") or "" for e in available if isinstance(e, dict)
|
||||
]
|
||||
for dup in sorted({n for n in custom_names if n and custom_names.count(n) > 1}):
|
||||
warnings.append(
|
||||
f"Multiple custom models share the display name '{dup}'; "
|
||||
"references to it resolve to the last one in the file"
|
||||
)
|
||||
|
||||
for entry in available:
|
||||
if isinstance(entry, str):
|
||||
if validate_model_id(entry, user):
|
||||
models.append(entry)
|
||||
@@ -1204,6 +1397,7 @@ def _apply_workflow(
|
||||
model_ids_by_name,
|
||||
warnings,
|
||||
)
|
||||
_strip_shadowed_ref_keys(config)
|
||||
position = node.get("position")
|
||||
nodes_data.append(
|
||||
{
|
||||
@@ -1328,6 +1522,7 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
"config": _import_config(spec),
|
||||
"slug": slug,
|
||||
}
|
||||
orphaned_workflow_id: Optional[str] = None
|
||||
if is_workflow:
|
||||
# A workflow agent's behavior lives in its graph — the agent-level
|
||||
# prompt/tool/model/source slots in the file exist only to carry the
|
||||
@@ -1358,6 +1553,14 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
)
|
||||
else:
|
||||
authoritative["workflow_id"] = workflow_result
|
||||
if workflow_result is None and is_update:
|
||||
# Explicit ``workflow: null`` on a draft: remember the old
|
||||
# link so the row can be reaped after the update — there is
|
||||
# no workflow-list API independent of agents, so an
|
||||
# unlinked workflow would be unreachable forever.
|
||||
prior = agents_repo.get(str(target["agent_id"]), user) or {}
|
||||
if prior.get("workflow_id"):
|
||||
orphaned_workflow_id = str(prior["workflow_id"])
|
||||
|
||||
# Optional fields — applied only when present so a partial file doesn't wipe them.
|
||||
optional = {
|
||||
@@ -1374,6 +1577,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
# 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):
|
||||
if orphaned_workflow_id and not agents_repo.count_by_workflow(
|
||||
orphaned_workflow_id, user
|
||||
):
|
||||
# Nothing references it anymore; delete (cascades nodes/edges
|
||||
# and reaps run artifacts) rather than stranding the row.
|
||||
WorkflowsRepository(conn).delete(orphaned_workflow_id, user)
|
||||
return {
|
||||
"agent_id": str(target["agent_id"]),
|
||||
"action": "updated",
|
||||
@@ -1451,7 +1660,12 @@ class ExportAgent(Resource):
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, user)
|
||||
export = serialize_agent(conn, agent, user)
|
||||
try:
|
||||
export = serialize_agent(conn, agent, user)
|
||||
except AgentExportError as exc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(exc)}), 400
|
||||
)
|
||||
body = agent_to_yaml(export)
|
||||
filename = f"{export['metadata'].get('slug') or 'agent'}.agent.yaml"
|
||||
response = make_response(body, 200)
|
||||
|
||||
@@ -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,
|
||||
@@ -1615,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")
|
||||
@@ -1673,21 +1743,37 @@ class AdoptAgent(Resource):
|
||||
# ``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.
|
||||
# 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 not None:
|
||||
create_kwargs["workflow_id"] = str(cloned_workflow["id"])
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
"Adopt: template %s references missing workflow %s; "
|
||||
"adopted agent created without one",
|
||||
template.get("id"), template.get("workflow_id"),
|
||||
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,
|
||||
@@ -1698,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
|
||||
@@ -121,6 +122,7 @@ class WorkflowsRepository:
|
||||
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``.
|
||||
|
||||
@@ -133,6 +135,12 @@ class WorkflowsRepository:
|
||||
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,
|
||||
@@ -162,6 +170,12 @@ class WorkflowsRepository:
|
||||
)
|
||||
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(
|
||||
@@ -174,7 +188,7 @@ class WorkflowsRepository:
|
||||
"title": n.get("title"),
|
||||
"description": n.get("description"),
|
||||
"position": n.get("position") or {"x": 0, "y": 0},
|
||||
"config": n.get("config") or {},
|
||||
"config": _cloned_config(n),
|
||||
}
|
||||
for n in node_rows
|
||||
],
|
||||
|
||||
@@ -129,6 +129,13 @@ export default function ImportAgentModal({
|
||||
if (acceptedFiles[0]) processFile(acceptedFiles[0]);
|
||||
},
|
||||
multiple: false,
|
||||
// Declared here (not via getInputProps) so drag-and-drop is filtered
|
||||
// too, with drag-over rejection feedback; processFile's extension check
|
||||
// stays as a backstop for odd MIME reports.
|
||||
accept: {
|
||||
'application/x-yaml': ['.yaml', '.yml'],
|
||||
'text/yaml': ['.yaml', '.yml'],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
@@ -325,7 +332,7 @@ export default function ImportAgentModal({
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fileName || t('modals.importAgent.dropzoneText')}
|
||||
</p>
|
||||
<input {...getInputProps({ accept: '.yaml,.yml' })} />
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,13 @@ export default function ImportSpecModal({
|
||||
if (acceptedFiles[0]) processFile(acceptedFiles[0]);
|
||||
},
|
||||
multiple: false,
|
||||
// Declared here (not via getInputProps) so drag-and-drop is filtered
|
||||
// too; processFile's extension check stays as a backstop.
|
||||
accept: {
|
||||
'application/json': ['.json'],
|
||||
'application/x-yaml': ['.yaml', '.yml'],
|
||||
'text/yaml': ['.yaml', '.yml'],
|
||||
},
|
||||
});
|
||||
|
||||
const handleParse = async () => {
|
||||
@@ -217,7 +224,7 @@ export default function ImportSpecModal({
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('modals.importSpec.supportedFormats')}
|
||||
</p>
|
||||
<input {...getInputProps({ accept: '.json,.yaml,.yml' })} />
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -703,3 +703,122 @@ def test_action_override_for_removed_action_warns(pg_conn, monkeypatch):
|
||||
assert chat_id["filled_by_llm"] is False and chat_id["value"] == "42"
|
||||
assert any("telegram_removed_action" in w for w in result["warnings"])
|
||||
assert any("gone_param" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def _fake_api_tool(monkeypatch):
|
||||
"""api_tool's live surface: no declared config, no column actions."""
|
||||
fake_tool = Mock()
|
||||
fake_tool.get_config_requirements.return_value = {}
|
||||
fake_tool.get_actions_metadata.return_value = []
|
||||
monkeypatch.setattr(
|
||||
"application.api.user.agents.portability._tool_instance",
|
||||
lambda tool_type: fake_tool,
|
||||
)
|
||||
# The sanitizer SSRF-gates each action URL; DNS isn't available in tests.
|
||||
monkeypatch.setattr(
|
||||
"application.api.user.agents.portability.validate_url", lambda url: url
|
||||
)
|
||||
return fake_tool
|
||||
|
||||
|
||||
def _stored_api_tool_config():
|
||||
"""``config["actions"]`` as ToolConfig persists it (dict keyed by name)."""
|
||||
return {
|
||||
"actions": {
|
||||
"get_pet": {
|
||||
"name": "get_pet",
|
||||
"url": "https://api.example.com/pets/{petId}?api_key=QSSECRET",
|
||||
"method": "GET",
|
||||
"active": True,
|
||||
"headers": {
|
||||
"properties": {
|
||||
"Authorization": {
|
||||
"type": "string",
|
||||
"filled_by_llm": False,
|
||||
"value": "Bearer sk-live-XYZ",
|
||||
}
|
||||
}
|
||||
},
|
||||
"query_params": {
|
||||
"properties": {
|
||||
"petId": {
|
||||
"type": "string",
|
||||
"filled_by_llm": True,
|
||||
"value": "",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_api_tool_actions_round_trip_redacted(pg_conn, monkeypatch):
|
||||
"""api_tool actions live in config, not the actions column — they must
|
||||
travel (redacted) or the imported tool is dead (executor resolves calls
|
||||
exclusively through ``config["actions"]``)."""
|
||||
user = "u_api_tool"
|
||||
_fake_api_tool(monkeypatch)
|
||||
tool = UserToolsRepository(pg_conn).create(
|
||||
user,
|
||||
"api_tool",
|
||||
config=_stored_api_tool_config(),
|
||||
custom_name="Petstore",
|
||||
display_name="Petstore",
|
||||
description="pets",
|
||||
config_requirements={},
|
||||
actions=[],
|
||||
)
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "Pet Bot", "published", description="d", tools=[str(tool["id"])]
|
||||
)
|
||||
|
||||
export = serialize_agent(pg_conn, agent, user)
|
||||
text = agent_to_yaml(export)
|
||||
assert "sk-live-XYZ" not in text and "QSSECRET" not in text
|
||||
exported = export["spec"]["tools"][0]["config"]["actions"]["get_pet"]
|
||||
# Shape travels; the URL loses its query string; the pinned header
|
||||
# value is blanked while its flag and schema survive.
|
||||
assert exported["url"] == "https://api.example.com/pets/{petId}"
|
||||
auth = exported["headers"]["properties"]["Authorization"]
|
||||
assert auth["filled_by_llm"] is False and auth["value"] == ""
|
||||
assert exported["query_params"]["properties"]["petId"]["filled_by_llm"] is True
|
||||
|
||||
doc = parse_agent_yaml(text)
|
||||
importer = "u_api_tool_2"
|
||||
result = apply_import(
|
||||
pg_conn,
|
||||
importer,
|
||||
doc,
|
||||
resolution={"tools": {"tool-0": {"decision": "create"}}},
|
||||
)
|
||||
imported_agent = AgentsRepository(pg_conn).get(result["agent_id"], importer)
|
||||
row = UserToolsRepository(pg_conn).get_any(imported_agent["tools"][0], importer)
|
||||
action = row["config"]["actions"]["get_pet"]
|
||||
assert action["url"] == "https://api.example.com/pets/{petId}"
|
||||
assert action["method"] == "GET"
|
||||
# The blanked pinned header is called out — the request would silently
|
||||
# omit it until the importer sets a value in the tool's settings.
|
||||
assert any("Authorization" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_api_tool_without_actions_imports_with_warning(pg_conn, monkeypatch):
|
||||
"""Older exports carried no actions for api_tool; the tool is created but
|
||||
the dead state is surfaced instead of silent."""
|
||||
user = "u_api_tool_empty"
|
||||
_fake_api_tool(monkeypatch)
|
||||
doc = _doc(
|
||||
name="Dead API Bot",
|
||||
_slug="dead-api",
|
||||
tools=[{"type": "api_tool", "name": "Legacy", "config": {}}],
|
||||
)
|
||||
result = apply_import(
|
||||
pg_conn,
|
||||
user,
|
||||
doc,
|
||||
resolution={"tools": {"tool-0": {"decision": "create"}}},
|
||||
)
|
||||
assert any("without any actions" in w for w in result["warnings"])
|
||||
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
|
||||
row = UserToolsRepository(pg_conn).get_any(agent["tools"][0], user)
|
||||
assert row["config"]["actions"] == {}
|
||||
@@ -131,7 +131,11 @@ def test_export_rewrites_node_refs_and_redacts_secrets(pg_conn):
|
||||
# The collected sections use the classic serializers' portable identities.
|
||||
assert spec["tools"][0]["type"] == "brave"
|
||||
assert spec["tools"][0]["requires_secrets"] == ["token"]
|
||||
assert spec["tools"][1] == {"type": "read_document", "builtin": True}
|
||||
assert spec["tools"][1] == {
|
||||
"type": "read_document",
|
||||
"builtin": True,
|
||||
"ref": default_tool_id("read_document"),
|
||||
}
|
||||
assert spec["sources"][0]["name"] == "KB"
|
||||
assert spec["model"]["available"][0]["display_name"] == "My Llama"
|
||||
assert spec["prompt"] == "default"
|
||||
@@ -378,8 +382,29 @@ def test_import_with_null_workflow_keeps_published_but_clears_draft(pg_conn):
|
||||
assert str(updated["workflow_id"]) == str(wf["id"])
|
||||
assert any("kept its existing" in w for w in result["warnings"])
|
||||
|
||||
# Draft agent: explicit null clears the link.
|
||||
# Draft agent: explicit null clears the link AND reaps the now-orphaned
|
||||
# workflow row — there is no workflow-list API independent of agents, so
|
||||
# an unlinked row would be unreachable forever.
|
||||
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
|
||||
apply_import(pg_conn, user, doc)
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
assert updated["workflow_id"] is None
|
||||
assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is None
|
||||
|
||||
|
||||
def test_null_workflow_keeps_row_still_referenced_by_another_agent(pg_conn):
|
||||
user = "u_wf_null_shared"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
# A second draft agent points at the same workflow.
|
||||
AgentsRepository(pg_conn).create(
|
||||
user, "Sibling", "draft", agent_type="workflow", workflow_id=str(wf["id"])
|
||||
)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
doc["spec"]["workflow"] = None
|
||||
|
||||
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
|
||||
apply_import(pg_conn, user, doc)
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
assert updated["workflow_id"] is None
|
||||
# Still referenced — must not be deleted.
|
||||
assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is not None
|
||||
@@ -181,8 +181,12 @@ class TestAdoptWorkflowAgent:
|
||||
assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
|
||||
assert len(edges) == 2
|
||||
|
||||
def test_adopt_survives_unresolvable_workflow(self, app, pg_conn):
|
||||
"""A template pointing at a graph its owner doesn't own adopts without one."""
|
||||
def test_adopt_refuses_unresolvable_workflow(self, app, pg_conn):
|
||||
"""A template pointing at a graph its owner doesn't own cannot adopt.
|
||||
|
||||
Publishing the agent anyway would hand the adopter a workflow agent
|
||||
with no graph — permanently unrunnable — so the adopt fails instead.
|
||||
"""
|
||||
from application.api.user.agents.routes import AdoptAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
@@ -203,17 +207,87 @@ class TestAdoptWorkflowAgent:
|
||||
|
||||
request.decoded_token = {"sub": "u-wf-adopter2"}
|
||||
response = AdoptAgent().post()
|
||||
assert response.status_code == 200
|
||||
adopted = next(
|
||||
assert response.status_code == 500
|
||||
assert not [
|
||||
a
|
||||
for a in repo.list_for_user("u-wf-adopter2")
|
||||
if a["name"] == "Broken WF Template"
|
||||
)
|
||||
assert adopted["workflow_id"] is None
|
||||
]
|
||||
# The unowned graph was not cloned or mutated.
|
||||
nodes, edges = _graph(pg_conn, wf["id"])
|
||||
assert len(nodes) == 3 and len(edges) == 2
|
||||
|
||||
def test_adopt_strips_foreign_node_refs(self, app, pg_conn):
|
||||
"""Cloned agent nodes keep builtin tool ids but shed the owner's refs."""
|
||||
from application.agents.default_tools import default_tool_id
|
||||
from application.api.user.agents.routes import AdoptAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.user_tools import (
|
||||
UserToolsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.workflow_nodes import (
|
||||
WorkflowNodesRepository,
|
||||
)
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
owner_tool = UserToolsRepository(pg_conn).create(
|
||||
"__system__", "brave", config={}, display_name="Brave"
|
||||
)
|
||||
builtin_id = default_tool_id("artifact_generator")
|
||||
wf = WorkflowsRepository(pg_conn).create("__system__", "Reffy WF")
|
||||
WorkflowNodesRepository(pg_conn).bulk_create(
|
||||
str(wf["id"]),
|
||||
1,
|
||||
[
|
||||
{"node_id": "start-1", "node_type": "start", "title": "Start"},
|
||||
{
|
||||
"node_id": "agent-1",
|
||||
"node_type": "agent",
|
||||
"title": "Agent",
|
||||
"config": {
|
||||
"tools": [builtin_id, str(owner_tool["id"])],
|
||||
"sources": ["00000000-0000-0000-0000-00000000dead"],
|
||||
"model_id": "00000000-0000-0000-0000-00000000beef",
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
repo = AgentsRepository(pg_conn)
|
||||
template = repo.create(
|
||||
"__system__",
|
||||
"Reffy Template",
|
||||
"template",
|
||||
agent_type="workflow",
|
||||
workflow_id=str(wf["id"]),
|
||||
tools=[builtin_id, str(owner_tool["id"])],
|
||||
)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/adopt_agent?id={template['id']}", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": "u-wf-adopter3"}
|
||||
response = AdoptAgent().post()
|
||||
assert response.status_code == 200
|
||||
|
||||
adopted = next(
|
||||
a
|
||||
for a in repo.list_for_user("u-wf-adopter3")
|
||||
if a["name"] == "Reffy Template"
|
||||
)
|
||||
# Classic-column tools: the builtin survives, the foreign row doesn't.
|
||||
assert adopted["tools"] == [builtin_id]
|
||||
nodes, _ = _graph(pg_conn, adopted["workflow_id"])
|
||||
agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
|
||||
assert agent_node["config"]["tools"] == [builtin_id]
|
||||
assert agent_node["config"]["sources"] == []
|
||||
assert agent_node["config"]["model_id"] is None
|
||||
# The template's own graph is untouched.
|
||||
src_nodes, _ = _graph(pg_conn, wf["id"])
|
||||
src_agent = next(n for n in src_nodes if n["node_id"] == "agent-1")
|
||||
assert str(owner_tool["id"]) in src_agent["config"]["tools"]
|
||||
|
||||
|
||||
class TestDeleteWorkflowAgent:
|
||||
def test_delete_removes_owned_workflow(self, app, pg_conn):
|
||||
|
||||
@@ -96,14 +96,6 @@ class TestFindByVersion:
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_by_workflow(self, pg_conn):
|
||||
wf, n1, n2 = _setup(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
repo.create(wf["id"], 1, "e1", n1["id"], n2["id"])
|
||||
deleted = repo.delete_by_workflow(wf["id"])
|
||||
assert deleted == 1
|
||||
assert repo.find_by_version(wf["id"], 1) == []
|
||||
|
||||
def test_delete_by_version(self, pg_conn):
|
||||
wf = WorkflowsRepository(pg_conn).create("user-1", "wf")
|
||||
node_repo = WorkflowNodesRepository(pg_conn)
|
||||
|
||||
@@ -161,17 +161,6 @@ class TestBulkCreateOnConflict:
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_by_workflow(self, pg_conn):
|
||||
wf = _wf(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
repo.bulk_create(wf["id"], 1, [
|
||||
{"node_id": "n1", "node_type": "start"},
|
||||
{"node_id": "n2", "node_type": "end"},
|
||||
])
|
||||
deleted = repo.delete_by_workflow(wf["id"])
|
||||
assert deleted == 2
|
||||
assert repo.find_by_version(wf["id"], 1) == []
|
||||
|
||||
def test_delete_by_version(self, pg_conn):
|
||||
wf = _wf(pg_conn)
|
||||
repo = _repo(pg_conn)
|
||||
|
||||
Reference in new issue
Block a user