diff --git a/CHANGELOG.md b/CHANGELOG.md index cac692a..e23c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,6 @@ former name, "Daybook".) _Nothing yet._ -## [0.5.1] — 2026-09-07 - -### Added -- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention - window are now permanently removed automatically. It's cron-less: a delete - triggers a sweep that runs on a **background thread** (off the request, so - the click never blocks), tolerant of concurrent runs. Manual "Empty Trash" - still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE` - (on by default; tests run it inline). - ## [0.5.0] — 2026-09-07 ### Added @@ -195,8 +185,7 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...HEAD -[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1 +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...HEAD [0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0 [0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1 [0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 diff --git a/CLAUDE.md b/CLAUDE.md index f38dbbf..75af818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ The core product principle is: agents publish content, not files. The server own - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. -- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both folders hide when empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately. +- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both folders hide when empty. (Auto purge-on-delete is a planned fast-follow; currently purge is manual.) - Use random 8-character artifact IDs. - Start as a plain Django project; add Docker after core ingest and viewer work. - Preserve original markdown as `.md` alongside rendered `.html`. diff --git a/artifacts/storage.py b/artifacts/storage.py index df53526..2a7f7bd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -7,11 +7,9 @@ SUM rather than a stat of every file. """ import shutil -import threading from datetime import timedelta from django.conf import settings -from django.db import connection from django.db.models import F, Sum from django.utils import timezone @@ -156,45 +154,6 @@ def purge_trashed(user): return len(trashed) -TRASH_RETENTION = timedelta(days=3) - - -def purge_expired_trash(now=None): - """Permanently remove trashed artifacts older than the retention window - (all users). Tolerant of already-gone rows so concurrent purges are safe. - Returns the number purged.""" - now = now or timezone.now() - cutoff = now - TRASH_RETENTION - expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff)) - purged = 0 - for artifact in expired: - try: - delete_artifact_files(artifact) - artifact.delete() - purged += 1 - except Exception: - # Another concurrent purge got there first, or a file's already - # gone — a purge error must never surface to anyone. - pass - return purged - - -def spawn_trash_purge(): - """Run purge_expired_trash off the request thread (cron-less: triggered by - deletes). Falls back to inline when async is disabled (tests).""" - if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True): - purge_expired_trash() - return - - def _run(): - try: - purge_expired_trash() - finally: - connection.close() # threads get their own connection; don't leak it - - threading.Thread(target=_run, daemon=True).start() - - ASSET_ORPHAN_GRACE = timedelta(hours=24) diff --git a/artifacts/views.py b/artifacts/views.py index 1d2aff0..84ddfa1 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -45,7 +45,6 @@ from .storage import ( disk_free_bytes, make_room_for, purge_trashed, - spawn_trash_purge, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -306,7 +305,6 @@ class ArtifactDetailView(APIView): if artifact.deleted_at is None: artifact.deleted_at = timezone.now() artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread return Response(status=204) @@ -671,7 +669,6 @@ def delete_artifact(request, artifact_id): if artifact.deleted_at is None: artifact.deleted_at = timezone.now() artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread return JsonResponse({"id": artifact.id, "deleted": True}) diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index ee033ee..4cfb5af 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.5.1" +__version__ = "0.5.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index a041730..ab989ea 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -19,10 +19,6 @@ AGENTFEED_KEY_PROVISIONING_API = os.environ.get( "AGENTFEED_KEY_PROVISIONING_API", "" ).lower() in {"1", "true", "yes", "on"} -# Purge expired Trash off the request thread (triggered by deletes, no cron). -# Tests override this to run inline so purge behavior is deterministic. -AGENTFEED_ASYNC_PURGE = True - # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # on login/POST. The proxy must forward X-Forwarded-Proto: https. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 3e17bd5..e86d1c0 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -17,7 +17,6 @@ from artifacts.models import ApiKey, Artifact User = get_user_model() -@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests class ArtifactAPITestCase(APITestCase): """Exercises the ingest/list/detail API against a real (temp) filesystem and a real (test) database, the way an agent posting over HTTP would.""" diff --git a/tests/views/test_trash.py b/tests/views/test_trash.py index e04601e..a851725 100644 --- a/tests/views/test_trash.py +++ b/tests/views/test_trash.py @@ -1,6 +1,5 @@ import shutil import tempfile -from datetime import timedelta from pathlib import Path from django.contrib.auth import get_user_model @@ -9,13 +8,11 @@ from django.urls import reverse from django.utils import timezone from artifacts.models import Artifact -from artifacts.storage import purge_expired_trash, user_usage_bytes +from artifacts.storage import user_usage_bytes User = get_user_model() -# Run the purge inline (no background thread) so its effects are deterministic. -@override_settings(AGENTFEED_ASYNC_PURGE=False) class TrashKeepViewTestCase(TestCase): @classmethod def setUpClass(cls): @@ -108,23 +105,3 @@ class TrashKeepViewTestCase(TestCase): page = self.client.get("/?folder=keep").content.decode() self.assertIn("KeptOne", page) self.assertNotIn("NormalOne", page) - - def test_purge_expired_trash_removes_old_only(self): - old = self.make() - recent = self.make() - Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4)) - Artifact.objects.filter(pk=recent.id).update(deleted_at=timezone.now()) - purged = purge_expired_trash() - self.assertEqual(purged, 1) - self.assertFalse(Artifact.objects.filter(pk=old.id).exists()) - self.assertTrue(Artifact.objects.filter(pk=recent.id).exists()) - - def test_delete_triggers_expired_purge(self): - old = self.make() - Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4)) - fresh = self.make() - # Deleting fresh runs the purge (inline here) -> the expired one goes, - # the freshly-trashed one stays. - self.client.post(reverse("artifact-delete", args=[fresh.id])) - self.assertFalse(Artifact.objects.filter(pk=old.id).exists()) - self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())