mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 20:13:04 +00:00
fix: mini hardening
This commit is contained in:
1 parent
33d93ba0ca
commit
b47bfa8a37
12 files changed
+353
-74
No files matched your search
@@ -888,6 +888,38 @@ def _tool_key(index: int) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plan_workflow_removal(conn, user: str, target: dict) -> Optional[dict]:
|
||||
"""Describe what an explicit ``workflow: null`` would destroy, if anything.
|
||||
|
||||
Returns a ``delete`` block sized with the current graph, or None when the
|
||||
workflow survives — the target is new, published (``_apply_workflow``
|
||||
keeps a published agent's graph), has no workflow, or another agent still
|
||||
references it.
|
||||
"""
|
||||
if target.get("action") != "update" or not target.get("agent_id"):
|
||||
return None
|
||||
if (target.get("status") or "") == "published":
|
||||
return None
|
||||
agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user) or {}
|
||||
existing_id = agent_row.get("workflow_id")
|
||||
if not existing_id:
|
||||
return None
|
||||
row = WorkflowsRepository(conn).get(str(existing_id), user)
|
||||
if row is None:
|
||||
return None
|
||||
if AgentsRepository(conn).count_by_workflow(str(existing_id), user) > 1:
|
||||
return None
|
||||
# Lazy import, same as _apply_workflow — keeps route modules decoupled at load.
|
||||
from application.api.user.workflows.routes import get_workflow_graph_version
|
||||
|
||||
version = get_workflow_graph_version(row)
|
||||
return {
|
||||
"action": "delete",
|
||||
"nodes": len(WorkflowNodesRepository(conn).find_by_version(str(row["id"]), version)),
|
||||
"edges": len(WorkflowEdgesRepository(conn).find_by_version(str(row["id"]), version)),
|
||||
}
|
||||
|
||||
|
||||
def plan_import(conn, user: str, doc: dict) -> dict:
|
||||
"""Resolve every reference without writing; returns a resolution report."""
|
||||
spec = doc["spec"]
|
||||
@@ -978,18 +1010,25 @@ def plan_import(conn, user: str, doc: dict) -> dict:
|
||||
|
||||
workflow_plan = None
|
||||
wf_spec = spec.get("workflow")
|
||||
if spec.get("agent_type") == "workflow" and isinstance(wf_spec, dict):
|
||||
action = "create"
|
||||
if target.get("action") == "update" and target.get("agent_id"):
|
||||
agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
|
||||
existing_wf = (agent_row or {}).get("workflow_id")
|
||||
if existing_wf and WorkflowsRepository(conn).get(str(existing_wf), user):
|
||||
action = "update"
|
||||
workflow_plan = {
|
||||
"nodes": len(wf_spec.get("nodes") or []),
|
||||
"edges": len(wf_spec.get("edges") or []),
|
||||
"action": action,
|
||||
}
|
||||
if spec.get("agent_type") == "workflow":
|
||||
if isinstance(wf_spec, dict):
|
||||
action = "create"
|
||||
if target.get("action") == "update" and target.get("agent_id"):
|
||||
agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
|
||||
existing_wf = (agent_row or {}).get("workflow_id")
|
||||
if existing_wf and WorkflowsRepository(conn).get(str(existing_wf), user):
|
||||
action = "update"
|
||||
workflow_plan = {
|
||||
"nodes": len(wf_spec.get("nodes") or []),
|
||||
"edges": len(wf_spec.get("edges") or []),
|
||||
"action": action,
|
||||
}
|
||||
elif "workflow" in spec:
|
||||
# Explicit ``workflow: null`` (vs. an omitted key, which changes
|
||||
# nothing). Apply clears the link and reaps the row when no other
|
||||
# agent references it, destroying the graph, its run history and
|
||||
# its artifacts — the dry-run has to say so before the user commits.
|
||||
workflow_plan = _plan_workflow_removal(conn, user, target)
|
||||
|
||||
return {
|
||||
"target": target,
|
||||
@@ -1029,7 +1068,19 @@ def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list
|
||||
mapping = {}
|
||||
resolved: list[str] = []
|
||||
id_by_name: dict[str, Optional[str]] = {}
|
||||
for src in spec.get("sources") or []:
|
||||
entries = [src for src in spec.get("sources") or [] if isinstance(src, dict)]
|
||||
# ``id_by_name`` (and a workflow node's source reference) keys on the name
|
||||
# alone, but names aren't unique per user — ``find_by_name`` returns the
|
||||
# oldest match, so duplicates collapse onto one source. Same hazard the
|
||||
# custom-model display names carry below; surface it rather than silently
|
||||
# rewiring a node to a different source.
|
||||
names = [src.get("name") or "" for src in entries]
|
||||
for dup in sorted({n for n in names if n and names.count(n) > 1}):
|
||||
warnings.append(
|
||||
f"Multiple sources share the name '{dup}'; references to it all "
|
||||
"resolve to the oldest one"
|
||||
)
|
||||
for src in entries:
|
||||
name = src.get("name") or ""
|
||||
id_by_name.setdefault(name, None)
|
||||
mapped = mapping.get(name)
|
||||
@@ -1338,7 +1389,9 @@ def _rewrite_node_refs(
|
||||
resolved_sources.append(ref)
|
||||
else:
|
||||
warnings.append(f"Workflow node source '{ref}' not resolved; removed")
|
||||
cfg["sources"] = resolved_sources
|
||||
# Distinct names can collapse onto one source (see ``_apply_sources``), so
|
||||
# dedupe rather than storing the same id twice on the node.
|
||||
cfg["sources"] = list(dict.fromkeys(resolved_sources))
|
||||
|
||||
raw_model = cfg.get("model_id")
|
||||
if raw_model:
|
||||
@@ -1536,6 +1589,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
"extra_source_ids": [],
|
||||
}
|
||||
)
|
||||
prior_workflow_id = None
|
||||
if is_update:
|
||||
prior_workflow_id = (
|
||||
agents_repo.get(str(target["agent_id"]), user) or {}
|
||||
).get("workflow_id")
|
||||
published = (target.get("status") or "") == "published"
|
||||
workflow_result = _apply_workflow(
|
||||
conn,
|
||||
user,
|
||||
@@ -1546,21 +1605,31 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
model_ids_by_name,
|
||||
warnings,
|
||||
)
|
||||
if workflow_result is not _WORKFLOW_UNCHANGED:
|
||||
if workflow_result is None and (target.get("status") or "") == "published":
|
||||
warnings.append(
|
||||
"File has no workflow; the published agent kept its existing one"
|
||||
# A published workflow agent with no graph can't run — the create and
|
||||
# update routes both reject that state, so import must not install it
|
||||
# through the back door (e.g. a file that flips ``agent_type`` to
|
||||
# workflow over a published classic agent).
|
||||
if workflow_result is _WORKFLOW_UNCHANGED:
|
||||
if published and not prior_workflow_id:
|
||||
raise AgentImportError(
|
||||
"A published workflow agent needs a workflow; this file has none"
|
||||
)
|
||||
else:
|
||||
authoritative["workflow_id"] = workflow_result
|
||||
if workflow_result is None and is_update:
|
||||
# Explicit ``workflow: null`` on a draft: remember the old
|
||||
# link so the row can be reaped after the update — there is
|
||||
# no workflow-list API independent of agents, so an
|
||||
# unlinked workflow would be unreachable forever.
|
||||
prior = agents_repo.get(str(target["agent_id"]), user) or {}
|
||||
if prior.get("workflow_id"):
|
||||
orphaned_workflow_id = str(prior["workflow_id"])
|
||||
elif workflow_result is None and published:
|
||||
if not prior_workflow_id:
|
||||
raise AgentImportError(
|
||||
"A published workflow agent needs a workflow; this file has none"
|
||||
)
|
||||
warnings.append(
|
||||
"File has no workflow; the published agent kept its existing one"
|
||||
)
|
||||
else:
|
||||
authoritative["workflow_id"] = workflow_result
|
||||
if workflow_result is None and prior_workflow_id:
|
||||
# Explicit ``workflow: null`` on a draft: remember the old
|
||||
# link so the row can be reaped after the update — there is
|
||||
# no workflow-list API independent of agents, so an
|
||||
# unlinked workflow would be unreachable forever.
|
||||
orphaned_workflow_id = str(prior_workflow_id)
|
||||
|
||||
# Optional fields — applied only when present so a partial file doesn't wipe them.
|
||||
optional = {
|
||||
@@ -1583,6 +1652,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
# Nothing references it anymore; delete (cascades nodes/edges
|
||||
# and reaps run artifacts) rather than stranding the row.
|
||||
WorkflowsRepository(conn).delete(orphaned_workflow_id, user)
|
||||
# Destructive and irreversible — the graph, its run history and
|
||||
# its artifacts (rows and stored bytes) all go. Say so.
|
||||
warnings.append(
|
||||
"File has no workflow; the agent's workflow was deleted "
|
||||
"along with its run history and artifacts"
|
||||
)
|
||||
return {
|
||||
"agent_id": str(target["agent_id"]),
|
||||
"action": "updated",
|
||||
|
||||
@@ -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: [
|
||||
@@ -302,7 +305,12 @@ export default function AgentCard({
|
||||
.json()
|
||||
.then((data) => data?.message)
|
||||
.catch(() => null);
|
||||
throw new Error(message || t('agents.exportAgentFailed'));
|
||||
// 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' });
|
||||
@@ -316,8 +324,10 @@ export default function AgentCard({
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert(
|
||||
error instanceof Error ? error.message : t('agents.exportAgentFailed'),
|
||||
setExportError(
|
||||
error instanceof Error && error.name === 'ExportRefused'
|
||||
? error.message
|
||||
: t('agents.exportAgentFailed'),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -470,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}
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -1037,6 +1037,7 @@
|
||||
"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",
|
||||
@@ -1161,6 +1162,7 @@
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Close",
|
||||
"shareWithTeam": "Share with team",
|
||||
"backToAll": "Back to all agents",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "閉じる",
|
||||
"shareWithTeam": "チームと共有",
|
||||
"backToAll": "すべてのエージェントに戻る",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "Закрыть",
|
||||
"shareWithTeam": "Поделиться с командой",
|
||||
"backToAll": "Вернуться ко всем агентам",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "關閉",
|
||||
"shareWithTeam": "與團隊分享",
|
||||
"backToAll": "返回所有代理",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"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",
|
||||
@@ -1152,6 +1153,7 @@
|
||||
"importAgent": "Import",
|
||||
"exportAgent": "Export as YAML",
|
||||
"exportAgentFailed": "Failed to export agent",
|
||||
"close": "关闭",
|
||||
"shareWithTeam": "与团队共享",
|
||||
"backToAll": "返回所有代理",
|
||||
"pageHeader": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -58,7 +58,7 @@ type ImportPlan = {
|
||||
workflow?: {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
action: 'create' | 'update';
|
||||
action: 'create' | 'update' | 'delete';
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -124,19 +124,28 @@ export default function ImportAgentModal({
|
||||
setYamlText(await selectedFile.text());
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles[0]) processFile(acceptedFiles[0]);
|
||||
},
|
||||
multiple: false,
|
||||
// Declared here (not via getInputProps) so drag-and-drop is filtered
|
||||
// too, with drag-over rejection feedback; processFile's extension check
|
||||
// stays as a backstop for odd MIME reports.
|
||||
accept: {
|
||||
'application/x-yaml': ['.yaml', '.yml'],
|
||||
'text/yaml': ['.yaml', '.yml'],
|
||||
},
|
||||
});
|
||||
const { 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;
|
||||
@@ -320,7 +329,11 @@ export default function ImportAgentModal({
|
||||
<div
|
||||
{...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 ${
|
||||
isDragActive ? 'border-primary' : ''
|
||||
isDragReject
|
||||
? 'border-destructive'
|
||||
: isDragActive
|
||||
? 'border-primary'
|
||||
: ''
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
@@ -346,18 +359,28 @@ export default function ImportAgentModal({
|
||||
: t('modals.importAgent.willCreate')}
|
||||
</div>
|
||||
|
||||
{plan.workflow && (
|
||||
<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.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')}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
@@ -73,19 +73,28 @@ export default function ImportSpecModal({
|
||||
setParsedResult(null);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: (acceptedFiles: File[]) => {
|
||||
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 { 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;
|
||||
@@ -209,7 +218,11 @@ export default function ImportSpecModal({
|
||||
<div
|
||||
{...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 ${
|
||||
isDragActive ? 'border-primary' : ''
|
||||
isDragReject
|
||||
? 'border-destructive'
|
||||
: isDragActive
|
||||
? 'border-primary'
|
||||
: ''
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -408,3 +408,128 @@ def test_null_workflow_keeps_row_still_referenced_by_another_agent(pg_conn):
|
||||
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"])]
|
||||
Reference in new issue
Block a user