fix: stop manifest editor base image selection loop

The ManifestEditor had a feedback loop:
1. State change → buildManifest changes → onChange notifies parent
2. Parent updates manifestData → new manifest prop
3. Loading effect sets all state from manifest (arrays get new refs even if same content)
4. New array refs → buildManifest changes → onChange fires again → loop

Fix: track the last-sent manifest via a ref and only call onChange when the
serialized built manifest actually differs. This breaks the cycle because after
the loading effect syncs state, the rebuilt manifest is identical in content
so we skip the parent notification.
This commit is contained in:
Alex Blank
2026-05-28 21:44:49 +02:00
parent 3aa56dcfc3
commit 0a0af4e02a
3 changed files with 112 additions and 73 deletions
+91 -55
View File
@@ -50,7 +50,9 @@ class ToolTypeCreate(BaseModel):
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError("definition_type must be 'compose', 'dockerfile', or 'manifest'")
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@@ -59,10 +61,12 @@ class ToolTypeCreate(BaseModel):
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError("compose_template is required when definition_type is 'compose'")
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@@ -72,13 +76,15 @@ class ToolTypeCreate(BaseModel):
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("interface_type")
@@ -104,44 +110,62 @@ class ToolTypeCreate(BaseModel):
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(f"Required variable '{var}' not found in compose template")
raise ValueError(
f"Required variable '{var}' not found in compose template"
)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError("manifest_id is required when definition_type is 'manifest'")
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (self.dockerfile_template is None or not self.dockerfile_template.strip()):
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and (self.compose_template is None or not self.compose_template.strip()):
raise ValueError("compose_template is required when definition_type is 'compose'")
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
if (
self.requires_port
and self.definition_type == "compose"
and self.compose_template
):
try:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
raise ValueError(
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
)
return self
@@ -167,7 +191,9 @@ class ToolTypeUpdate(BaseModel):
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError("definition_type must be 'compose', 'dockerfile', or 'manifest'")
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("interface_type")
@@ -198,15 +224,15 @@ class ToolTypeUpdate(BaseModel):
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@@ -258,12 +284,15 @@ async def create_tool_type(
"""
user = await _get_user(session, user_id)
await _require_admin(user)
# Check for duplicate name
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="tool type with this name already exists",
)
tool_type = ToolType(
name=data.name,
display_name=data.display_name,
@@ -336,7 +365,9 @@ async def get_tool_type(
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
return tool_type
@@ -365,15 +396,17 @@ async def update_tool_type(
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
# Built-in tool types can now be modified
update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated
requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
@@ -381,9 +414,9 @@ async def update_tool_type(
if new_port <= 0 or new_port > 65535:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Port must be between 1 and 65535"
detail="Port must be between 1 and 65535",
)
# Only validate port exposure for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
@@ -394,12 +427,11 @@ async def update_tool_type(
if not check_port_exposed(parsed, new_port):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template"
detail=f"Port {new_port} is not exposed in the compose template",
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
)
# Validate required variables for compose definitions
@@ -413,17 +445,17 @@ async def update_tool_type(
template = tool_type.compose_template
if template:
validate_required_variables(template, update_data["required_variables"])
# When switching to manifest, clear legacy templates
if definition_type == "manifest":
if "manifest_id" in update_data:
tool_type.manifest_id = update_data["manifest_id"]
tool_type.compose_template = None
tool_type.dockerfile_template = None
for field, value in update_data.items():
setattr(tool_type, field, value)
await session.commit()
await session.refresh(tool_type)
return tool_type
@@ -509,10 +541,12 @@ async def validate_tool_type(
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
errors = []
if tool_type.definition_type == "compose":
if not tool_type.compose_template:
errors.append("Compose template is empty")
@@ -521,17 +555,17 @@ async def validate_tool_type(
validate_compose_yaml(tool_type.compose_template)
except ValueError as e:
errors.append(str(e))
elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template:
errors.append("Dockerfile template is empty")
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
elif tool_type.definition_type == "manifest":
if not tool_type.manifest_id:
errors.append("Manifest reference is missing")
return {
"valid": len(errors) == 0,
"errors": errors,
@@ -561,12 +595,14 @@ async def delete_tool_type(
"""
user = await _get_user(session, user_id)
await _require_admin(user)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
# Built-in tool types can now be deleted
await session.delete(tool_type)
await session.commit()