Fix issues #002-#006 found by the LLM test harness
- #006 (high, data loss): make_room_for() no longer deletes unkept artifacts on a doomed request - checks whether eviction can possibly succeed before deleting anything. Also fixed a related latent bug found while testing: the function returned evicted Artifact objects after calling .delete() on them, so their .id was always None (Django clears pk on delete); now returns ids captured beforehand. - #003: added server-side field-length validation (source/title/ source_name/source_id/theme) on POST and PATCH, matching the model's own max_length limits, which were previously unenforced on the ingest path entirely. - #004: ported daybook_post.py's content-shape validation (html_fragment/html_full/deck/markdown) to the server, closing the gap for any client that doesn't go through the helper script. - #005: PATCH now rejects wrong-typed `tags` (previously silently wiped existing tags) and rejects immutable fields (content/source/kind/day) with 400 instead of silently no-op'ing them. - #002: deleted the misleading skills/daybook-posting/.env.example (a zero-dependency script that never reads .env files) and clarified SKILL.md/docs/llm_posting_helper.md that these must be real process/shell env vars. Adds tests/unit/ for storage-layer tests. 46 tests passing (was 25). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c1ea5c7990
commit
07328a3412
13 changed files with 635 additions and 35 deletions
|
|
@ -76,8 +76,10 @@ def user_quota_bytes(user):
|
|||
def make_room_for(user, incoming_bytes):
|
||||
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
|
||||
|
||||
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
|
||||
if even removing every unkept artifact leaves no room.
|
||||
Returns the ids of evicted artifacts (already deleted - Django clears an
|
||||
instance's pk on .delete(), so ids are captured beforehand). Raises
|
||||
QuotaExceeded, without deleting anything, if even evicting every unkept
|
||||
artifact wouldn't free enough room.
|
||||
"""
|
||||
quota = user_quota_bytes(user)
|
||||
if incoming_bytes > quota:
|
||||
|
|
@ -87,21 +89,27 @@ def make_room_for(user, incoming_bytes):
|
|||
if used + incoming_bytes <= quota:
|
||||
return []
|
||||
|
||||
evicted = []
|
||||
unkept = (
|
||||
unkept = list(
|
||||
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||
)
|
||||
# Check whether eviction can possibly succeed before deleting anything -
|
||||
# otherwise a doomed request would still destroy every unkept artifact on
|
||||
# its way to failing anyway.
|
||||
evictable_total = sum(artifact_total_bytes(artifact) for artifact in unkept)
|
||||
if used - evictable_total + incoming_bytes > quota:
|
||||
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
||||
|
||||
evicted = []
|
||||
for artifact in unkept:
|
||||
if used + incoming_bytes <= quota:
|
||||
break
|
||||
# Freeing an artifact also frees the assets embedded in it.
|
||||
used -= artifact_total_bytes(artifact)
|
||||
evicted_id = artifact.id
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
evicted.append(artifact)
|
||||
evicted.append(evicted_id)
|
||||
|
||||
if used + incoming_bytes > quota:
|
||||
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
||||
return evicted
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ from .storage import (
|
|||
|
||||
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
|
||||
|
||||
# Affects storage layout, renderer choice, or feed ordering - never editable after create.
|
||||
IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"}
|
||||
|
||||
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||
|
||||
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
||||
|
|
@ -112,6 +115,10 @@ class ArtifactListCreateView(APIView):
|
|||
if source_error:
|
||||
return source_error
|
||||
|
||||
content_error = validate_content_shape(kind, data["content"])
|
||||
if content_error:
|
||||
return content_error
|
||||
|
||||
artifact = create_text_artifact(data, request, user)
|
||||
if isinstance(artifact, Response):
|
||||
return artifact
|
||||
|
|
@ -206,11 +213,22 @@ class ArtifactDetailView(APIView):
|
|||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
data = request.data
|
||||
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
||||
if not_editable:
|
||||
return Response(
|
||||
{"detail": f"Not editable via PATCH: {', '.join(sorted(not_editable))}"},
|
||||
status=400,
|
||||
)
|
||||
length_error = validate_field_lengths(data, ["title", "source_name", "source_id"])
|
||||
if length_error:
|
||||
return length_error
|
||||
if "title" in data:
|
||||
if not data["title"]:
|
||||
return Response({"detail": "title cannot be blank"}, status=400)
|
||||
artifact.title = data["title"]
|
||||
if "tags" in data:
|
||||
if not isinstance(data["tags"], (str, list)):
|
||||
return Response({"detail": "tags must be a string or an array"}, status=400)
|
||||
artifact.tags = normalize_tags(data["tags"])
|
||||
if "source_prompt" in data:
|
||||
artifact.source_prompt = data["source_prompt"] or ""
|
||||
|
|
@ -691,7 +709,50 @@ def labels_with_colors(artifact, overrides=None):
|
|||
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
|
||||
|
||||
|
||||
FIELD_LENGTH_LIMITS = {
|
||||
"source": 80,
|
||||
"title": 200,
|
||||
"source_name": 120,
|
||||
"source_id": 120,
|
||||
"theme": 40,
|
||||
}
|
||||
|
||||
|
||||
def validate_field_lengths(data, fields):
|
||||
for field in fields:
|
||||
value = data.get(field)
|
||||
if value and len(str(value)) > FIELD_LENGTH_LIMITS[field]:
|
||||
return Response(
|
||||
{"detail": f"{field} must be {FIELD_LENGTH_LIMITS[field]} characters or fewer"},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def validate_content_shape(kind, content):
|
||||
lower = (content or "").lower()
|
||||
if kind == Artifact.DECK and "<section" not in lower:
|
||||
return Response(
|
||||
{"detail": "deck needs top-level <section>...</section> blocks"}, status=400
|
||||
)
|
||||
if kind == Artifact.HTML_FRAGMENT and any(tag in lower for tag in ("<html", "<!doctype", "<body")):
|
||||
return Response(
|
||||
{"detail": "html_fragment must be body-only HTML; do not include doctype/html/body"},
|
||||
status=400,
|
||||
)
|
||||
if kind == Artifact.HTML_FULL and "<html" not in lower:
|
||||
return Response(
|
||||
{"detail": "html_full must be a complete HTML document containing <html>"}, status=400
|
||||
)
|
||||
if kind == Artifact.MARKDOWN and not (content or "").strip():
|
||||
return Response({"detail": "markdown content cannot be blank"}, status=400)
|
||||
return None
|
||||
|
||||
|
||||
def validate_source_fields(data):
|
||||
length_error = validate_field_lengths(data, ["source", "title", "source_name", "source_id", "theme"])
|
||||
if length_error:
|
||||
return length_error
|
||||
if source_type := data.get("source_type"):
|
||||
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue