fix: keep agent published on update

This commit is contained in:
Alex committed 2026-06-14 14:44:27 +01:00
1 parent ab3337ced8
commit ee4abf1038
10 files changed
+86 -15

No files matched your search

+32 -6
View File
@@ -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,
}
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+6 -1
View File
@@ -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) => (
+34 -1
View File
@@ -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"