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 <noreply@anthropic.com>
Delete now moves an artifact to a recoverable Trash instead of hard-removing.
- Model: Artifact.deleted_at (nullable, indexed) = in Trash.
- Soft-delete everywhere: DELETE /api/artifacts/{id} sets deleted_at
(idempotent 204); session POST /a/{id}/delete for the feed's hover Delete
icon; POST /a/{id}/undelete restores; POST /feed/empty-trash purges.
- Trashed artifacts are off-quota (deleting frees quota) and eviction-protected
(never evicted early); hidden from feed, sources, counts, and all API reads
(list/detail/raw).
- Sidebar: "Deleted" and "Keep" system folders at the bottom (counts, hidden
when empty); ?folder=deleted / ?folder=keep views reuse the row rendering.
Deleted view has hover Undelete + an Empty Trash button. Keep = view over
is_kept. Live-poll disabled in folder views.
- Docs (api.md delete section, CLAUDE.md), 10 new/updated tests. 150 passing;
API soft-delete lifecycle verified live.
Purge is manual (Empty Trash) this release; auto purge-on-delete (background,
~3-day retention) is the planned fast-follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
make_room_for() read usage before any write lock was held, so concurrent
requests could each read stale data and both conclude "this fits" -
confirmed reproducible (measured 616 bytes over a 1551-byte quota with 5
truly concurrent posts).
Fix: force SQLite to acquire its write lock immediately via a no-op UPDATE
on the user's UserProfile row, before reading `used`, wrapped in
transaction.atomic() through the subsequent Artifact.objects.create() so
the lock is held across the whole eviction+insert sequence. Added a 20s
DB timeout as headroom for legitimate contention.
Deliberately no retry/backoff logic - this project's actual load (a
low-volume self-hosted tool) doesn't need it, and SQLite has no row-level
locking regardless, so quota-affecting writes serialize globally either
way. Did fix a real bug found while verifying this: exception handling
around artifact creation only caught QuotaExceeded, so a genuine lock
timeout left the just-written file orphaned on disk - both call sites now
clean up on any exception.
Verified live against the real dev instance (twice, with 5 truly
simultaneous posts against a tight quota) rather than via an automated
test - Django's in-memory shared-cache test database uses a different,
non-retryable SQLite locking model that fails for reasons unrelated to
this fix's correctness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Gap 1 from planning/2026-09-02-artifact_kind_gaps_for_frequent_agent_output.md.
Chose new first-class kinds over riding on html_fragment, resolving that
doc's open design question in favor of filtering support, raw-source
preservation (like markdown's .md sidecar), and server-owned validation.
- json: validated with json.loads at post time (400 on invalid JSON),
rendered with renderjson (collapsible tree), vendored locally.
- csv: parsed server-side with the stdlib csv module into columns/rows and
handed to Grid.js as plain data - no client-side CSV-parsing plugin
needed, keeping normalization server-owned. Vendored locally (MIT).
- Both preserve their original text as a sidecar file, generalizing what
was previously markdown-only handling (storage.py's SIDECAR_SUFFIXES) so
disk-size accounting, eviction cleanup, and ?raw=1 all cover json/csv too.
- Raw JSON is embedded in a <script type="application/json"> block with
`</script` occurrences escaped (safe because JSON's \/ escape round-trips
correctly) - verified with a live </script>-injection payload against the
actual running server, not just unit tests.
New migration (0012) for the KIND_CHOICES change - SQLite doesn't enforce
choices at the schema level, but Django's migration state tracks it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- #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>
Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts,
or people publish artifacts and each logged-in user browses their own
time-ordered, label-filterable feed.
This squashes prior local development history (kept privately on a separate
branch) into a single clean starting point for the public repository.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>