mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 14:15:52 +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
|
||||
|
||||
@@ -17,12 +17,14 @@ import Trash from '../assets/red-trash.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import UnPin from '../assets/unpin.svg';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../components/ui/dropdown-menu';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MoveToFolderModal from '../modals/MoveToFolderModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
@@ -67,6 +69,7 @@ export default function AgentCard({
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [moveModalState, setMoveModalState] = useState<ActiveState>('INACTIVE');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const menuOptionsConfig: Record<string, AgentMenuOption[]> = {
|
||||
template: [
|
||||
@@ -297,7 +300,18 @@ export default function AgentCard({
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await userService.exportAgent(agent.id ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to export agent');
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.then((data) => data?.message)
|
||||
.catch(() => null);
|
||||
// Server-side refusals (e.g. a workflow referencing too many
|
||||
// resources) carry an actionable message; flag it so the catch can
|
||||
// tell it apart from a transport error like "Failed to fetch".
|
||||
const error = new Error(message || t('agents.exportAgentFailed'));
|
||||
error.name = 'ExportRefused';
|
||||
throw error;
|
||||
}
|
||||
const yamlText = await response.text();
|
||||
const blob = new Blob([yamlText], { type: 'application/x-yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -310,6 +324,11 @@ export default function AgentCard({
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setExportError(
|
||||
error instanceof Error && error.name === 'ExportRefused'
|
||||
? error.message
|
||||
: t('agents.exportAgentFailed'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -461,6 +480,25 @@ export default function AgentCard({
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
<Modal
|
||||
open={exportError !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setExportError(null);
|
||||
}}
|
||||
title={t('agents.exportAgentFailed')}
|
||||
size="sm"
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExportError(null)}
|
||||
className="rounded-3xl px-5"
|
||||
>
|
||||
{t('agents.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{exportError}</p>
|
||||
</Modal>
|
||||
<MoveToFolderModal
|
||||
modalState={moveModalState}
|
||||
setModalState={setMoveModalState}
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "Neuer Agent",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Schließen",
|
||||
"shareWithTeam": "Mit Team teilen",
|
||||
"backToAll": "Zurück zu allen Agenten",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1035,6 +1035,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1158,6 +1161,8 @@
|
||||
"newAgent": "New Agent",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Close",
|
||||
"shareWithTeam": "Share with team",
|
||||
"backToAll": "Back to all agents",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "Nuevo Agente",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Cerrar",
|
||||
"shareWithTeam": "Compartir con el equipo",
|
||||
"backToAll": "Volver a todos los agentes",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "新しいエージェント",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "閉じる",
|
||||
"shareWithTeam": "チームと共有",
|
||||
"backToAll": "すべてのエージェントに戻る",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "Новый Агент",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Закрыть",
|
||||
"shareWithTeam": "Поделиться с командой",
|
||||
"backToAll": "Вернуться ко всем агентам",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "新建代理",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "關閉",
|
||||
"shareWithTeam": "與團隊分享",
|
||||
"backToAll": "返回所有代理",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1030,6 +1030,9 @@
|
||||
"importError": "Import failed",
|
||||
"willCreate": "Creates a new agent.",
|
||||
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
|
||||
"workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
|
||||
"workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
|
||||
"workflowDelete": "Includes no workflow — the agent's existing workflow ({{nodes}} nodes), its run history and its artifacts will be permanently deleted.",
|
||||
"sources": "Sources",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
@@ -1149,6 +1152,8 @@
|
||||
"newAgent": "新建代理",
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "关闭",
|
||||
"shareWithTeam": "与团队共享",
|
||||
"backToAll": "返回所有代理",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -54,6 +55,11 @@ type ImportPlan = {
|
||||
tools: PlanTool[];
|
||||
prompt: { status: string; name?: string };
|
||||
models: PlanModel[];
|
||||
workflow?: {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
} | null;
|
||||
};
|
||||
|
||||
interface ImportAgentModalProps {
|
||||
@@ -69,7 +75,6 @@ export default function ImportAgentModal({
|
||||
const token = useSelector(selectToken);
|
||||
const sourceDocs = useSelector(selectSourceDocs);
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [yamlText, setYamlText] = useState<string>('');
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
@@ -85,7 +90,7 @@ export default function ImportAgentModal({
|
||||
const [modelKeys, setModelKeys] = useState<Record<string, string>>({});
|
||||
const [warnings, setWarnings] = useState<string[] | null>(null);
|
||||
const [importedStatus, setImportedStatus] = useState<string | null>(null);
|
||||
const [goToAgentId, setGoToAgentId] = useState<string | null>(null);
|
||||
const [goToEditPath, setGoToEditPath] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setYamlText('');
|
||||
@@ -99,7 +104,7 @@ export default function ImportAgentModal({
|
||||
setModelKeys({});
|
||||
setWarnings(null);
|
||||
setImportedStatus(null);
|
||||
setGoToAgentId(null);
|
||||
setGoToEditPath(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -107,9 +112,7 @@ export default function ImportAgentModal({
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
const processFile = async (selectedFile: File) => {
|
||||
const name = selectedFile.name.toLowerCase();
|
||||
if (!name.endsWith('.yaml') && !name.endsWith('.yml')) {
|
||||
setError(t('modals.importAgent.invalidFileType'));
|
||||
@@ -121,6 +124,29 @@ export default function ImportAgentModal({
|
||||
setYamlText(await selectedFile.text());
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } =
|
||||
useDropzone({
|
||||
onDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
// A rejected file never reaches acceptedFiles, so without this the
|
||||
// drop is a silent no-op and any previously picked file stays staged.
|
||||
if (fileRejections.length > 0) {
|
||||
setFileName('');
|
||||
setYamlText('');
|
||||
setError(t('modals.importAgent.invalidFileType'));
|
||||
return;
|
||||
}
|
||||
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 for odd MIME
|
||||
// reports.
|
||||
accept: {
|
||||
'application/x-yaml': ['.yaml', '.yml'],
|
||||
'text/yaml': ['.yaml', '.yml'],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!yamlText) return;
|
||||
setLoading(true);
|
||||
@@ -187,16 +213,20 @@ export default function ImportAgentModal({
|
||||
return;
|
||||
}
|
||||
const agentId = data.agent_id as string;
|
||||
const editPath =
|
||||
data.agent_type === 'workflow'
|
||||
? `/agents/workflow/edit/${agentId}`
|
||||
: `/agents/edit/${agentId}`;
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
// Keep the modal open so the user sees what was skipped.
|
||||
setGoToAgentId(agentId);
|
||||
setGoToEditPath(editPath);
|
||||
setWarnings(data.warnings as string[]);
|
||||
setImportedStatus((data.status as string) || null);
|
||||
setPlan(null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
navigate(`/agents/edit/${agentId}`);
|
||||
navigate(editPath);
|
||||
} catch {
|
||||
setError(t('modals.importAgent.importError'));
|
||||
} finally {
|
||||
@@ -217,9 +247,9 @@ export default function ImportAgentModal({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const id = goToAgentId;
|
||||
const path = goToEditPath;
|
||||
handleClose();
|
||||
if (id) navigate(`/agents/edit/${id}`);
|
||||
if (path) navigate(path);
|
||||
}}
|
||||
className="rounded-3xl px-5"
|
||||
>
|
||||
@@ -297,8 +327,15 @@ export default function ImportAgentModal({
|
||||
{t('modals.importAgent.description')}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-border hover:border-primary flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors"
|
||||
{...getRootProps({
|
||||
className: `border-border hover:border-primary flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors ${
|
||||
isDragReject
|
||||
? 'border-destructive'
|
||||
: isDragActive
|
||||
? 'border-primary'
|
||||
: ''
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={Upload}
|
||||
@@ -308,14 +345,8 @@ export default function ImportAgentModal({
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fileName || t('modals.importAgent.dropzoneText')}
|
||||
</p>
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
@@ -328,6 +359,29 @@ export default function ImportAgentModal({
|
||||
: t('modals.importAgent.willCreate')}
|
||||
</div>
|
||||
|
||||
{plan.workflow &&
|
||||
(plan.workflow.action === 'delete' ? (
|
||||
// Irreversible: the graph, its run history and its artifacts
|
||||
// all go. Warn rather than confirming with a green check.
|
||||
<p className="text-destructive flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t('modals.importAgent.workflowDelete', {
|
||||
nodes: plan.workflow.nodes,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
{plan.workflow.action === 'update'
|
||||
? t('modals.importAgent.workflowUpdate', {
|
||||
nodes: plan.workflow.nodes,
|
||||
})
|
||||
: t('modals.importAgent.workflowCreate', {
|
||||
nodes: plan.workflow.nodes,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{plan.sources.length > 0 && (
|
||||
<Section title={t('modals.importAgent.sources')}>
|
||||
{plan.sources.map((s) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
@@ -36,7 +37,6 @@ export default function ImportSpecModal({
|
||||
}: ImportSpecModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -57,10 +57,7 @@ export default function ImportSpecModal({
|
||||
setBaseUrl('');
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
const processFile = (selectedFile: File) => {
|
||||
const validExtensions = ['.json', '.yaml', '.yml'];
|
||||
const hasValidExtension = validExtensions.some((ext) =>
|
||||
selectedFile.name.toLowerCase().endsWith(ext),
|
||||
@@ -76,6 +73,29 @@ export default function ImportSpecModal({
|
||||
setParsedResult(null);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } =
|
||||
useDropzone({
|
||||
onDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
// A rejected file never reaches acceptedFiles, so without this the
|
||||
// drop is a silent no-op and any previously picked file stays staged.
|
||||
if (fileRejections.length > 0) {
|
||||
setFile(null);
|
||||
setParsedResult(null);
|
||||
setError(t('modals.importSpec.invalidFileType'));
|
||||
return;
|
||||
}
|
||||
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 () => {
|
||||
if (!file) return;
|
||||
|
||||
@@ -196,8 +216,15 @@ export default function ImportSpecModal({
|
||||
</p>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-border dark:border-border hover:border-primary dark:hover:border-primary flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors"
|
||||
{...getRootProps({
|
||||
className: `border-border dark:border-border hover:border-primary dark:hover:border-primary flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors ${
|
||||
isDragReject
|
||||
? 'border-destructive'
|
||||
: isDragActive
|
||||
? 'border-primary'
|
||||
: ''
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={Upload}
|
||||
@@ -210,13 +237,7 @@ export default function ImportSpecModal({
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('modals.importSpec.supportedFormats')}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -286,10 +286,29 @@ def test_parse_rejects_bad_documents():
|
||||
parse_agent_yaml("kind: Agent\napiVersion: other/v1\nspec:\n name: x\n")
|
||||
with pytest.raises(AgentImportError):
|
||||
parse_agent_yaml("kind: Agent\napiVersion: docsgpt.arc53.com/v1\nspec: {}\n")
|
||||
# A workflow agent without a graph parses (drafts may have none)…
|
||||
doc = parse_agent_yaml(
|
||||
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
|
||||
"spec:\n name: x\n agent_type: workflow\n"
|
||||
)
|
||||
assert doc["spec"]["agent_type"] == "workflow"
|
||||
# …but a present workflow block must be well-formed.
|
||||
with pytest.raises(AgentImportError):
|
||||
parse_agent_yaml(
|
||||
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
|
||||
"spec:\n name: x\n agent_type: workflow\n workflow: nonsense\n"
|
||||
)
|
||||
with pytest.raises(AgentImportError):
|
||||
parse_agent_yaml(
|
||||
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
|
||||
"spec:\n name: x\n agent_type: workflow\n"
|
||||
" workflow:\n nodes: {}\n edges: []\n"
|
||||
)
|
||||
with pytest.raises(AgentImportError):
|
||||
parse_agent_yaml(
|
||||
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
|
||||
"spec:\n name: x\n agent_type: workflow\n"
|
||||
" workflow:\n nodes:\n - id: n1\n edges: []\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -478,3 +497,328 @@ def test_tool_skipped_when_encryption_fails(pg_conn, monkeypatch):
|
||||
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
|
||||
assert agent["tools"] == []
|
||||
assert any("encryption failed" in w for w in result["warnings"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action customizations (per-parameter fixed values / filled_by_llm flags)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SEND_MESSAGE_META = {
|
||||
"name": "telegram_send_message",
|
||||
"description": "Send a message",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {"type": "string", "description": "Chat id"},
|
||||
"text": {"type": "string", "description": "Message text"},
|
||||
},
|
||||
"required": ["chat_id", "text"],
|
||||
},
|
||||
}
|
||||
_GET_UPDATES_META = {
|
||||
"name": "telegram_get_updates",
|
||||
"description": "Poll updates",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
|
||||
def _fake_telegram(monkeypatch):
|
||||
import copy as _copy
|
||||
|
||||
fake_tool = Mock()
|
||||
fake_tool.get_config_requirements.return_value = {
|
||||
"token": {"secret": True, "required": True, "label": "Bot token"}
|
||||
}
|
||||
fake_tool.get_actions_metadata.side_effect = lambda: _copy.deepcopy(
|
||||
[_SEND_MESSAGE_META, _GET_UPDATES_META]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"application.api.user.agents.portability._tool_instance",
|
||||
lambda tool_type: fake_tool,
|
||||
)
|
||||
return fake_tool
|
||||
|
||||
|
||||
def _stored_telegram_actions():
|
||||
"""The shape ``update_tool_actions`` persists after the user fixes chat_id
|
||||
and disables the updates action."""
|
||||
return [
|
||||
{
|
||||
"name": "telegram_send_message",
|
||||
"description": "Send a message",
|
||||
"active": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"description": "Chat id",
|
||||
"filled_by_llm": False,
|
||||
"value": "123456789",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text",
|
||||
"filled_by_llm": True,
|
||||
"value": "",
|
||||
},
|
||||
},
|
||||
"required": ["chat_id", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_get_updates",
|
||||
"description": "Poll updates",
|
||||
"active": False,
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_action_customizations_round_trip(pg_conn, monkeypatch):
|
||||
user = "u_actions"
|
||||
_fake_telegram(monkeypatch)
|
||||
tool = UserToolsRepository(pg_conn).create(
|
||||
user,
|
||||
"telegram",
|
||||
config={"encrypted_credentials": "BLOB"},
|
||||
custom_name="My TG",
|
||||
display_name="Telegram",
|
||||
description="tg",
|
||||
config_requirements={"token": {"secret": True, "required": True}},
|
||||
actions=_stored_telegram_actions(),
|
||||
)
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "TG Bot", "published", description="d", tools=[str(tool["id"])]
|
||||
)
|
||||
|
||||
export = serialize_agent(pg_conn, agent, user)
|
||||
overrides = export["spec"]["tools"][0]["actions"]
|
||||
# Only deviations from factory defaults travel: the fixed chat_id and the
|
||||
# disabled action — the default-state ``text`` param does not.
|
||||
assert overrides == [
|
||||
{
|
||||
"name": "telegram_send_message",
|
||||
"parameters": {"chat_id": {"filled_by_llm": False, "value": "123456789"}},
|
||||
},
|
||||
{"name": "telegram_get_updates", "active": False},
|
||||
]
|
||||
|
||||
# Import as a fresh user (tool re-created with the supplied secret).
|
||||
doc = parse_agent_yaml(agent_to_yaml(export))
|
||||
importer = "u_actions_2"
|
||||
result = apply_import(
|
||||
pg_conn,
|
||||
importer,
|
||||
doc,
|
||||
resolution={"tools": {"tool-0": {"decision": "create", "secrets": {"token": "tok"}}}},
|
||||
)
|
||||
imported_agent = AgentsRepository(pg_conn).get(result["agent_id"], importer)
|
||||
row = UserToolsRepository(pg_conn).get_any(imported_agent["tools"][0], importer)
|
||||
by_name = {a["name"]: a for a in row["actions"]}
|
||||
chat_id = by_name["telegram_send_message"]["parameters"]["properties"]["chat_id"]
|
||||
text = by_name["telegram_send_message"]["parameters"]["properties"]["text"]
|
||||
assert chat_id["filled_by_llm"] is False
|
||||
assert chat_id["value"] == "123456789"
|
||||
assert text["filled_by_llm"] is True and text["value"] == ""
|
||||
assert by_name["telegram_get_updates"]["active"] is False
|
||||
assert by_name["telegram_send_message"]["active"] is True
|
||||
|
||||
|
||||
def test_action_header_values_never_exported(pg_conn):
|
||||
user = "u_actions_hdr"
|
||||
tool = UserToolsRepository(pg_conn).create(
|
||||
user,
|
||||
"custom_http",
|
||||
config={},
|
||||
custom_name="HTTP",
|
||||
display_name="HTTP",
|
||||
description="",
|
||||
config_requirements={},
|
||||
actions=[
|
||||
{
|
||||
"name": "call",
|
||||
"active": True,
|
||||
"headers": {
|
||||
"properties": {
|
||||
"Authorization": {
|
||||
"type": "string",
|
||||
"filled_by_llm": False,
|
||||
"value": "Bearer sk-live-XYZ",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "Hdr Bot", "published", description="d", tools=[str(tool["id"])]
|
||||
)
|
||||
|
||||
export = serialize_agent(pg_conn, agent, user)
|
||||
text = agent_to_yaml(export)
|
||||
assert "Bearer sk-live-XYZ" not in text
|
||||
# The flag still travels so the imported action keeps its shape.
|
||||
override = export["spec"]["tools"][0]["actions"][0]
|
||||
assert override["headers"]["Authorization"] == {"filled_by_llm": False}
|
||||
|
||||
|
||||
def test_action_override_for_removed_action_warns(pg_conn, monkeypatch):
|
||||
user = "u_actions_gone"
|
||||
_fake_telegram(monkeypatch)
|
||||
doc = _doc(
|
||||
name="Drifted Bot",
|
||||
_slug="drifted",
|
||||
tools=[
|
||||
{
|
||||
"type": "telegram",
|
||||
"name": "My TG",
|
||||
"config": {},
|
||||
"requires_secrets": ["token"],
|
||||
"actions": [
|
||||
{"name": "telegram_removed_action", "active": False},
|
||||
{
|
||||
"name": "telegram_send_message",
|
||||
"parameters": {
|
||||
"gone_param": {"filled_by_llm": False, "value": "x"},
|
||||
"chat_id": {"filled_by_llm": False, "value": "42"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
result = apply_import(
|
||||
pg_conn,
|
||||
user,
|
||||
doc,
|
||||
resolution={"tools": {"tool-0": {"secrets": {"token": "tok"}}}},
|
||||
)
|
||||
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
|
||||
row = UserToolsRepository(pg_conn).get_any(agent["tools"][0], user)
|
||||
by_name = {a["name"]: a for a in row["actions"]}
|
||||
# The surviving customization applied; the drifted ones warned and skipped.
|
||||
chat_id = by_name["telegram_send_message"]["parameters"]["properties"]["chat_id"]
|
||||
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"] == {}
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Workflow-agent YAML export/import (application.api.user.agents.portability).
|
||||
|
||||
Real SQL against the ephemeral ``pg_conn`` fixture, like
|
||||
``test_agent_portability.py``. Covers the graph block: reference rewriting
|
||||
(tools → ``tool-N`` keys, sources → names, custom models → display names),
|
||||
secret redaction inside node configs, graph validation at apply, and
|
||||
idempotent re-import as a new graph version of the same workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from application.agents.default_tools import default_tool_id
|
||||
from application.api.user.agents.portability import (
|
||||
AgentImportError,
|
||||
agent_to_yaml,
|
||||
apply_import,
|
||||
parse_agent_yaml,
|
||||
plan_import,
|
||||
serialize_agent,
|
||||
)
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
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.repositories.workflow_edges import WorkflowEdgesRepository
|
||||
from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _graph(conn, workflow_row):
|
||||
version = int(workflow_row.get("current_graph_version") or 1)
|
||||
nodes = WorkflowNodesRepository(conn).find_by_version(str(workflow_row["id"]), version)
|
||||
edges = WorkflowEdgesRepository(conn).find_by_version(str(workflow_row["id"]), version)
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _seed_workflow_agent(conn, user, *, tool_id=None, source_id=None, model_id=None):
|
||||
"""Workflow agent with a start → agent → end graph referencing real resources."""
|
||||
agent_cfg = {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "Answer briefly.",
|
||||
"tools": [t for t in [tool_id, default_tool_id("read_document")] if t],
|
||||
"sources": [s for s in [source_id] if s],
|
||||
"model_id": model_id,
|
||||
}
|
||||
wf = WorkflowsRepository(conn).create(user, "Pipeline", description="wd")
|
||||
wf_id = str(wf["id"])
|
||||
nodes = WorkflowNodesRepository(conn).bulk_create(
|
||||
wf_id,
|
||||
1,
|
||||
[
|
||||
{"node_id": "start-1", "node_type": "start", "title": "Start"},
|
||||
{"node_id": "agent-1", "node_type": "agent", "title": "Answer", "config": agent_cfg},
|
||||
{"node_id": "end-1", "node_type": "end", "title": "End"},
|
||||
],
|
||||
)
|
||||
by_str = {n["node_id"]: n["id"] for n in nodes}
|
||||
WorkflowEdgesRepository(conn).bulk_create(
|
||||
wf_id,
|
||||
1,
|
||||
[
|
||||
{"edge_id": "e1", "from_node_id": by_str["start-1"], "to_node_id": by_str["agent-1"]},
|
||||
{"edge_id": "e2", "from_node_id": by_str["agent-1"], "to_node_id": by_str["end-1"]},
|
||||
],
|
||||
)
|
||||
agent = AgentsRepository(conn).create(
|
||||
user,
|
||||
"Flow Bot",
|
||||
"published",
|
||||
description="wf agent",
|
||||
agent_type="workflow",
|
||||
workflow_id=wf_id,
|
||||
)
|
||||
return agent, wf
|
||||
|
||||
|
||||
def _seed_tool(conn, user):
|
||||
return UserToolsRepository(conn).create(
|
||||
user,
|
||||
"brave",
|
||||
config={"encrypted_credentials": "SECRETBLOB", "region": "US"},
|
||||
custom_name="My Brave",
|
||||
display_name="Brave Search",
|
||||
description="search",
|
||||
config_requirements={"token": {"secret": True, "required": True}, "region": {}},
|
||||
actions=[],
|
||||
)
|
||||
|
||||
|
||||
def test_export_rewrites_node_refs_and_redacts_secrets(pg_conn):
|
||||
user = "u_wf_export"
|
||||
tool = _seed_tool(pg_conn, user)
|
||||
src = SourcesRepository(pg_conn).create("KB", user_id=user, type="file")
|
||||
model = UserCustomModelsRepository(pg_conn).create(
|
||||
user, "llama-3", "My Llama", "https://models.example.com/v1", "sk-plain"
|
||||
)
|
||||
agent, wf = _seed_workflow_agent(
|
||||
pg_conn,
|
||||
user,
|
||||
tool_id=str(tool["id"]),
|
||||
source_id=str(src["id"]),
|
||||
model_id=str(model["id"]),
|
||||
)
|
||||
|
||||
export = serialize_agent(pg_conn, agent, user)
|
||||
text = agent_to_yaml(export)
|
||||
spec = export["spec"]
|
||||
|
||||
assert "SECRETBLOB" not in text and "sk-plain" not in text
|
||||
# Raw ids never appear inside the graph block (the sections may carry
|
||||
# ``ref`` hints, same as classic export).
|
||||
workflow_text = agent_to_yaml(spec["workflow"])
|
||||
assert str(tool["id"]) not in workflow_text
|
||||
assert str(src["id"]) not in workflow_text
|
||||
assert str(model["id"]) not in workflow_text
|
||||
|
||||
assert spec["workflow"]["name"] == "Pipeline"
|
||||
assert [n["id"] for n in spec["workflow"]["nodes"]] == ["agent-1", "end-1", "start-1"]
|
||||
agent_node = next(n for n in spec["workflow"]["nodes"] if n["id"] == "agent-1")
|
||||
assert agent_node["config"]["tools"] == ["tool-0", "tool-1"]
|
||||
assert agent_node["config"]["sources"] == ["KB"]
|
||||
assert agent_node["config"]["model_id"] == "My Llama"
|
||||
|
||||
# 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,
|
||||
"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"
|
||||
|
||||
edges = spec["workflow"]["edges"]
|
||||
assert {(e["source"], e["target"]) for e in edges} == {
|
||||
("start-1", "agent-1"),
|
||||
("agent-1", "end-1"),
|
||||
}
|
||||
|
||||
|
||||
def test_export_without_workflow_emits_null_block(pg_conn):
|
||||
user = "u_wf_export2"
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "Draft Flow", "draft", agent_type="workflow"
|
||||
)
|
||||
export = serialize_agent(pg_conn, agent, user)
|
||||
assert export["spec"]["workflow"] is None
|
||||
assert export["spec"]["tools"] == []
|
||||
assert export["spec"]["sources"] == []
|
||||
|
||||
|
||||
def test_round_trip_same_user_reuses_everything(pg_conn):
|
||||
user = "u_wf_round"
|
||||
tool = _seed_tool(pg_conn, user)
|
||||
src = SourcesRepository(pg_conn).create("KB", user_id=user, type="file")
|
||||
model = UserCustomModelsRepository(pg_conn).create(
|
||||
user, "llama-3", "My Llama", "https://models.example.com/v1", "sk-plain"
|
||||
)
|
||||
agent, wf = _seed_workflow_agent(
|
||||
pg_conn,
|
||||
user,
|
||||
tool_id=str(tool["id"]),
|
||||
source_id=str(src["id"]),
|
||||
model_id=str(model["id"]),
|
||||
)
|
||||
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
|
||||
assert result["action"] == "updated"
|
||||
assert result["agent_id"] == str(agent["id"])
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
# Same workflow row, bumped to a new graph version.
|
||||
assert str(updated["workflow_id"]) == str(wf["id"])
|
||||
wf_row = WorkflowsRepository(pg_conn).get(str(wf["id"]), user)
|
||||
assert int(wf_row["current_graph_version"]) == 2
|
||||
nodes, edges = _graph(pg_conn, wf_row)
|
||||
assert len(nodes) == 3 and len(edges) == 2
|
||||
agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
|
||||
# References resolved back to the same owned resources — no duplicates.
|
||||
assert set(agent_node["config"]["tools"]) == {
|
||||
str(tool["id"]), default_tool_id("read_document"),
|
||||
}
|
||||
assert agent_node["config"]["sources"] == [str(src["id"])]
|
||||
assert agent_node["config"]["model_id"] == str(model["id"])
|
||||
assert len(UserToolsRepository(pg_conn).list_for_user(user)) == 1
|
||||
# Old graph versions are pruned.
|
||||
assert WorkflowNodesRepository(pg_conn).find_by_version(str(wf["id"]), 1) == []
|
||||
|
||||
|
||||
def test_import_into_fresh_user_creates_workflow(pg_conn):
|
||||
owner = "u_wf_owner"
|
||||
tool = _seed_tool(pg_conn, owner)
|
||||
src = SourcesRepository(pg_conn).create("KB", user_id=owner, type="file")
|
||||
agent, _ = _seed_workflow_agent(
|
||||
pg_conn, owner, tool_id=str(tool["id"]), source_id=str(src["id"])
|
||||
)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, owner)))
|
||||
|
||||
importer = "u_wf_importer"
|
||||
result = apply_import(
|
||||
pg_conn,
|
||||
importer,
|
||||
doc,
|
||||
{"tools": {"tool-0": {"decision": "create", "secrets": {"token": "tok"}}}},
|
||||
)
|
||||
|
||||
assert result["action"] == "created"
|
||||
created = AgentsRepository(pg_conn).get(result["agent_id"], importer)
|
||||
assert created["status"] == "draft"
|
||||
assert created["workflow_id"] is not None
|
||||
wf_row = WorkflowsRepository(pg_conn).get(str(created["workflow_id"]), importer)
|
||||
assert wf_row is not None and wf_row["name"] == "Pipeline"
|
||||
nodes, edges = _graph(pg_conn, wf_row)
|
||||
assert len(nodes) == 3 and len(edges) == 2
|
||||
agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
|
||||
# The tool was created for the importer; the missing source was dropped.
|
||||
my_tool = UserToolsRepository(pg_conn).list_for_user(importer)[0]
|
||||
assert set(agent_node["config"]["tools"]) == {
|
||||
str(my_tool["id"]), default_tool_id("read_document"),
|
||||
}
|
||||
assert agent_node["config"]["sources"] == []
|
||||
assert any("Source 'KB' not found" in w for w in result["warnings"])
|
||||
assert any("not resolved" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_plan_reports_workflow_block(pg_conn):
|
||||
user = "u_wf_plan"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
|
||||
plan = plan_import(pg_conn, user, doc)
|
||||
assert plan["workflow"] == {"nodes": 3, "edges": 2, "action": "update"}
|
||||
|
||||
other_plan = plan_import(pg_conn, "someone_else", doc)
|
||||
assert other_plan["workflow"]["action"] == "create"
|
||||
|
||||
|
||||
def test_import_rejects_invalid_graph(pg_conn):
|
||||
user = "u_wf_invalid"
|
||||
doc = {
|
||||
"apiVersion": "docsgpt.arc53.com/v1",
|
||||
"kind": "Agent",
|
||||
"metadata": {"slug": "broken-flow"},
|
||||
"spec": {
|
||||
"name": "Broken Flow",
|
||||
"agent_type": "workflow",
|
||||
"workflow": {
|
||||
"name": "Broken",
|
||||
# No start/end nodes — the API's validation gate must reject it.
|
||||
"nodes": [{"id": "a", "type": "agent", "config": {}}],
|
||||
"edges": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
with pytest.raises(AgentImportError, match="Workflow validation failed"):
|
||||
apply_import(pg_conn, user, doc)
|
||||
# Nothing half-imported.
|
||||
assert AgentsRepository(pg_conn).list_for_user(user) == []
|
||||
assert WorkflowsRepository(pg_conn).list_for_user(user) == []
|
||||
|
||||
|
||||
def test_import_drops_foreign_raw_ids_from_node_config(pg_conn):
|
||||
"""A hand-edited file carrying someone else's raw ids must not link them."""
|
||||
victim = "u_wf_victim"
|
||||
victim_tool = _seed_tool(pg_conn, victim)
|
||||
victim_src = SourcesRepository(pg_conn).create("Secret KB", user_id=victim, type="file")
|
||||
|
||||
doc = {
|
||||
"apiVersion": "docsgpt.arc53.com/v1",
|
||||
"kind": "Agent",
|
||||
"metadata": {"slug": "sneaky-flow"},
|
||||
"spec": {
|
||||
"name": "Sneaky Flow",
|
||||
"agent_type": "workflow",
|
||||
"workflow": {
|
||||
"name": "Sneaky",
|
||||
"nodes": [
|
||||
{"id": "s", "type": "start"},
|
||||
{
|
||||
"id": "a",
|
||||
"type": "agent",
|
||||
"config": {
|
||||
"tools": [str(victim_tool["id"])],
|
||||
"sources": [str(victim_src["id"])],
|
||||
},
|
||||
},
|
||||
{"id": "e", "type": "end"},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "s", "target": "a"},
|
||||
{"id": "e2", "source": "a", "target": "e"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
result = apply_import(pg_conn, "u_wf_attacker", doc)
|
||||
created = AgentsRepository(pg_conn).get(result["agent_id"], "u_wf_attacker")
|
||||
wf_row = WorkflowsRepository(pg_conn).get(str(created["workflow_id"]), "u_wf_attacker")
|
||||
nodes, _ = _graph(pg_conn, wf_row)
|
||||
agent_node = next(n for n in nodes if n["node_id"] == "a")
|
||||
assert agent_node["config"]["tools"] == []
|
||||
assert agent_node["config"]["sources"] == []
|
||||
assert len(result["warnings"]) == 2
|
||||
|
||||
|
||||
def test_import_route_rejects_invalid_graph_as_client_error(pg_conn):
|
||||
"""Apply-time graph rejection is a 400 carrying the validation detail.
|
||||
|
||||
Regression: an ``AgentImportError`` raised while applying (the file parses,
|
||||
but the graph fails the workflow API's validation gate) used to fall into
|
||||
the route's generic handler and surface as a 500 with no explanation.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from application.api.user.agents.portability import ImportAgent
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield pg_conn
|
||||
|
||||
yaml_text = (
|
||||
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
|
||||
"spec:\n"
|
||||
" name: Broken Flow\n"
|
||||
" agent_type: workflow\n"
|
||||
" workflow:\n"
|
||||
" name: Broken\n"
|
||||
" nodes:\n"
|
||||
" - id: a\n"
|
||||
" type: agent\n"
|
||||
" edges: []\n"
|
||||
)
|
||||
app = Flask(__name__)
|
||||
with patch(
|
||||
"application.api.user.agents.portability.db_session", _conn
|
||||
), app.test_request_context(
|
||||
"/api/import_agent", method="POST", json={"yaml": yaml_text}
|
||||
):
|
||||
request.decoded_token = {"sub": "u_wf_route"}
|
||||
response = ImportAgent().post()
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["success"] is False
|
||||
assert "Workflow validation failed" in body["message"]
|
||||
assert AgentsRepository(pg_conn).list_for_user("u_wf_route") == []
|
||||
|
||||
|
||||
def test_import_without_workflow_keeps_published_agents_graph(pg_conn):
|
||||
user = "u_wf_keep"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
del doc["spec"]["workflow"]
|
||||
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
assert result["action"] == "updated"
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
assert str(updated["workflow_id"]) == str(wf["id"])
|
||||
|
||||
|
||||
def test_import_with_null_workflow_keeps_published_but_clears_draft(pg_conn):
|
||||
user = "u_wf_null"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
doc["spec"]["workflow"] = None
|
||||
|
||||
# Published agent: explicit null must not break it — kept with a warning.
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
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 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
|
||||
|
||||
|
||||
def test_publish_guard_rejects_workflow_agent_with_no_graph(pg_conn):
|
||||
"""A file that makes a published agent a graph-less workflow agent is refused.
|
||||
|
||||
The create and update routes both reject that state; import must not
|
||||
install it through the back door.
|
||||
"""
|
||||
user = "u_wf_publish_guard"
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "Probe Bot", "published", agent_type="classic", slug="probe-bot"
|
||||
)
|
||||
doc = {
|
||||
"apiVersion": "docsgpt.arc53.com/v1",
|
||||
"kind": "Agent",
|
||||
"metadata": {"id": str(agent["id"]), "slug": "probe-bot"},
|
||||
"spec": {"name": "Probe Bot", "agent_type": "workflow"},
|
||||
}
|
||||
|
||||
# Key omitted entirely.
|
||||
with pytest.raises(AgentImportError, match="published workflow agent needs a workflow"):
|
||||
apply_import(pg_conn, user, doc)
|
||||
|
||||
# Explicit null, with no prior workflow to fall back on.
|
||||
doc["spec"]["workflow"] = None
|
||||
with pytest.raises(AgentImportError, match="published workflow agent needs a workflow"):
|
||||
apply_import(pg_conn, user, doc)
|
||||
|
||||
unchanged = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
assert unchanged["agent_type"] == "classic"
|
||||
assert unchanged["status"] == "published"
|
||||
|
||||
|
||||
def test_plan_distinguishes_absent_workflow_from_explicit_null(pg_conn):
|
||||
"""Omitting the key is a no-op; an explicit null destroys the graph.
|
||||
|
||||
The two used to produce an identical plan, so the modal showed nothing
|
||||
either way while the outcomes differed completely.
|
||||
"""
|
||||
user = "u_wf_plan_null"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
|
||||
absent = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
del absent["spec"]["workflow"]
|
||||
assert plan_import(pg_conn, user, absent)["workflow"] is None
|
||||
|
||||
doc["spec"]["workflow"] = None
|
||||
assert plan_import(pg_conn, user, doc)["workflow"] == {
|
||||
"action": "delete",
|
||||
"nodes": 3,
|
||||
"edges": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_plan_reports_no_removal_when_workflow_survives(pg_conn):
|
||||
"""No delete block when the row lives on — published, or still referenced."""
|
||||
user = "u_wf_plan_keep"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
doc["spec"]["workflow"] = None
|
||||
|
||||
# Published: _apply_workflow keeps the existing graph.
|
||||
assert plan_import(pg_conn, user, doc)["workflow"] is None
|
||||
|
||||
# Draft, but a sibling agent still references the row.
|
||||
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
|
||||
AgentsRepository(pg_conn).create(
|
||||
user, "Sibling", "draft", agent_type="workflow", workflow_id=str(wf["id"])
|
||||
)
|
||||
assert plan_import(pg_conn, user, doc)["workflow"] is None
|
||||
|
||||
|
||||
def test_null_workflow_deletion_is_warned_about(pg_conn):
|
||||
"""The destructive reap must not be silent."""
|
||||
user = "u_wf_null_warn"
|
||||
agent, wf = _seed_workflow_agent(pg_conn, user)
|
||||
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"})
|
||||
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is None
|
||||
assert any("was deleted" in w and "run history" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_duplicate_source_names_warn_and_dedupe(pg_conn):
|
||||
"""Two sources sharing a name collapse onto the oldest — say so."""
|
||||
user = "u_wf_dup_src"
|
||||
repo = SourcesRepository(pg_conn)
|
||||
older = repo.create("KB", user_id=user, type="file")
|
||||
newer = repo.create("KB", user_id=user, type="file")
|
||||
agent, _wf = _seed_workflow_agent(pg_conn, user, source_id=str(older["id"]))
|
||||
|
||||
# Point the node at both same-named sources.
|
||||
version = 1
|
||||
nodes_repo = WorkflowNodesRepository(pg_conn)
|
||||
node = [n for n in nodes_repo.find_by_version(str(agent["workflow_id"]), version)
|
||||
if n["node_type"] == "agent"][0]
|
||||
config = dict(node["config"])
|
||||
config["sources"] = [str(older["id"]), str(newer["id"])]
|
||||
nodes_repo.bulk_create(
|
||||
str(agent["workflow_id"]),
|
||||
version,
|
||||
[{
|
||||
"node_id": node["node_id"],
|
||||
"node_type": node["node_type"],
|
||||
"title": node.get("title"),
|
||||
"position": node.get("position"),
|
||||
"config": config,
|
||||
}],
|
||||
)
|
||||
|
||||
doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
|
||||
assert [s["name"] for s in doc["spec"]["sources"]] == ["KB", "KB"]
|
||||
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
assert any("share the name 'KB'" in w for w in result["warnings"])
|
||||
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
graph_nodes, _ = _graph(pg_conn, WorkflowsRepository(pg_conn).get(str(updated["workflow_id"]), user))
|
||||
agent_node = [n for n in graph_nodes if n["node_type"] == "agent"][0]
|
||||
# Collapsed onto the oldest match, and not stored twice.
|
||||
assert agent_node["config"]["sources"] == [str(older["id"])]
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Workflow-agent behavior of the agents routes, against real Postgres.
|
||||
|
||||
Rebuilds the workflow-related coverage of the retired Mongo-era
|
||||
``tests/api/user/test_agents_routes.py`` on the ``pg_conn`` fixture:
|
||||
adopting a workflow template (the graph must be deep-copied, never
|
||||
shared by id), deleting a workflow agent (only the owner's graph may
|
||||
go), and creating/updating agents with a workflow reference.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.agents.routes.db_session", _yield
|
||||
), patch(
|
||||
"application.api.user.agents.routes.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _seed_workflow(pg_conn, user, *, name="WF"):
|
||||
"""Create a start → agent → end workflow; returns (workflow, nodes, edges)."""
|
||||
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
|
||||
|
||||
wf = WorkflowsRepository(pg_conn).create(user, name, description="d")
|
||||
wf_id = str(wf["id"])
|
||||
nodes = WorkflowNodesRepository(pg_conn).bulk_create(
|
||||
wf_id,
|
||||
1,
|
||||
[
|
||||
{"node_id": "start-1", "node_type": "start", "title": "Start"},
|
||||
{
|
||||
"node_id": "agent-1",
|
||||
"node_type": "agent",
|
||||
"title": "Agent",
|
||||
"config": {"system_prompt": "hi", "tools": [], "sources": []},
|
||||
},
|
||||
{"node_id": "end-1", "node_type": "end", "title": "End"},
|
||||
],
|
||||
)
|
||||
by_str = {n["node_id"]: n["id"] for n in nodes}
|
||||
edges = WorkflowEdgesRepository(pg_conn).bulk_create(
|
||||
wf_id,
|
||||
1,
|
||||
[
|
||||
{
|
||||
"edge_id": "e1",
|
||||
"from_node_id": by_str["start-1"],
|
||||
"to_node_id": by_str["agent-1"],
|
||||
},
|
||||
{
|
||||
"edge_id": "e2",
|
||||
"from_node_id": by_str["agent-1"],
|
||||
"to_node_id": by_str["end-1"],
|
||||
},
|
||||
],
|
||||
)
|
||||
return wf, nodes, edges
|
||||
|
||||
|
||||
def _graph(pg_conn, workflow_id, version=1):
|
||||
from application.storage.db.repositories.workflow_edges import (
|
||||
WorkflowEdgesRepository,
|
||||
)
|
||||
from application.storage.db.repositories.workflow_nodes import (
|
||||
WorkflowNodesRepository,
|
||||
)
|
||||
|
||||
nodes = WorkflowNodesRepository(pg_conn).find_by_version(str(workflow_id), version)
|
||||
edges = WorkflowEdgesRepository(pg_conn).find_by_version(str(workflow_id), version)
|
||||
return nodes, edges
|
||||
|
||||
|
||||
class TestCloneToUser:
|
||||
def test_clones_graph_for_new_owner(self, pg_conn):
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "owner-a", name="Source WF")
|
||||
clone = WorkflowsRepository(pg_conn).clone_to_user(
|
||||
str(wf["id"]), "owner-b", from_owner="owner-a"
|
||||
)
|
||||
assert clone is not None
|
||||
assert str(clone["id"]) != str(wf["id"])
|
||||
assert clone["user_id"] == "owner-b"
|
||||
assert clone["name"] == "Source WF"
|
||||
|
||||
nodes, edges = _graph(pg_conn, clone["id"])
|
||||
assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
|
||||
agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
|
||||
assert agent_node["config"] == {
|
||||
"system_prompt": "hi", "tools": [], "sources": [],
|
||||
}
|
||||
# Edges are re-keyed onto the clone's node rows, not the source's.
|
||||
clone_node_uuids = {n["id"] for n in nodes}
|
||||
assert len(edges) == 2
|
||||
for e in edges:
|
||||
assert e["from_node_id"] in clone_node_uuids
|
||||
assert e["to_node_id"] in clone_node_uuids
|
||||
# The source graph is untouched.
|
||||
src_nodes, src_edges = _graph(pg_conn, wf["id"])
|
||||
assert len(src_nodes) == 3 and len(src_edges) == 2
|
||||
|
||||
def test_wrong_owner_returns_none(self, pg_conn):
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "owner-a")
|
||||
assert (
|
||||
WorkflowsRepository(pg_conn).clone_to_user(
|
||||
str(wf["id"]), "thief", from_owner="not-the-owner"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_missing_workflow_returns_none(self, pg_conn):
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
assert (
|
||||
WorkflowsRepository(pg_conn).clone_to_user(
|
||||
"00000000-0000-0000-0000-000000000000", "u"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestAdoptWorkflowAgent:
|
||||
def test_adopt_clones_the_graph(self, app, pg_conn):
|
||||
from application.api.user.agents.routes import AdoptAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "__system__", name="Template WF")
|
||||
repo = AgentsRepository(pg_conn)
|
||||
template = repo.create(
|
||||
"__system__",
|
||||
"WF Template",
|
||||
"template",
|
||||
agent_type="workflow",
|
||||
workflow_id=str(wf["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-adopter"}
|
||||
response = AdoptAgent().post()
|
||||
assert response.status_code == 200
|
||||
|
||||
adopted = next(
|
||||
a for a in repo.list_for_user("u-wf-adopter") if a["name"] == "WF Template"
|
||||
)
|
||||
assert adopted["workflow_id"] is not None
|
||||
assert str(adopted["workflow_id"]) != str(wf["id"])
|
||||
cloned = WorkflowsRepository(pg_conn).get(
|
||||
str(adopted["workflow_id"]), "u-wf-adopter"
|
||||
)
|
||||
assert cloned is not None
|
||||
nodes, edges = _graph(pg_conn, cloned["id"])
|
||||
assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
|
||||
assert len(edges) == 2
|
||||
|
||||
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
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "someone-else")
|
||||
repo = AgentsRepository(pg_conn)
|
||||
template = repo.create(
|
||||
"__system__",
|
||||
"Broken WF Template",
|
||||
"template",
|
||||
agent_type="workflow",
|
||||
workflow_id=str(wf["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-adopter2"}
|
||||
response = AdoptAgent().post()
|
||||
assert response.status_code == 500
|
||||
assert not [
|
||||
a
|
||||
for a in repo.list_for_user("u-wf-adopter2")
|
||||
if a["name"] == "Broken WF Template"
|
||||
]
|
||||
# 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):
|
||||
from application.api.user.agents.routes import DeleteAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
user = "u-wf-del"
|
||||
wf, _, _ = _seed_workflow(pg_conn, user)
|
||||
repo = AgentsRepository(pg_conn)
|
||||
agent = repo.create(
|
||||
user, "WF Agent", "draft", agent_type="workflow", workflow_id=str(wf["id"])
|
||||
)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/delete_agent?id={agent['id']}", method="DELETE"
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
response = DeleteAgent().delete()
|
||||
assert response.status_code == 200
|
||||
assert repo.get_any(str(agent["id"]), user) is None
|
||||
assert WorkflowsRepository(pg_conn).get_by_id(str(wf["id"])) is None
|
||||
nodes, edges = _graph(pg_conn, wf["id"])
|
||||
assert nodes == [] and edges == []
|
||||
|
||||
def test_delete_leaves_unowned_workflow_intact(self, app, pg_conn):
|
||||
"""Regression: deleting an agent whose ``workflow_id`` points at another
|
||||
user's workflow (the pre-clone adopted shape) must not touch that graph.
|
||||
The old explicit node/edge cleanup was not owner-scoped and gutted it."""
|
||||
from application.api.user.agents.routes import DeleteAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "__system__", name="Shared Template WF")
|
||||
repo = AgentsRepository(pg_conn)
|
||||
agent = repo.create(
|
||||
"u-wf-del2",
|
||||
"Adopted WF Agent",
|
||||
"draft",
|
||||
agent_type="workflow",
|
||||
workflow_id=str(wf["id"]),
|
||||
)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/delete_agent?id={agent['id']}", method="DELETE"
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": "u-wf-del2"}
|
||||
response = DeleteAgent().delete()
|
||||
assert response.status_code == 200
|
||||
assert repo.get_any(str(agent["id"]), "u-wf-del2") is None
|
||||
# The system's workflow and its whole graph survive.
|
||||
assert WorkflowsRepository(pg_conn).get_by_id(str(wf["id"])) is not None
|
||||
nodes, edges = _graph(pg_conn, wf["id"])
|
||||
assert len(nodes) == 3 and len(edges) == 2
|
||||
|
||||
|
||||
class TestCreateWorkflowAgent:
|
||||
def test_create_published_with_owned_workflow(self, app, pg_conn):
|
||||
from application.api.user.agents.routes import CreateAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
user = "u-wf-create"
|
||||
wf, _, _ = _seed_workflow(pg_conn, user)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/create_agent",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "My WF Agent",
|
||||
"description": "d",
|
||||
"agent_type": "workflow",
|
||||
"status": "published",
|
||||
"workflow": str(wf["id"]),
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
response = CreateAgent().post()
|
||||
assert response.status_code == 201
|
||||
agent = next(
|
||||
a
|
||||
for a in AgentsRepository(pg_conn).list_for_user(user)
|
||||
if a["name"] == "My WF Agent"
|
||||
)
|
||||
assert str(agent["workflow_id"]) == str(wf["id"])
|
||||
assert agent["status"] == "published"
|
||||
|
||||
def test_create_published_with_unowned_workflow_returns_404(self, app, pg_conn):
|
||||
from application.api.user.agents.routes import CreateAgent
|
||||
|
||||
wf, _, _ = _seed_workflow(pg_conn, "someone-else")
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/create_agent",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "Stolen WF Agent",
|
||||
"description": "d",
|
||||
"agent_type": "workflow",
|
||||
"status": "published",
|
||||
"workflow": str(wf["id"]),
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": "u-wf-thief"}
|
||||
response = CreateAgent().post()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateWorkflowAgent:
|
||||
def test_update_sets_and_clears_workflow(self, app, pg_conn):
|
||||
from application.api.user.agents.routes import UpdateAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
user = "u-wf-update"
|
||||
wf, _, _ = _seed_workflow(pg_conn, user)
|
||||
repo = AgentsRepository(pg_conn)
|
||||
agent = repo.create(user, "WF Agent", "draft", agent_type="workflow")
|
||||
agent_id = str(agent["id"])
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/update_agent/{agent_id}",
|
||||
method="PUT",
|
||||
json={"workflow": str(wf["id"])},
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
response = UpdateAgent().put(agent_id)
|
||||
assert response.status_code == 200
|
||||
assert str(repo.get(agent_id, user)["workflow_id"]) == str(wf["id"])
|
||||
|
||||
# Clearing is allowed while the agent stays a draft.
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/update_agent/{agent_id}",
|
||||
method="PUT",
|
||||
json={"workflow": None},
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
response = UpdateAgent().put(agent_id)
|
||||
assert response.status_code == 200
|
||||
assert repo.get(agent_id, user)["workflow_id"] is None
|
||||
|
||||
def test_publish_without_workflow_is_rejected(self, app, pg_conn):
|
||||
from application.api.user.agents.routes import UpdateAgent
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
|
||||
user = "u-wf-update2"
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user, "WF Agent", "draft", agent_type="workflow"
|
||||
)
|
||||
agent_id = str(agent["id"])
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/update_agent/{agent_id}",
|
||||
method="PUT",
|
||||
json={"status": "published"},
|
||||
):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
response = UpdateAgent().put(agent_id)
|
||||
assert response.status_code == 400
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +0,0 @@
|
||||
"""Tests for application/api/user/workflows/routes.py.
|
||||
|
||||
Previously asserted on bson.ObjectId serialization. Workflow persistence is
|
||||
now Postgres-backed; coverage will be rebuilt via pg_conn + WorkflowsRepository.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="needs PG fixture rewrite - tracked separately")
|
||||
def test_user_workflows_routes_pending_pg_rewrite():
|
||||
pass
|
||||
@@ -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