mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 10:17:55 +00:00
fix: keep agent published on update
This commit is contained in:
1 parent
ab3337ced8
commit
ee4abf1038
10 files changed
+86
-15
No files matched your search
@@ -366,13 +366,23 @@ def _resolve_target(conn, user: str, metadata: dict) -> dict:
|
||||
if agent_id and looks_like_uuid(str(agent_id)):
|
||||
row = repo.get(str(agent_id), user)
|
||||
if row:
|
||||
return {"action": "update", "agent_id": str(row["id"]), "matched_by": "id"}
|
||||
return {
|
||||
"action": "update",
|
||||
"agent_id": str(row["id"]),
|
||||
"matched_by": "id",
|
||||
"status": row.get("status"),
|
||||
}
|
||||
slug = metadata.get("slug")
|
||||
if slug:
|
||||
row = repo.find_by_slug(user, str(slug))
|
||||
if row:
|
||||
return {"action": "update", "agent_id": str(row["id"]), "matched_by": "slug"}
|
||||
return {"action": "create", "agent_id": None, "matched_by": None}
|
||||
return {
|
||||
"action": "update",
|
||||
"agent_id": str(row["id"]),
|
||||
"matched_by": "slug",
|
||||
"status": row.get("status"),
|
||||
}
|
||||
return {"action": "create", "agent_id": None, "matched_by": None, "status": None}
|
||||
|
||||
|
||||
def _find_user_tool(user_tools: list, tool_type: str, custom_name: str):
|
||||
@@ -728,7 +738,12 @@ def _limit(spec: dict, key: str):
|
||||
|
||||
|
||||
def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict:
|
||||
"""Create or update an agent from a parsed YAML doc. Always lands as draft.
|
||||
"""Create or update an agent from a parsed YAML doc.
|
||||
|
||||
A newly created agent lands as a draft. An update to an existing agent
|
||||
(matched by id or slug) preserves that agent's current status, so
|
||||
re-importing over a published agent keeps it live — its API key and any
|
||||
active users are unaffected — rather than silently reverting it to draft.
|
||||
|
||||
On update the YAML is authoritative: fields it specifies are written even
|
||||
when that clears a value (e.g. removing all models, dropping ``json_schema``,
|
||||
@@ -791,18 +806,29 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
|
||||
optional = {k: v for k, v in optional.items() if v is not None}
|
||||
|
||||
if is_update:
|
||||
fields = {**authoritative, **optional, "name": spec.get("name"), "status": "draft"}
|
||||
# Status is intentionally omitted so an update never changes it:
|
||||
# re-importing over a published agent keeps it live (its API key and
|
||||
# any active users are unaffected) instead of reverting it to draft.
|
||||
# Only a brand-new agent (the create path below) starts as a draft.
|
||||
fields = {**authoritative, **optional, "name": spec.get("name")}
|
||||
if agents_repo.update(str(target["agent_id"]), user, fields):
|
||||
return {
|
||||
"agent_id": str(target["agent_id"]),
|
||||
"action": "updated",
|
||||
"status": target.get("status") or "draft",
|
||||
"slug": slug,
|
||||
"warnings": warnings,
|
||||
}
|
||||
# Row vanished between resolve and write — fall through to create.
|
||||
|
||||
row = agents_repo.create(user, spec.get("name"), "draft", **{**authoritative, **optional})
|
||||
return {"agent_id": str(row["id"]), "action": "created", "slug": slug, "warnings": warnings}
|
||||
return {
|
||||
"agent_id": str(row["id"]),
|
||||
"action": "created",
|
||||
"status": "draft",
|
||||
"slug": slug,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -563,7 +563,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -584,6 +584,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -715,7 +715,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -736,6 +736,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -602,6 +602,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -602,6 +602,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -602,6 +602,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -602,6 +602,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"importAgent": {
|
||||
"title": "Import agent from YAML",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.",
|
||||
"description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.",
|
||||
"dropzoneText": "Click to upload or drag and drop a .yaml file",
|
||||
"invalidFileType": "Only .yaml or .yml files are supported",
|
||||
"readError": "Could not read this agent file",
|
||||
@@ -602,6 +602,7 @@
|
||||
"modelCustom": "{{name}} — custom model",
|
||||
"apiKeyPlaceholder": "API key (leave blank to skip this model)",
|
||||
"warningsTitle": "Agent imported as a draft. Some items need your attention:",
|
||||
"warningsTitlePublished": "Your live agent was updated. Some items need your attention:",
|
||||
"cancel": "Cancel",
|
||||
"review": "Review",
|
||||
"import": "Import",
|
||||
|
||||
@@ -84,6 +84,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 reset = () => {
|
||||
@@ -97,6 +98,7 @@ export default function ImportAgentModal({
|
||||
setToolSecrets({});
|
||||
setModelKeys({});
|
||||
setWarnings(null);
|
||||
setImportedStatus(null);
|
||||
setGoToAgentId(null);
|
||||
};
|
||||
|
||||
@@ -189,6 +191,7 @@ export default function ImportAgentModal({
|
||||
// Keep the modal open so the user sees what was skipped.
|
||||
setGoToAgentId(agentId);
|
||||
setWarnings(data.warnings as string[]);
|
||||
setImportedStatus((data.status as string) || null);
|
||||
setPlan(null);
|
||||
return;
|
||||
}
|
||||
@@ -278,7 +281,9 @@ export default function ImportAgentModal({
|
||||
{warnings ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-foreground text-sm">
|
||||
{t('modals.importAgent.warningsTitle')}
|
||||
{importedStatus === 'published'
|
||||
? t('modals.importAgent.warningsTitlePublished')
|
||||
: t('modals.importAgent.warningsTitle')}
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
{warnings.map((w, i) => (
|
||||
|
||||
@@ -126,11 +126,43 @@ def test_round_trip_same_user_idempotent(pg_conn):
|
||||
assert r1["agent_id"] == r2["agent_id"] == str(agent["id"]) # matched by id
|
||||
assert len(PromptsRepository(pg_conn).list_for_user(user)) == 1 # no dup prompt
|
||||
imported = AgentsRepository(pg_conn).get(r1["agent_id"], user)
|
||||
assert imported["status"] == "draft"
|
||||
assert imported["status"] == "published" # update preserves the live status
|
||||
assert [str(s) for s in imported["extra_source_ids"]] == [str(src["id"])]
|
||||
assert imported["tools"] == [scheduler_id] # builtin passthrough
|
||||
|
||||
|
||||
def test_update_preserves_published_status(pg_conn):
|
||||
"""Re-importing over a published agent must not revert it to draft.
|
||||
|
||||
The agent's API key and any active users keep working; only the content
|
||||
is synced. A new agent (no match) still lands as a draft.
|
||||
"""
|
||||
user = "u_pub"
|
||||
agent = AgentsRepository(pg_conn).create(
|
||||
user,
|
||||
"Live Bot",
|
||||
"published",
|
||||
description="old",
|
||||
chunks=2,
|
||||
retriever="classic",
|
||||
)
|
||||
slug = ensure_agent_slug(pg_conn, agent, user)
|
||||
doc = {
|
||||
"apiVersion": API_VERSION,
|
||||
"kind": "Agent",
|
||||
"metadata": {"id": str(agent["id"]), "slug": slug},
|
||||
"spec": {"name": "Live Bot", "description": "new", "retriever": "classic"},
|
||||
}
|
||||
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
|
||||
assert result["action"] == "updated"
|
||||
assert result["status"] == "published" # response reports the preserved status
|
||||
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
|
||||
assert updated["status"] == "published" # stayed live
|
||||
assert updated["description"] == "new" # content still synced
|
||||
|
||||
|
||||
def test_import_by_slug_idempotent(pg_conn):
|
||||
user = "u_slug_import"
|
||||
doc = _doc(name="Slug Bot", _slug="slug-bot", retriever="classic")
|
||||
@@ -154,6 +186,7 @@ def test_import_missing_source_drafts_and_warns(pg_conn):
|
||||
|
||||
result = apply_import(pg_conn, user, doc)
|
||||
|
||||
assert result["status"] == "draft" # new agents are created as drafts
|
||||
assert any("Nonexistent KB" in w for w in result["warnings"])
|
||||
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
|
||||
assert agent["status"] == "draft"
|
||||
|
||||
Reference in new issue
Block a user