From 57ad58ff2d7115af0ddaf6cd0ad40597eac3376c Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 13:01:12 +0100 Subject: [PATCH 1/8] v0.5.1: automatic Trash purge (cron-less background thread) Trashed artifacts past a ~3-day retention window are now purged automatically. - storage.purge_expired_trash(): removes trash older than TRASH_RETENTION (all users), tolerant of already-gone rows (concurrent-safe). - spawn_trash_purge(): runs it on a daemon thread (manages/closes its own DB connection), triggered by each delete so it's cron-less and off the request thread. Inline fallback when AGENTFEED_ASYNC_PURGE is off (tests). - Wired into both delete paths (API + session). Manual Empty Trash unchanged. - Tests: purge removes expired-only; delete triggers the purge. 152 passing; live delete verified with the thread enabled (204, server healthy). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++++++++++- CLAUDE.md | 2 +- artifacts/storage.py | 41 +++++++++++++++++++++++++++++++++++++ artifacts/views.py | 3 +++ daybook_project/__init__.py | 2 +- daybook_project/settings.py | 4 ++++ tests/api/test_artifacts.py | 1 + tests/views/test_trash.py | 25 +++++++++++++++++++++- 8 files changed, 87 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e23c61d..cac692a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ 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 @@ -185,7 +195,8 @@ 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.0...HEAD +[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 [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 75af818..f38dbbf 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. (Auto purge-on-delete is a planned fast-follow; currently purge is manual.) +- 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. - 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 2a7f7bd..df53526 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -7,9 +7,11 @@ 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 @@ -154,6 +156,45 @@ 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 84ddfa1..1d2aff0 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -45,6 +45,7 @@ from .storage import ( disk_free_bytes, make_room_for, purge_trashed, + spawn_trash_purge, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -305,6 +306,7 @@ 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) @@ -669,6 +671,7 @@ 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 4cfb5af..ee033ee 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.0" +__version__ = "0.5.1" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index ab989ea..a041730 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -19,6 +19,10 @@ 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 e86d1c0..3e17bd5 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -17,6 +17,7 @@ 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 a851725..e04601e 100644 --- a/tests/views/test_trash.py +++ b/tests/views/test_trash.py @@ -1,5 +1,6 @@ import shutil import tempfile +from datetime import timedelta from pathlib import Path from django.contrib.auth import get_user_model @@ -8,11 +9,13 @@ from django.urls import reverse from django.utils import timezone from artifacts.models import Artifact -from artifacts.storage import user_usage_bytes +from artifacts.storage import purge_expired_trash, 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): @@ -105,3 +108,23 @@ 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()) From 548ba3a66940241a1b2e9551af26cee17aca90e2 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 13:12:27 +0100 Subject: [PATCH 2/8] =?UTF-8?q?v0.6.0:=20per-artifact=20actions=20menu=20(?= =?UTF-8?q?=E2=8B=AF=20+=20right-click)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ⋯ button on row hover and right-click on a feed row open the same floating menu: Keep / Open / Copy link / Delete. Dismisses on click-away/Escape, edge-flips to stay on-screen. Standalone one-click Delete icon stays; the Trash view keeps its dedicated Undelete. - Row markup: actions grouped in a .row-actions container (delete + ⋯ in the feed/keep views; undelete in trash). Reuses the existing keep endpoint. - Menu only in the normal/keep views, not trash. Keep-toggle syncs the row's kept badge + the preview Keep button, and (in the Keep folder) drops the row when unkept. - Frontend-only (no backend change); inline JS validated with node --check; 152 tests pass; feed renders in all views. Share + Add-label join the menu in later slices. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++- daybook_project/__init__.py | 2 +- static/agentfeed.css | 56 +++++++++++-- templates/artifacts/_artifact_rows.html | 13 +-- templates/artifacts/feed.html | 100 +++++++++++++++++++++++- 5 files changed, 166 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac692a..46204aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [0.6.0] — 2026-09-07 + +### Added +- **Per-artifact actions menu.** A **⋯** button appears on row hover, and + **right-clicking** a feed row opens the same floating menu — **Keep / + Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips + to stay on-screen. The standalone one-click Delete icon stays; the Trash + view keeps its dedicated Undelete. (Share and Add-label will join the menu + in later slices.) + ## [0.5.1] — 2026-09-07 ### Added @@ -195,7 +205,8 @@ 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 +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...HEAD +[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0 [0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1 [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 diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index ee033ee..2d6a887 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.6.0" diff --git a/static/agentfeed.css b/static/agentfeed.css index 667a968..aa0626e 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1060,12 +1060,22 @@ kbd { position: relative; } -.row-action { +.row-actions { position: absolute; - right: 0.5rem; + right: 0.4rem; top: 50%; transform: translateY(-50%); + display: flex; + gap: 0.15rem; opacity: 0; +} + +.artifact-row:hover .row-actions, +.artifact-row:focus-within .row-actions { + opacity: 1; +} + +.row-action { cursor: pointer; font-size: 0.95rem; line-height: 1; @@ -1075,11 +1085,6 @@ kbd { color: var(--muted); } -.artifact-row:hover .row-action, -.artifact-row:focus-within .row-action { - opacity: 1; -} - .row-action:hover { background: var(--surface-alt); color: var(--text); @@ -1089,6 +1094,43 @@ kbd { color: var(--danger-text-plain); } +/* Per-row actions menu (opened by the ⋯ trigger or right-click) */ +.row-menu { + position: fixed; + z-index: 100; + min-width: 9rem; + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14); + padding: 0.25rem; + display: flex; + flex-direction: column; +} + +.row-menu[hidden] { + display: none; +} + +.row-menu button { + background: transparent; + border: 0; + border-radius: 5px; + color: var(--text); + cursor: pointer; + font-size: 0.85rem; + padding: 0.4rem 0.6rem; + text-align: left; +} + +.row-menu button:hover { + background: var(--surface-alt); +} + +.row-menu button.danger { + color: var(--danger-text-plain); +} + /* System folders (Keep / Deleted) at the bottom of the sidebar */ .source-folders { border-top: 1px solid var(--grid-line); diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html index 45c2f2f..37c9ed1 100644 --- a/templates/artifacts/_artifact_rows.html +++ b/templates/artifacts/_artifact_rows.html @@ -30,10 +30,13 @@ {% endif %} {{ artifact.source }} - {% if in_trash %} - ↩ - {% else %} - 🗑 - {% endif %} + + {% if in_trash %} + ↩ + {% else %} + 🗑 + ⋯ + {% endif %} + {% endfor %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 90a26bb..95e8659 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -147,6 +147,13 @@ + +