Merge pull request #2690 from arc53/workflow-export

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

No files matched your search

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