From 4fa20e05c22a978c5656b9ad2d9a296baf5d62b3 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 11:37:35 +0100 Subject: [PATCH 01/16] Docs: add README, document /admin/, refresh for v0.2.x - Add a top-level README (repo had none): overview, local quick start, posting via API keys / the skill endpoint, a docs index, and the admin split. - deployment.md: new "Django Admin (/admin/)" section covering how to log in, the is_superuser vs is_staff distinction, how to grant /admin/ access, and the raw-edit-bypasses-app-logic caution. - Refresh constraint wording to per-user multiple API keys. Co-Authored-By: Claude Opus 4.8 --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++ docs/deployment.md | 34 +++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3adb1c --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Daybook + +Daybook is a self-hosted, multi-user **artifact journal**. AI agents, scripts, +or people publish finished outputs — reports, HTML, images, PDFs, decks, JSON, +CSV — and each logged-in user browses their own time-ordered, label-filterable +feed. The core principle: **agents publish content, not files.** The server +owns IDs, styling, storage layout, dates, and normalization into browser-native +HTML / image / PDF output. + +Built with Django + Django REST Framework, SQLite, and WhiteNoise; deployed as +a single Docker container. + +## Quick start (local development) + +```bash +python -m venv .venv +.venv/Scripts/python -m pip install -r requirements.txt # Windows; use .venv/bin on POSIX +python manage.py migrate +python manage.py createsuperuser +python manage.py runserver 127.0.0.1:8001 +``` + +Then open and log in. + +- **Post as an agent:** create a key under **Settings → API Keys**, then use + it as `Authorization: Bearer `. Each user can hold several keys (one + per agent), each revocable on its own. +- **Health check / contract:** `GET /api/capabilities`. +- **Ready-to-use posting instructions for an AI:** `GET /api/skill` returns the + bundled posting skill with this instance's URL pre-filled. + +## Documentation + +| Doc | For | +|---|---| +| [`docs/agent_quickstart.md`](docs/agent_quickstart.md) | Shortest path to posting from an agent | +| [`docs/api.md`](docs/api.md) | Full REST API reference (kinds, endpoints, keys, sharing) | +| [`docs/llm_posting_helper.md`](docs/llm_posting_helper.md) | Using the bundled `daybook_post.py` helper | +| [`docs/deployment.md`](docs/deployment.md) | Docker deployment, reverse proxy, `/admin/`, backup | +| [`skills/daybook-posting/SKILL.md`](skills/daybook-posting/SKILL.md) | The agent-posting skill (also served at `/api/skill`) | +| [`CHANGELOG.md`](CHANGELOG.md) | Release history | + +## Admin + +- **Settings** (in-app, themed) is the everyday surface: account, API keys, + storage, and — for superusers — user management. +- **Django admin** at `/admin/` is an operator break-glass tool for raw-data + fixes. It requires `is_staff` (the `createsuperuser` account has it). See + [`docs/deployment.md`](docs/deployment.md#django-admin-admin) for the + `is_superuser` vs `is_staff` distinction and cautions. + +## Version + +The version is defined once in `daybook_project/__init__.py`, shown on +**Settings → About**, and reported by `GET /api/capabilities`. Releases are +git-tagged `vX.Y.Z`. diff --git a/docs/deployment.md b/docs/deployment.md index cf83192..c1f6257 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -135,6 +135,37 @@ docker compose -f deploy/docker-compose.yml exec daybook python manage.py check docker compose -f deploy/docker-compose.yml exec daybook sh ``` +## Django Admin (`/admin/`) + +Daybook ships Django's built-in admin at `/admin/` as an operator +break-glass tool — for raw-data fixes: reset a password, inspect or delete +any user's artifacts, adjust a quota, audit or revoke API keys. It is +separate from the in-app **Settings** area, which is the everyday product +surface (see the in-app guide / `docs/` for normal use). + +**Logging in:** open `/admin/` and sign in with an account that has +`is_staff = True`. The account you create with `createsuperuser` has it (plus +full superuser rights). Note the two different admin concepts: + +- `is_superuser` — Daybook's *in-app* admin (can reach Settings → Users). +- `is_staff` — can log into *Django's* `/admin/` site. + +The `createsuperuser` account has **both**. A user promoted in-app via +Settings → Users ("Grant admin access") gets `is_superuser` only — so they +can administer Daybook through its own UI but **cannot** reach `/admin/`. +Regular users have neither. To grant someone `/admin/` access, set staff +explicitly: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook \ + python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='NAME'); u.is_staff=True; u.save()" +``` + +**Caution:** the admin edits database rows directly and bypasses app logic — +e.g. deleting an artifact there does not clean up its files on disk the way +the app's own delete does. Use it for inspection and targeted fixes, not +routine management. + ## Backup And Restore Back up `deploy/data/` — it contains the SQLite database and all artifact @@ -198,6 +229,7 @@ Caddy: header_up X-Forwarded-Proto https ## Known MVP Constraints - SQLite is used by default — fine for a single-VM self-hosted deployment. -- Per-user ingest tokens; no shared/global token. +- Per-user API keys (multiple per user, individually revocable); no + shared/global key. - Reader/browser access requires login; feeds are private per user. - Manual drag/drop upload and content revisions are deferred. From 4cacd8f64eb02f71820237814e62104eead3131f Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 11:41:03 +0100 Subject: [PATCH 02/16] Issues: file in-app guide (020); archive completed issues - Add issue 020: in-app user guide (Settings -> Guide), Open, for later. - Archive to _archive_/ (gitignored, local only): fixed bugs 014/015/016 and the completed LLM-test handover docs 007/013/018/019. - Tracked issues now: 017 (open), 020 (open), README. Co-Authored-By: Claude Opus 4.8 --- issues/007-handover-to-llm-test-2026-09-02.md | 54 ------ issues/013-handover-to-llm-test-round-2.md | 121 ------------- ...4-stored-xss-in-rendered-text-artifacts.md | 169 ------------------ ...5-deeply-nested-json-recursionerror-500.md | 111 ------------ ...-ragged-rows-silent-data-loss-in-render.md | 94 ---------- issues/018-handover-to-llm-test-round-3.md | 93 ---------- ...-handover-to-llm-test-round-4-predeploy.md | 77 -------- issues/020-in-app-user-guide.md | 43 +++++ 8 files changed, 43 insertions(+), 719 deletions(-) delete mode 100644 issues/007-handover-to-llm-test-2026-09-02.md delete mode 100644 issues/013-handover-to-llm-test-round-2.md delete mode 100644 issues/014-stored-xss-in-rendered-text-artifacts.md delete mode 100644 issues/015-deeply-nested-json-recursionerror-500.md delete mode 100644 issues/016-csv-ragged-rows-silent-data-loss-in-render.md delete mode 100644 issues/018-handover-to-llm-test-round-3.md delete mode 100644 issues/019-handover-to-llm-test-round-4-predeploy.md create mode 100644 issues/020-in-app-user-guide.md diff --git a/issues/007-handover-to-llm-test-2026-09-02.md b/issues/007-handover-to-llm-test-2026-09-02.md deleted file mode 100644 index 1303fb9..0000000 --- a/issues/007-handover-to-llm-test-2026-09-02.md +++ /dev/null @@ -1,54 +0,0 @@ -# 007 — Handover to AGENT_DAYBOOK__LLM_TEST: issues #002-#006 fixed - -**Status:** For information — not a bug report, a handback after fixing -what #002-#006 found. - -## What Changed - -All five issues your last testing round filed are fixed, committed -(`07328a3` on `main`, pushed to `origin`), and confirmed live on the running -local dev instance (`http://127.0.0.1:8001`, same one you test against — -no restart needed on your end, Django's autoreloader already picked it up): - -- **#002** — `.env.example` deleted; `SKILL.md`/`docs/llm_posting_helper.md` - now say explicitly these must be real process/shell env vars. -- **#003** — `source`/`title`/`source_name`/`source_id`/`theme` now - enforce their model length limits on both `POST` and `PATCH`, returning - `400` naming the field. -- **#004** — server now validates content shape per `kind` - (`html_fragment`/`html_full`/`deck`/`markdown`), mirroring - `daybook_post.py`'s existing client-side checks. -- **#005** — `PATCH` now rejects wrong-typed `tags` (400, instead of - silently wiping existing tags) and rejects immutable fields - (`content`/`source`/`kind`/`day`) with 400 instead of silently no-op'ing. -- **#006** — the quota-eviction data-loss bug: a doomed `POST` no longer - destroys unkept artifacts on its way to still failing with `413`. - -Full detail, root cause, and exact fix for each is in its own issue file -(`002` through `006`) — this doc is just the summary + what to do next. - -## Suggested Next Round - -Worth specifically re-probing (not just re-running the original repros — -try to break the fixes themselves): - -- **#003/#004 boundary/edge cases**: exact-limit-plus-one values, unicode - characters (does length-checking count characters or bytes?), mixed valid - content with edge-case whitespace/casing on the ``, `< html`, content - containing those strings inside a code block/string literal rather than as - real markup). -- **#005**: other wrong-typed fields on `PATCH` beyond `tags` — e.g. - `source_prompt`/`source_name`/`source_id` as non-strings (currently - accepted via implicit coercion, noted as untested in #005's original - writeup). -- **#006**: multi-request race conditions around eviction (two posts near - the quota boundary at once) — not covered by the fix, which only handles - the single-request case cleanly. -- General: anything from the original `issues/002-006` reproductions that - might have a sibling case not explicitly covered by the tests added - alongside each fix (`tests/api/test_artifacts.py`, `tests/unit/test_storage.py`). - -As always: use the `test`/`testpwd` account, clean up what you create, and -file anything real as a new numbered issue rather than reopening a closed -one (link back to the original if it's a variant of the same root cause). diff --git a/issues/013-handover-to-llm-test-round-2.md b/issues/013-handover-to-llm-test-round-2.md deleted file mode 100644 index 4cae7ac..0000000 --- a/issues/013-handover-to-llm-test-round-2.md +++ /dev/null @@ -1,121 +0,0 @@ -# 013 — Handover to AGENT_DAYBOOK__LLM_TEST (round 2) - -**Status:** For information — a handback covering everything shipped since -the round-1 handoff (#007). - -Round 1 (#007) closed out issues #002–#006. Since then, your round-2 -testing filed #008–#012 (all now fixed), and several new capabilities -landed. This doc summarizes all of it and points at where the fresh -surface area is worth probing. - -All of this is committed on `main` (pushed to Forgejo) and live on the -local dev instance you test against (`http://127.0.0.1:8001`) — Django's -autoreloader has already picked it up, no restart needed on your end. - -## New Capabilities To Exercise - -These are new since #007 and largely untested by you yet: - -1. **`json` and `csv` artifact kinds** (commit `e5d2f19`). Text kinds like - markdown. `json` renders as a collapsible tree (renderjson), `csv` as a - sortable/searchable table (Grid.js). Both validate server-side (`json` - must parse; `csv` only needs non-blank content) and preserve original - text as a `?raw=1` sidecar. Both vendored libs are served from - `/static/vendor/…`. - -2. **`.xlsx` upload → `csv`** (commit `4b5ec09`). Uploading an `.xlsx` file - (multipart, like any file) is converted server-side via openpyxl into a - `csv` artifact rather than stored as `binary`. Active sheet only; cached - formula results only; `.xls` unsupported; corrupt file → `400`. - -3. **Per-artifact public sharing** (commit `91ea19d`). Owner toggles a - share link from the feed UI; `GET /s/{share_token}` then serves that one - artifact with **no authentication at all**. `is_shared`/`share_url` now - appear in every artifact API response. Embedded images in a shared - artifact are also served unauthenticated while it's shared. **This is - the highest-value target for adversarial testing** — see below. - -4. **Syntax-highlighted code + styled tables** (commits `af9ecf2`, - `696cfe7`). Markdown fenced code blocks get Pygments highlighting; - tables in markdown and `html_fragment` render styled. Cosmetic, low-risk. - -5. **Posting script supports `json`/`csv`/`.xlsx`** (commit `e137725`). - `daybook_post.py --kind json/csv`, positional auto-detect of - `.json`/`.csv`/`.xlsx`. - -6. **Light/dark UI toggle** (commit `9a3b381`). UI-chrome only, no API - surface — not relevant to API testing. - -## Fixes Since #007 (issues you filed) - -- **#008** — `source_name`/`source_id`/`source_prompt` now reject - non-string values with `400` instead of silently mangling them. -- **#009** — the `html_fragment` content-shape check is substring-based; - documented as a known limitation rather than switched to real HTML - parsing (deliberate — matches the client helper's own approach). -- **#010** — concurrent posts could race past quota; fixed with a - write-lock-before-read + atomic transaction. Verified live but **not** - covered by an automated test (SQLite shared-cache test DB behaves - differently — see the issue). Worth re-probing. -- **#011** — CDN snippets documented for `html_full`; `--theme` help typo - fixed. -- **#012** — posting script gained `json`/`csv` support. - -## Suggested Round-3 Probes - -Prioritized by where the new surface area is riskiest. - -### Sharing (#91ea19d) — security boundary, probe hardest - -- Can an **unshared** artifact ever be reached via `/s/`? Brute/ - guessed/empty/malformed tokens should all `404`. -- Does **unsharing immediately** kill the old link? (Confirmed manually, - but re-verify — and try racing an unshare against an in-flight request.) -- Is `share_token` ever leaked in a response for an artifact you *don't* - own, or in the list endpoint, or anywhere a non-owner could see it? -- Can a shared artifact's **embedded assets** be reached unauthenticated — - and, importantly, can an *un*shared artifact's assets be reached that way - (they must not)? Also: does an orphaned/unattached asset stay private - even when some unrelated artifact is shared? -- Does `?raw=1` on `/s/` correctly serve raw source for the kinds - that support it, and not leak anything for kinds that don't? -- Cross-user: user A shares an artifact; can user B toggle/​unshare it, or - only view it? - -### json / csv (#e5d2f19) - -- `json`: valid-but-not-an-object payloads (bare array, string, number, - `null`); deeply nested / very large; unicode; the `` injection - vector (confirmed handled for json — verify **csv** escapes its embedded - data block the same way, it's a separate code path). -- `csv`: ragged rows (fewer/more columns than the header), embedded commas/ - quotes/newlines, duplicate column names, header-only, empty, a single - giant cell, BOM/encoding oddities. -- Quota accounting: json/csv now write a sidecar file — confirm size - accounting and eviction include/delete the sidecar (this generalized the - markdown-only logic; worth verifying it didn't regress markdown either). - -### .xlsx conversion (#4b5ec09) - -- Multi-sheet workbook — verify only the active sheet survives and the - others are silently dropped (documented behavior, confirm it's true). -- Formula cells never calculated by a real spreadsheet app (openpyxl - doesn't evaluate) → should be blank, not error. -- A non-`.xlsx` file renamed to `.xlsx`; a real `.xlsx` sent with a wrong/ - generic content type; a huge sheet; empty workbook; `.xls` (must not - convert). -- Field mangling regression: this path rebuilds the payload dict manually - because multipart `QueryDict` + `{**data}` was silently list-wrapping - values (the bug that made this a multi-fix). Re-probe that title/tags/ - source_* all come through correctly on `.xlsx` posts specifically. - -### #010 quota race (no automated coverage) - -- Fire N truly-concurrent posts at a tight quota; confirm final usage never - exceeds quota and no artifact is left half-created (row without files or - files without row). - -As always: use the `test` / `testpwd` account, clean up what you create, -check `artifacts/views.py` before calling a surprise a bug (some, like -#009, are documented-by-design), and file anything real as a new numbered -issue (next is #014), linking back if it's a variant of a closed one. diff --git a/issues/014-stored-xss-in-rendered-text-artifacts.md b/issues/014-stored-xss-in-rendered-text-artifacts.md deleted file mode 100644 index 88edbd9..0000000 --- a/issues/014-stored-xss-in-rendered-text-artifacts.md +++ /dev/null @@ -1,169 +0,0 @@ -# 014 — Stored XSS: markdown (and deck/html_fragment) render unsanitized script; no CSP; now reachable unauthenticated via public share links - -**Status:** Fixed -**Severity:** High — stored XSS executing on the Daybook origin, with a -concrete escalation chain against the owner's session, and (since the -sharing feature shipped in `91ea19d`) reachable by unauthenticated third -parties. - -Round-3 probe of content validation ("put malformed/bad content in each -kind, is it caught?"). Short answer: JSON and CSV are well-defended; -markdown is not; deck and html_fragment inject script too. - -## What was tested and what happened - -All posted to the `test` account and fetched back rendered from `/a/{id}`. - -| Kind | Injected | Result | -|---|---|---| -| `markdown` | ``, ``, `` | **All pass through verbatim into rendered HTML — executes.** | -| `deck` | `
…` | **Script passes through into the themed deck page — executes.** | -| `html_fragment` | `

hi

` | **Script/`svg onload` pass through into the themed page — executes.** (The #004 content-shape check blocks `//` but never `"}` | **Defended.** `escape_script_body` (`re.compile(r"` data block; renderjson shows it as inert text. Case-variation bypass (``) correctly caught. | -| `csv` | `` in a cell | **Defended.** Same escaping via `json.dumps` + `escape_script_body`. | -| `csv` | `=cmd|/c calc`, `+SUM(...)` as cell values | Passes through into the `?raw=1` sidecar verbatim (classic CSV/formula injection). Low severity / debatable — see below. | - -## Root cause (markdown specifically) - -`create_text_artifact` renders markdown with: - -```python -markdown.markdown(content, extensions=["extra", "sane_lists", "codehilite"], ...) -``` - -Python-Markdown does **no** HTML sanitization, and the `extra` extension -enables raw-HTML passthrough. Its own docs warn output must be sanitized -(bleach/nh3) before display if the input is at all untrusted. The rendered -HTML is then written to the artifact's `.html` file and served as-is. - -Unlike `html_full`/`html_fragment`, **markdown is not an "author your own -HTML" kind** — nobody expects a Markdown note to be a script-execution -vector. In an agent-driven system this is exactly the kind most likely to -carry *indirectly* untrusted content: an agent that summarizes a scraped -page, an email, or an ingested document can emit a summary containing an -attacker-controlled ` -
+ +
@@ -1007,18 +1007,18 @@ def wrap_csv_viewer(content, title, request): - -
+ +
@@ -1306,9 +1306,9 @@ def raw_artifact_path(artifact): return None -class DaybookLoginView(auth_views.LoginView): +class AgentFeedLoginView(auth_views.LoginView): template_name = "artifacts/login.html" - authentication_form = DaybookLoginForm + authentication_form = AgentFeedLoginForm def form_valid(self, form): response = super().form_valid(form) @@ -1459,7 +1459,7 @@ def raw_unavailable_html(artifact):

Raw source unavailable

-

Daybook does not have preserved raw source for {html.escape(artifact.title)}.

+

AgentFeed does not have preserved raw source for {html.escape(artifact.title)}.

diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index f7e7251..665c74e 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.2.1" +__version__ = "0.3.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index c8987a1..ab989ea 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -3,7 +3,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() +DATA_DIR = Path(os.environ.get("AGENTFEED_DATA_DIR", BASE_DIR)).resolve() SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} @@ -11,14 +11,13 @@ ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", # Allow creating/listing/revoking API keys over the ingest API (POST /api/keys). # Meant for automated test installations that provision their own agents. It -# exposes key material over the API, so it is OFF in deployment. Unset, it -# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API -# explicitly (0/1) to override either way. -_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API") -if _key_api_env is None: - DAYBOOK_KEY_PROVISIONING_API = DEBUG -else: - DAYBOOK_KEY_PROVISIONING_API = _key_api_env.lower() in {"1", "true", "yes", "on"} +# exposes key material over the API, so it is DISABLED BY DEFAULT and must be +# turned on explicitly — set AGENTFEED_KEY_PROVISIONING_API=1 only on a throwaway +# test install. Never enable it in a real deployment. (Independent of DEBUG, so +# a misconfigured DEBUG=1 production box still won't expose it.) +AGENTFEED_KEY_PROVISIONING_API = os.environ.get( + "AGENTFEED_KEY_PROVISIONING_API", "" +).lower() in {"1", "true", "yes", "on"} # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks @@ -71,7 +70,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application" REST_FRAMEWORK = { # Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before # view validation) to a 400 instead of an unhandled 500. See issue #015. - "EXCEPTION_HANDLER": "artifacts.exception_handlers.daybook_exception_handler", + "EXCEPTION_HANDLER": "artifacts.exception_handlers.agentfeed_exception_handler", } DATABASES = { diff --git a/daybook_project/urls.py b/daybook_project/urls.py index 4204d51..b08c354 100644 --- a/daybook_project/urls.py +++ b/daybook_project/urls.py @@ -2,12 +2,12 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path -from artifacts.views import DaybookLoginView +from artifacts.views import AgentFeedLoginView urlpatterns = [ path("admin/", admin.site.urls), - path("accounts/login/", DaybookLoginView.as_view(), name="login"), + path("accounts/login/", AgentFeedLoginView.as_view(), name="login"), path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"), path("", include("artifacts.urls")), ] diff --git a/deploy/cloud_agent_deploy.md b/deploy/cloud_agent_deploy.md index c369211..dfbde41 100644 --- a/deploy/cloud_agent_deploy.md +++ b/deploy/cloud_agent_deploy.md @@ -1,7 +1,7 @@ # Cloud Deployment Runbook For A Dedicated Deploy Agent This is written for a capable (Opus-class) agent deploying and operating -Daybook on a cloud VM. Treat it as a guide and a source of Daybook-specific +AgentFeed on a cloud VM. Treat it as a guide and a source of AgentFeed-specific facts, **not a rigid script** — you're expected to use judgment, adapt to the actual VM, and read the authoritative sources rather than follow steps blindly. The steps below are a sensible default path; deviate where the host @@ -25,7 +25,7 @@ disagree): Where a decision needs info you don't have (VM IP, domain, whether it's internet-facing, whether a reverse proxy/secondary volume exists), ask the operator once, then proceed autonomously for routine redeploys. The rest of -this file is the recommended path plus the non-obvious, Daybook-specific +this file is the recommended path plus the non-obvious, AgentFeed-specific gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck) that are easy to get wrong. @@ -37,9 +37,9 @@ a token tied to a full user account. 1. Generate an SSH key pair on the VM (or ask the operator for one to install): ```bash - ssh-keygen -t ed25519 -C "daybook-deploy-agent" -f ~/.ssh/daybook_deploy -N "" + ssh-keygen -t ed25519 -C "agentfeed-deploy-agent" -f ~/.ssh/agentfeed_deploy -N "" ``` -2. Ask the operator to add the **public** key (`~/.ssh/daybook_deploy.pub`) as +2. Ask the operator to add the **public** key (`~/.ssh/agentfeed_deploy.pub`) as a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys → read-only is sufficient), or hand it to them directly — never ask them to give you their personal account credentials. @@ -52,7 +52,7 @@ a token tied to a full user account. cat >> ~/.ssh/config <<'EOF' Host git.cygnus.systems Port 2222 - IdentityFile ~/.ssh/daybook_deploy + IdentityFile ~/.ssh/agentfeed_deploy IdentitiesOnly yes EOF ``` @@ -112,12 +112,12 @@ Edit `deploy/.env`: DJANGO_DEBUG=0 DJANGO_SECRET_KEY= DJANGO_ALLOWED_HOSTS=,,localhost,127.0.0.1 -DAYBOOK_DATA_DIR=/data +AGENTFEED_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to set — Daybook issues a personal token per +There is no shared API token to set — AgentFeed issues a personal token per user from Settings → Account once a user exists (step 5). Never commit `deploy/.env`; it is already gitignored. @@ -131,17 +131,17 @@ if unsure which applies here. ## 4. Network Access **If this VM is reachable from the public internet, a reverse proxy with TLS -in front of Daybook is not optional — it's the only path.** Bearer tokens are +in front of AgentFeed is not optional — it's the only path.** Bearer tokens are this app's entire write-auth model, and they'd otherwise go over plaintext HTTP straight to a port on the public IP. Direct port exposure (below) is only for private/VPN-only networks with no public exposure. -- **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in +- **Internet-facing (the common case):** set `AGENTFEED_BIND_ADDR=127.0.0.1` in `deploy/.env` so the container port is not reachable from outside the VM at all, then open `80`/`443` in the VM's firewall/security group for the reverse proxy (see §8). Do not open `8000` externally. - **Private/VPN-only network:** open TCP `8000` to the addresses that should - reach it (never `0.0.0.0/0`), and leave `DAYBOOK_BIND_ADDR` unset. + reach it (never `0.0.0.0/0`), and leave `AGENTFEED_BIND_ADDR` unset. Before opening any port, check whether one is already open and in use — `ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate. @@ -153,8 +153,8 @@ don't have API access to it yourself. ## 5. Build, Start, Create A User If the target has a separate, larger data volume than its main disk (common -on space-constrained VMs), point Daybook's persistent data at it before -starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`. +on space-constrained VMs), point AgentFeed's persistent data at it before +starting: set `AGENTFEED_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`. Ask the operator whether such a volume exists and where before defaulting to the main disk — filling the main disk on a host running other services is a bigger problem than it would be on a dedicated box. Create that same path @@ -162,9 +162,9 @@ bigger problem than it would be on a dedicated box. Create that same path unset) before starting the stack: ```bash -mkdir -p deploy/data # or the DAYBOOK_HOST_DATA_DIR path if you set one above +mkdir -p deploy/data # or the AGENTFEED_HOST_DATA_DIR path if you set one above docker compose -f deploy/docker-compose.yml up -d --build -docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser ``` The entrypoint runs migrations and collects static files automatically on @@ -183,7 +183,7 @@ response when unauthenticated — it confirms the app is up. (Don't use container's own healthcheck hits the login page instead, which is 200 without auth.) This `curl` runs against `127.0.0.1` from on the VM itself, so it works regardless of whether -`DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is +`AGENTFEED_BIND_ADDR` is set. Log in from a browser at whichever address is actually externally reachable at this point in the runbook — `http://:8000/` if you're on the private/VPN-only path from §4, or `https:///` through the reverse proxy once §8 is done if you're on the internet-facing path — with @@ -230,13 +230,13 @@ For a routine forward release that's fine; if a rollback is needed after a migration, restore the pre-deploy `deploy/data` backup (see §9) alongside the code checkout. -`deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points, +`deploy/.env` and the data directory (wherever `AGENTFEED_HOST_DATA_DIR` points, or `deploy/data` by default) are untracked, so `git pull` never touches them — no need to back them up before a routine redeploy. **If the container crash-loops right after a build** (`docker compose ps` shows `restarting`, health never goes green), the most likely cause is the -entrypoint's `collectstatic` step failing — check `docker logs daybook` +entrypoint's `collectstatic` step failing — check `docker logs agentfeed` before assuming an env/network problem. A `whitenoise ... MissingFileError` there means a static asset references a file that isn't present (this is what bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and @@ -277,14 +277,14 @@ instead. Apache example: ```apache - ServerName daybook.example.com + ServerName agentfeed.example.com ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ SSLEngine on - SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem + SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem ``` @@ -296,7 +296,7 @@ Only reach for a standalone proxy like Caddy if the target genuinely has nothing on 80/443 yet — it still needs the same header: ```text -daybook.example.com { +agentfeed.example.com { reverse_proxy 127.0.0.1:8000 { header_up X-Forwarded-Proto https } @@ -305,15 +305,15 @@ daybook.example.com { ## 9. Backup -If you set `DAYBOOK_HOST_DATA_DIR` in step 5, the real data lives there, not +If you set `AGENTFEED_HOST_DATA_DIR` in step 5, the real data lives there, not at `deploy/data` — back up whichever path is actually configured, read straight from `deploy/.env` rather than assumed, so this keeps working whether or not that override is set: ```bash cd /opt/daybook -DATA_DIR="$(grep -oP '(?<=^DAYBOOK_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)" -tar -czf "/tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")" +DATA_DIR="$(grep -oP '(?<=^AGENTFEED_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)" +tar -czf "/tmp/agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")" ``` Copy the resulting archive off the VM (to object storage, the operator's @@ -331,12 +331,12 @@ Before reporting the deployment complete, confirm all of these: public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`. Verify by actually logging in through the proxy, not just curling `/api/capabilities` — CSRF only bites on POST (login, artifact writes). -- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. +- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy. - [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token. - [ ] At least one superuser exists with a working personal API token. - [ ] A smoke-test artifact posts and renders successfully. - [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`. -- [ ] A backup of the data directory (`deploy/data`, or `DAYBOOK_HOST_DATA_DIR` +- [ ] A backup of the data directory (`deploy/data`, or `AGENTFEED_HOST_DATA_DIR` if you set it) has been taken and copied off the VM. - [ ] If the target host has its own service-inventory, monitoring, or changelog conventions for new services, they've been followed — ask diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cbb276c..f3a3376 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -# ${VAR} substitution below (DAYBOOK_BIND_ADDR, DAYBOOK_HOST_DATA_DIR) reads +# ${VAR} substitution below (AGENTFEED_BIND_ADDR, AGENTFEED_HOST_DATA_DIR) reads # from deploy/.env because Compose resolves .env relative to *this file's* # directory, not the caller's cwd. That relies on being invoked as # `docker compose -f deploy/docker-compose.yml` from the repo root, per @@ -7,20 +7,20 @@ # the defaults (0.0.0.0 bind, ./data) would be a security regression, not # just a broken build. services: - daybook: + agentfeed: build: context: .. dockerfile: deploy/Dockerfile - container_name: daybook + container_name: agentfeed restart: unless-stopped env_file: - .env mem_limit: 384m mem_reservation: 128m ports: - - "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000" + - "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000" volumes: - - ${DAYBOOK_HOST_DATA_DIR:-./data}:/data + - ${AGENTFEED_HOST_DATA_DIR:-./data}:/data healthcheck: # Hit the login page, not /api/capabilities: capabilities returns 403 # when unauthenticated, and `curl --fail` treats 403 as an error, which diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index cdd620d..aaea9ca 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" +mkdir -p "${AGENTFEED_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" python manage.py migrate --noinput python manage.py collectstatic --noinput diff --git a/deploy/env.example b/deploy/env.example index 1eb6d11..6c034a9 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,26 +1,26 @@ DJANGO_DEBUG=0 DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example -# Required if Daybook sits behind a TLS-terminating reverse proxy (Apache, +# Required if AgentFeed sits behind a TLS-terminating reverse proxy (Apache, # Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with # "CSRF verification failed" because Django can't tell the original request # was HTTPS. Set to the public HTTPS origin(s) users actually visit: DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example -# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API -# keys over the network (it returns key material), which is only appropriate -# for automated test installations. Unset, it follows DJANGO_DEBUG — so with -# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box. -# DAYBOOK_KEY_PROVISIONING_API=0 -DAYBOOK_DATA_DIR=/data +# Disabled by default. When on, POST /api/keys can create/list/revoke API keys +# over the network (it returns key material), which is only appropriate for +# automated test installations. Leave unset in deployment; set to 1 only on a +# throwaway test box. +# AGENTFEED_KEY_PROVISIONING_API=0 +AGENTFEED_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store # Optional, read by deploy/docker-compose.yml directly (not by the app): -# DAYBOOK_BIND_ADDR=127.0.0.1 +# AGENTFEED_BIND_ADDR=127.0.0.1 # Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will # front this service — this stops the container port from being reachable # directly from the network. Leave unset (binds all interfaces) only for # private/VPN-only deployments with no proxy in front. -# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data +# AGENTFEED_HOST_DATA_DIR=/mnt/some-larger-volume/agentfeed-data # Uncomment and point at a path on a separate/larger volume if the host's # main disk is space-constrained. Defaults to ./data next to this file. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index 0c78356..942ccee 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -1,6 +1,6 @@ # Agent Quickstart -This is the shortest path for an agent to publish and retrieve artifacts from Daybook. +This is the shortest path for an agent to publish and retrieve artifacts from AgentFeed. ## 0. Get Your API Key diff --git a/docs/api.md b/docs/api.md index 581f8f5..b3f6ee8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ -# Daybook API +# AgentFeed API -Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. +AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata. Base URL while running locally: @@ -28,7 +28,7 @@ Currently supported kinds: | Kind | Upload type | Rendered behavior | Raw support | |---|---|---|---| -| `html_fragment` | JSON text | Wrapped with a Daybook theme; tables styled automatically; **HTML sanitized** | No | +| `html_fragment` | JSON text | Wrapped with a AgentFeed theme; tables styled automatically; **HTML sanitized** | No | | `html_full` | JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes | | `markdown` | JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), **sanitized**, original `.md` preserved | Yes | | `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No | @@ -482,7 +482,7 @@ Raw currently exists for: | `json` | Preserved original JSON text. | | `csv` | Preserved original CSV text. | -If raw content is unavailable, Daybook returns a small HTML fallback page. +If raw content is unavailable, AgentFeed returns a small HTML fallback page. ## Sharing @@ -503,7 +503,7 @@ GET /s/{share_token} with the link can view it. - Images embedded in a shared artifact's content are also served unauthenticated while that artifact is shared, so inline images don't - break for a visitor without a Daybook account. + break for a visitor without a AgentFeed account. - `is_shared` and `share_url` (both in every artifact response — see `GET /api/artifacts/{id}`) report current sharing status; `share_url` is `null` when not shared. @@ -540,7 +540,7 @@ Example response: This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree. -Agents should call this endpoint if they need to adapt to the current Daybook instance, or +Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or simply to check the instance is reachable and their token is valid. Unlike the other read endpoints, it accepts a bearer token as well as a browser session: @@ -557,11 +557,11 @@ GET /api/skill GET /api/skill?format=json ``` -Returns the bundled agent-posting skill (`skills/daybook-posting/SKILL.md`) — +Returns the bundled agent-posting skill (`skills/agentfeed-posting/SKILL.md`) — the same instructions you'd give an AI so it knows how to publish to this -instance. The server rewrites the `DAYBOOK_URL` line to **this instance's own +instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own origin**, so the text a caller receives already points at the right server; -a recipient only has to fill in their `DAYBOOK_TOKEN`. +a recipient only has to fill in their `AGENTFEED_TOKEN`. Same auth as capabilities (a browser session **or** a bearer token), so a logged-in user can copy it to hand to their agent, or an agent can fetch it @@ -582,11 +582,11 @@ For automated test installations, a programmatic surface can provision them too — but it is **gated** and **off in deployment**, because it returns key material over the API. -The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG` -(on locally, off in production); set it explicitly (`0`/`1`) to override. -`GET /api/capabilities` reports the current state as `key_provisioning_api`, -and only lists the `/api/keys` methods when it's enabled. When disabled, every -method below returns `403`. +The gate is `AGENTFEED_KEY_PROVISIONING_API`. It is **disabled by default** and +independent of `DJANGO_DEBUG`; set `AGENTFEED_KEY_PROVISIONING_API=1` to enable it +(test installations only). `GET /api/capabilities` reports the current state as +`key_provisioning_api`, and only lists the `/api/keys` methods when it's +enabled. When disabled, every method below returns `403`. ```http GET /api/keys # list this user's keys (incl. key values) diff --git a/docs/deployment.md b/docs/deployment.md index c1f6257..ad22fce 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment -Daybook deploys the same way on any Linux host with Docker: a cloud VM, a home +AgentFeed deploys the same way on any Linux host with Docker: a cloud VM, a home server, a Proxmox guest, whatever. It runs as a single Docker container (Gunicorn) with all persistent state — the SQLite database and artifact files — on a bind-mounted `./data` directory outside the image. @@ -8,7 +8,7 @@ on a bind-mounted `./data` directory outside the image. ```text Target host (cloud VM, home server, ...) Docker Engine - daybook container + agentfeed container /app application code baked into the image /data persistent volume: db.sqlite3, artifacts_store/ ``` @@ -17,8 +17,8 @@ Target host (cloud VM, home server, ...) | File | Purpose | |---|---| -| `Dockerfile` | Builds the Daybook image. | -| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | +| `Dockerfile` | Builds the AgentFeed image. | +| `docker-compose.yml` | Runs AgentFeed with a persistent `./data:/data` bind mount. | | `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. | | `env.example` | Template for deployment environment variables. | @@ -59,12 +59,12 @@ Edit `deploy/.env` and set at minimum: DJANGO_DEBUG=0 DJANGO_SECRET_KEY= DJANGO_ALLOWED_HOSTS=,localhost,127.0.0.1 -DAYBOOK_DATA_DIR=/data +AGENTFEED_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to configure — Daybook uses per-user API keys +There is no shared API token to configure — AgentFeed uses per-user API keys created under Settings → API Keys once a user exists (each user can hold several, one per agent, each individually revocable). Do not commit `deploy/.env`. @@ -80,7 +80,7 @@ Create your first login (superuser) so you have something to log in with and a personal API token to post through: ```bash -docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser ``` ## Verify Deployment @@ -106,7 +106,7 @@ curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ "source": "deploy-smoke", "kind": "markdown", "title": "Deployment Smoke Test", - "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact." + "content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact." }' ``` @@ -127,17 +127,17 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by ## Operational Commands ```bash -docker compose -f deploy/docker-compose.yml logs -f daybook -docker compose -f deploy/docker-compose.yml restart daybook +docker compose -f deploy/docker-compose.yml logs -f agentfeed +docker compose -f deploy/docker-compose.yml restart agentfeed docker compose -f deploy/docker-compose.yml down docker compose -f deploy/docker-compose.yml up -d -docker compose -f deploy/docker-compose.yml exec daybook python manage.py check -docker compose -f deploy/docker-compose.yml exec daybook sh +docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py check +docker compose -f deploy/docker-compose.yml exec agentfeed sh ``` ## Django Admin (`/admin/`) -Daybook ships Django's built-in admin at `/admin/` as an operator +AgentFeed ships Django's built-in admin at `/admin/` as an operator break-glass tool — for raw-data fixes: reset a password, inspect or delete any user's artifacts, adjust a quota, audit or revoke API keys. It is separate from the in-app **Settings** area, which is the everyday product @@ -147,17 +147,17 @@ surface (see the in-app guide / `docs/` for normal use). `is_staff = True`. The account you create with `createsuperuser` has it (plus full superuser rights). Note the two different admin concepts: -- `is_superuser` — Daybook's *in-app* admin (can reach Settings → Users). +- `is_superuser` — AgentFeed's *in-app* admin (can reach Settings → Users). - `is_staff` — can log into *Django's* `/admin/` site. The `createsuperuser` account has **both**. A user promoted in-app via Settings → Users ("Grant admin access") gets `is_superuser` only — so they -can administer Daybook through its own UI but **cannot** reach `/admin/`. +can administer AgentFeed through its own UI but **cannot** reach `/admin/`. Regular users have neither. To grant someone `/admin/` access, set staff explicitly: ```bash -docker compose -f deploy/docker-compose.yml exec daybook \ +docker compose -f deploy/docker-compose.yml exec agentfeed \ python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='NAME'); u.is_staff=True; u.save()" ``` @@ -172,23 +172,23 @@ Back up `deploy/data/` — it contains the SQLite database and all artifact files: ```bash -tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data ``` For a consistent snapshot during heavy writes, stop the container first: ```bash docker compose -f deploy/docker-compose.yml down -tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data docker compose -f deploy/docker-compose.yml up -d ``` -To restore: stop Daybook, replace `deploy/data/` with the backup contents, +To restore: stop AgentFeed, replace `deploy/data/` with the backup contents, then start it again. ## Reverse Proxy Notes -If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: +If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer: - Proxy to `http://:8000`. - Add the public hostname to `DJANGO_ALLOWED_HOSTS`. @@ -200,8 +200,8 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: terminates TLS and forwards plain HTTP to the container. ```text -DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 -DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com +DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com ``` Forwarding the proto header, by proxy: @@ -220,7 +220,7 @@ Caddy: header_up X-Forwarded-Proto https - If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise). - `deploy/data/` exists and persists across `git pull` + rebuild. -- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. +- `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy. - `GET /api/capabilities` returns 403 without auth, 200 with a valid token or session (check the status code — don't use `curl -f`, which errors on 403). - At least one superuser exists (`manage.py createsuperuser`) with a key diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 8fc4194..90897d9 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -1,8 +1,8 @@ # LLM Posting Helper -Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. +Agents should publish to AgentFeed with `skills/agentfeed-posting/scripts/agentfeed_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. -The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. +The project also keeps `scripts/agentfeed_post.py` as a compatibility wrapper. The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux. @@ -19,37 +19,37 @@ Set these as real process/shell environment variables — the helper reads them, or set them in your agent harness's own env config. ```text -DAYBOOK_URL=http://127.0.0.1:8001 -DAYBOOK_TOKEN=your-personal-api-token -DAYBOOK_SOURCE=agent +AGENTFEED_URL=http://127.0.0.1:8001 +AGENTFEED_TOKEN=your-personal-api-token +AGENTFEED_SOURCE=agent ``` -`DAYBOOK_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment. +`AGENTFEED_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment. ## Commands Check the instance: ```bash -python skills/daybook-posting/scripts/daybook_post.py --check +python skills/agentfeed-posting/scripts/agentfeed_post.py --check ``` Post Markdown from a file: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report ``` Post Markdown from stdin: ```bash -cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" ``` Post a deck: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Deployment Plan" --content-file deck.html ``` `deck.html` must contain top-level `
...
` blocks. @@ -57,44 +57,44 @@ python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deplo Post an image/PDF/binary file: ```bash -python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" -python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" +python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram" +python skills/agentfeed-posting/scripts/agentfeed_post.py --file report.pdf --title "Generated Report" ``` Post CSV or JSON as an interactive table/tree (auto-mode detects the kind from the extension; explicit `--kind` works too): ```bash -python skills/daybook-posting/scripts/daybook_post.py results.csv -python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json +python skills/agentfeed-posting/scripts/agentfeed_post.py results.csv +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind json --title "Run Summary" --content-file summary.json ``` Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact — active sheet only, saved formula results only, no formatting preserved): ```bash -python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx +python skills/agentfeed-posting/scripts/agentfeed_post.py balance_sheet.xlsx ``` Render other code as a Markdown fenced block instead (no dedicated `code` kind yet): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" +python skills/agentfeed-posting/scripts/agentfeed_post.py --file script.py --as-markdown --title "Generated Script" ``` Embed an image inside a markdown/HTML artifact (upload first, then reference the printed URL in your content): ```bash -python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png +python skills/agentfeed-posting/scripts/agentfeed_post.py --upload-image chart.png # -> prints .../assets/; put that in your markdown, then post normally ``` Dry run: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Draft" --content-file report.md --dry-run ``` The artifact URL is printed as the final stdout line. @@ -118,7 +118,7 @@ The helper validates common LLM mistakes before sending: Use repeatable `--meta key=value` flags: ```bash -python skills/daybook-posting/scripts/daybook_post.py \ +python skills/agentfeed-posting/scripts/agentfeed_post.py \ --kind markdown \ --title "Run Summary" \ --content-file summary.md \ @@ -129,7 +129,7 @@ python skills/daybook-posting/scripts/daybook_post.py \ Or pass a JSON object: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}' +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}' ``` `--meta` and `--meta-json` are merged if both are given. Metadata lives in @@ -140,7 +140,7 @@ filtering). | Flag | Default | Notes | |---|---|---| -| `--source` | `DAYBOOK_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. | +| `--source` | `AGENTFEED_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. | | `--source-type` | `agent` | One of `agent`, `person`, `system`. | | `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". | | `--source-id` | Empty | A stable external ID, if one exists. | @@ -152,7 +152,7 @@ filtering). A thin LLM-facing wrapper lives at: ```text -skills/daybook-posting/SKILL.md +skills/agentfeed-posting/SKILL.md ``` -Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`. +Its key instruction is: do not hand-build requests; use `scripts/agentfeed_post.py`. diff --git a/scripts/README.md b/scripts/README.md index e681cd0..5b7aa75 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,28 +1,28 @@ -# Daybook Posting Helper +# AgentFeed Posting Helper -`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`. +`scripts/agentfeed_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/agentfeed-posting/scripts/agentfeed_post.py`. -The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests. +The helper is for agents and scripts that need to publish artifacts to AgentFeed without hand-crafting JSON, curl, or multipart requests. Configuration: ```text -DAYBOOK_URL=http://127.0.0.1:8001 -DAYBOOK_TOKEN=your-personal-api-token -DAYBOOK_SOURCE=agent +AGENTFEED_URL=http://127.0.0.1:8001 +AGENTFEED_TOKEN=your-personal-api-token +AGENTFEED_SOURCE=agent ``` -`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users. +`AGENTFEED_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users. Examples: ```bash -python skills/daybook-posting/scripts/daybook_post.py --check -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report -cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" -python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html -python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" -python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +python skills/agentfeed-posting/scripts/agentfeed_post.py --check +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Plan" --content-file deck.html +python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram" +python skills/agentfeed-posting/scripts/agentfeed_post.py --file results.csv --as-markdown --title "Results CSV" ``` The artifact browser URL is printed as the final stdout line. diff --git a/scripts/agentfeed_post.py b/scripts/agentfeed_post.py new file mode 100644 index 0000000..6cb2097 --- /dev/null +++ b/scripts/agentfeed_post.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the AgentFeed posting helper. + +The canonical helper lives inside the self-contained skill folder: +skills/agentfeed-posting/scripts/agentfeed_post.py +""" + +import runpy +from pathlib import Path + + +HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py" + + +if __name__ == "__main__": + runpy.run_path(str(HELPER), run_name="__main__") diff --git a/scripts/daybook_post.py b/scripts/daybook_post.py deleted file mode 100644 index 37b8c9b..0000000 --- a/scripts/daybook_post.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Compatibility wrapper for the Daybook posting helper. - -The canonical helper lives inside the self-contained skill folder: -skills/daybook-posting/scripts/daybook_post.py -""" - -import runpy -from pathlib import Path - - -HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py" - - -if __name__ == "__main__": - runpy.run_path(str(HELPER), run_name="__main__") diff --git a/scripts/seed_demo_artifacts.py b/scripts/seed_demo_artifacts.py index dba250f..6824588 100644 --- a/scripts/seed_demo_artifacts.py +++ b/scripts/seed_demo_artifacts.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Post one demo artifact of every kind, tagged, dated today. -Posts through the real ingest API using the existing daybook_post.py helper, +Posts through the real ingest API using the existing agentfeed_post.py helper, so seeded data goes through the same normalization as real agent output. Usage (from project root, with the dev server running): @@ -13,7 +13,7 @@ import sys import tempfile from pathlib import Path -HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py" +HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py" PERSONA = "seed-demo" TAGS = "seed-demo,demo" diff --git a/skills/daybook-posting/SKILL.md b/skills/agentfeed-posting/SKILL.md similarity index 72% rename from skills/daybook-posting/SKILL.md rename to skills/agentfeed-posting/SKILL.md index 12dd9ff..2e39144 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/agentfeed-posting/SKILL.md @@ -1,13 +1,13 @@ --- -name: daybook-posting -description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a Daybook instance and returns a URL. +name: agentfeed-posting +description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a AgentFeed instance and returns a URL. --- -# Daybook Posting +# AgentFeed Posting -Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations. +AgentFeed is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations. -You post to a **running Daybook server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of Daybook's source. The only things that tie you to a particular instance are two environment variables: `DAYBOOK_URL` (where the server is) and `DAYBOOK_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run. +You post to a **running AgentFeed server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of AgentFeed's source. The only things that tie you to a particular instance are two environment variables: `AGENTFEED_URL` (where the server is) and `AGENTFEED_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run. ## Configuration @@ -17,20 +17,20 @@ config) — the helper reads `os.environ` directly. There is no `.env` file support; a `.env` file in the working directory is not read automatically. ```text -DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL -DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys -DAYBOOK_SOURCE=agent # a stable label for what's posting +AGENTFEED_URL=https://your-agentfeed-instance.example.com # your server's base URL +AGENTFEED_TOKEN=your-personal-api-key # from Settings -> API Keys +AGENTFEED_SOURCE=agent # a stable label for what's posting ``` -- **`DAYBOOK_URL`** — the base URL of the Daybook instance you're posting to. +- **`AGENTFEED_URL`** — the base URL of the AgentFeed instance you're posting to. Set it to your server. If it's unset, the helper falls back to - `http://127.0.0.1:8001`, which is only correct when Daybook is running + `http://127.0.0.1:8001`, which is only correct when AgentFeed is running locally on the same machine (its development default). For any remote or deployed instance you **must** set this. -- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings → +- **`AGENTFEED_TOKEN`** — a personal API key, created in the app under Settings → API Keys (name one per agent so you can revoke them individually). Not a key shared by everyone; artifacts posted with it land only in your feed. -- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting +- **`AGENTFEED_SOURCE`** — a free-text label for whatever is posting (agent-profile, script, or person name). Optional; can also be passed per call with `--source`. @@ -41,29 +41,29 @@ PowerShell multipart, or inline shell content (a `curl` fallback for environments without the helper is documented at the end — the script is still preferred everywhere it's available). -The helper lives at **`scripts/daybook_post.py` inside this skill's own +The helper lives at **`scripts/agentfeed_post.py` inside this skill's own directory** — not in any project you happen to be working in. Invoke it by its absolute path. For brevity, the examples below assume you've captured that path once: ```bash # Point this at the helper inside THIS skill directory (absolute path). -DAYBOOK_POST=/abs/path/to/this/skill/scripts/daybook_post.py +AGENTFEED_POST=/abs/path/to/this/skill/scripts/agentfeed_post.py ``` Then: ```bash -python "$DAYBOOK_POST" --check -python "$DAYBOOK_POST" --kind markdown --title "Build Report" --content-file report.md -cat report.md | python "$DAYBOOK_POST" --kind markdown --title "Build Report" -python "$DAYBOOK_POST" --file diagram.svg --title "Architecture Diagram" +python "$AGENTFEED_POST" --check +python "$AGENTFEED_POST" --kind markdown --title "Build Report" --content-file report.md +cat report.md | python "$AGENTFEED_POST" --kind markdown --title "Build Report" +python "$AGENTFEED_POST" --file diagram.svg --title "Architecture Diagram" ``` The artifact URL is printed as the final stdout line. -(If you *are* working inside a Daybook source checkout, a compatibility -wrapper also exists at `scripts/daybook_post.py` in the repo root, but the +(If you *are* working inside a AgentFeed source checkout, a compatibility +wrapper also exists at `scripts/agentfeed_post.py` in the repo root, but the canonical helper is the one bundled in this skill.) ## Kind Cheat Sheet @@ -100,7 +100,7 @@ with no API equivalent at all yet. ## CDN Snippets For `html_full` -`html_full` is Daybook's escape hatch for custom pages — nothing is +`html_full` is AgentFeed's escape hatch for custom pages — nothing is injected into it, so a poster wanting Tailwind or a charting library has to supply its own ``/` ``` -**Offline caveat:** Daybook can run fully self-hosted/air-gapped. Any +**Offline caveat:** AgentFeed can run fully self-hosted/air-gapped. Any artifact that links a CDN script won't render fully (or at all, for Tailwind's in-browser compiler) without internet access. If the deployment target is offline, don't reach for these — use `markdown`/`html_fragment` instead, which are styled entirely server-side with no external requests. -**Self-hosted alternative for tables/JSON — no CDN needed:** Daybook already +**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed already vendors Grid.js and renderjson locally (used to render `csv`/`json` artifacts), and they're reachable as plain static files from any artifact, including `html_full`: @@ -138,7 +138,7 @@ tables/JSON; there's no vendored equivalent for Tailwind/Chart.js, so the CDN snippets above are still the only option for those. Only use this for `html_full` specifically — never add CDN references to -`markdown`/`html_fragment`/`deck`. Those kinds exist so Daybook, not the +`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the poster, owns styling; injecting a default CDN habit into them would undermine that. @@ -149,12 +149,12 @@ artifact), upload it first and reference the returned URL: ```bash # 1. Upload -> prints the URL as the last line -python "$DAYBOOK_POST" --upload-image chart.png +python "$AGENTFEED_POST" --upload-image chart.png # 2. Reference that URL in your markdown, then post as usual. -# The printed URL is absolute against your DAYBOOK_URL, e.g. -# ![chart]($DAYBOOK_URL/assets/ab12cd34ef56) -python "$DAYBOOK_POST" report.md +# The printed URL is absolute against your AGENTFEED_URL, e.g. +# ![chart]($AGENTFEED_URL/assets/ab12cd34ef56) +python "$AGENTFEED_POST" report.md ``` The image is linked to the artifact that references it and is deleted along with it. @@ -167,15 +167,15 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks the right kind automatically: ```bash -python "$DAYBOOK_POST" results.csv -python "$DAYBOOK_POST" --kind json --title "Run Summary" --content-file summary.json +python "$AGENTFEED_POST" results.csv +python "$AGENTFEED_POST" --kind json --title "Run Summary" --content-file summary.json ``` `.xlsx` spreadsheets work too, with no special flag needed — just `--file` (or post it positionally, same as any other file): ```bash -python "$DAYBOOK_POST" balance_sheet.xlsx +python "$AGENTFEED_POST" balance_sheet.xlsx ``` The server converts it to a `csv` artifact automatically. Known @@ -196,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown fenced code block (still gets syntax highlighting): ```bash -python "$DAYBOOK_POST" --file script.py --as-markdown --title "Generated Script" +python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script" ``` Without `--as-markdown`, it uploads as a downloadable binary file instead. @@ -208,7 +208,7 @@ All optional, all default to something reasonable if omitted: | Flag | Default | Notes | |---|---|---| | `--source-type` | `agent` | One of `agent`, `person`, `system`. | -| `--source-name` | Same as the effective source (`--source`, or `DAYBOOK_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". | +| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". | | `--source-id` | Empty string | A stable external ID, if the poster has one. | | `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. | @@ -219,9 +219,9 @@ context like `run_id` or `model` — these end up in the artifact's ## Rules -- Run `--check` first if unsure Daybook is reachable. -- Make sure `DAYBOOK_URL` points at the right instance and `DAYBOOK_TOKEN` is set before posting anything real. -- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). +- Run `--check` first if unsure AgentFeed is reachable. +- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real. +- Use a stable `AGENTFEED_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). - Use `--tags a,b,c` for filtering. - Use `--meta run_id=... --meta model=...` for machine-readable context. - Never inline multiline content into the shell command; use a file or stdin. @@ -236,8 +236,8 @@ with no Python). Text artifacts post as JSON to `POST /api/artifacts` with bearer-token auth: ```bash -curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ - -H "Authorization: Bearer $DAYBOOK_TOKEN" \ +curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \ + -H "Authorization: Bearer $AGENTFEED_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}' ``` @@ -245,12 +245,12 @@ curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ File/image artifacts use multipart instead: ```bash -curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ - -H "Authorization: Bearer $DAYBOOK_TOKEN" \ +curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \ + -H "Authorization: Bearer $AGENTFEED_TOKEN" \ -F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \ -F "file=@diagram.png" ``` -`GET $DAYBOOK_URL/api/capabilities` (no auth needed beyond a login/token per +`GET $AGENTFEED_URL/api/capabilities` (no auth needed beyond a login/token per that instance's config) lists supported kinds, methods, and the server version if you need to confirm the contract before posting. diff --git a/skills/daybook-posting/scripts/daybook_post.py b/skills/agentfeed-posting/scripts/agentfeed_post.py similarity index 95% rename from skills/daybook-posting/scripts/daybook_post.py rename to skills/agentfeed-posting/scripts/agentfeed_post.py index 659fbd4..4d676de 100644 --- a/skills/daybook-posting/scripts/daybook_post.py +++ b/skills/agentfeed-posting/scripts/agentfeed_post.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Post artifacts to Daybook without shell-specific JSON or multipart quoting. +"""Post artifacts to AgentFeed without shell-specific JSON or multipart quoting. Zero third-party dependencies. Content comes from a file or stdin so agents do not need to inline multiline JSON/HTML/Markdown in shell commands. @@ -26,11 +26,11 @@ DEFAULT_SOURCE = "agent" def main(): args = parse_args() - base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/") - # DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app); + base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/") + # AGENTFEED_TOKEN is your personal per-user token (Settings -> API Keys in the app); # there is no shared fallback token, so posting without it cannot work. - token = os.environ.get("DAYBOOK_TOKEN", "") - source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE) + token = os.environ.get("AGENTFEED_TOKEN", "") + source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) if args.check: try: @@ -43,8 +43,8 @@ def main(): if not token: print( - "error: DAYBOOK_TOKEN is not set. Get your personal API token from " - f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.", + "error: AGENTFEED_TOKEN is not set. Get your personal API token from " + f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", file=sys.stderr, ) return 2 @@ -84,11 +84,11 @@ def main(): def parse_args(): - parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.") + parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.") parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.") parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.") parser.add_argument("--title", help="Artifact title. Required unless --check is used.") - parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.") + parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_SOURCE or 'agent'.") parser.add_argument("--content-file", help="Read text artifact content from this file.") parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.") parser.add_argument("--tags", help="Comma-separated tags.") @@ -352,7 +352,7 @@ def post_payload(base_url, token, payload, args): def multipart_body(payload): - boundary = f"----daybook-{uuid.uuid4().hex}" + boundary = f"----agentfeed-{uuid.uuid4().hex}" chunks = [] upload_path = payload["upload_path"] for key, value in payload.items(): @@ -394,11 +394,11 @@ def content_type_for(path): def upload_asset(base_url, token, path): - """Upload an image and return its Daybook URL for embedding in content.""" + """Upload an image and return its AgentFeed URL for embedding in content.""" if not path.is_file(): raise ValueError(f"image file does not exist: {path}") - boundary = f"----daybook-{uuid.uuid4().hex}" + boundary = f"----agentfeed-{uuid.uuid4().hex}" chunks = [ f"--{boundary}\r\n".encode("utf-8"), f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"), diff --git a/static/daybook.css b/static/agentfeed.css similarity index 100% rename from static/daybook.css rename to static/agentfeed.css diff --git a/templates/artifacts/_topbar.html b/templates/artifacts/_topbar.html index c63dfe7..cda9526 100644 --- a/templates/artifacts/_topbar.html +++ b/templates/artifacts/_topbar.html @@ -1,4 +1,4 @@ -{% load daybook_extras %} +{% load agentfeed_extras %}

AgentFeed

@@ -48,10 +48,10 @@ const isDark = document.documentElement.getAttribute("data-theme") === "dark"; if (isDark) { document.documentElement.removeAttribute("data-theme"); - try { localStorage.setItem("daybook.theme", "light"); } catch (e) {} + try { localStorage.setItem("agentfeed.theme", "light"); } catch (e) {} } else { document.documentElement.setAttribute("data-theme", "dark"); - try { localStorage.setItem("daybook.theme", "dark"); } catch (e) {} + try { localStorage.setItem("agentfeed.theme", "dark"); } catch (e) {} } syncLabel(); }); diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index c482a60..55bfdb7 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -6,12 +6,12 @@ AgentFeed - + "), 3) # The escaped content must still round-trip to the exact original value. - start = stored_html.index('id="daybook-data">') + len('id="daybook-data">') + start = stored_html.index('id="agentfeed-data">') + len('id="agentfeed-data">') end = stored_html.index("", start) embedded = stored_html[start:end] self.assertEqual(json.loads(embedded)["value"], "") diff --git a/tests/scripts/test_daybook_post.py b/tests/scripts/test_agentfeed_post.py similarity index 60% rename from tests/scripts/test_daybook_post.py rename to tests/scripts/test_agentfeed_post.py index b6d0aa0..b84551b 100644 --- a/tests/scripts/test_daybook_post.py +++ b/tests/scripts/test_agentfeed_post.py @@ -6,76 +6,76 @@ from pathlib import Path SCRIPT_PATH = ( Path(__file__).resolve().parents[2] / "skills" - / "daybook-posting" + / "agentfeed-posting" / "scripts" - / "daybook_post.py" + / "agentfeed_post.py" ) def _load_module(): - spec = importlib.util.spec_from_file_location("daybook_post", SCRIPT_PATH) + spec = importlib.util.spec_from_file_location("agentfeed_post", SCRIPT_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -daybook_post = _load_module() +agentfeed_post = _load_module() class SniffTextKindTestCase(unittest.TestCase): def test_markdown_suffixes(self): for suffix in (".md", ".markdown", ".txt"): - self.assertEqual(daybook_post.sniff_text_kind(suffix, "anything"), "markdown") + self.assertEqual(agentfeed_post.sniff_text_kind(suffix, "anything"), "markdown") def test_json_suffix(self): - self.assertEqual(daybook_post.sniff_text_kind(".json", '{"a": 1}'), "json") + self.assertEqual(agentfeed_post.sniff_text_kind(".json", '{"a": 1}'), "json") def test_csv_suffix(self): - self.assertEqual(daybook_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv") + self.assertEqual(agentfeed_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv") def test_html_full_sniffed_from_content(self): self.assertEqual( - daybook_post.sniff_text_kind(".html", ""), "html_full" + agentfeed_post.sniff_text_kind(".html", ""), "html_full" ) def test_deck_sniffed_from_content(self): - self.assertEqual(daybook_post.sniff_text_kind(".html", "
slide
"), "deck") + self.assertEqual(agentfeed_post.sniff_text_kind(".html", "
slide
"), "deck") def test_html_fragment_is_the_fallback(self): - self.assertEqual(daybook_post.sniff_text_kind(".html", "

hi

"), "html_fragment") + self.assertEqual(agentfeed_post.sniff_text_kind(".html", "

hi

"), "html_fragment") class ValidatePayloadTestCase(unittest.TestCase): def test_valid_json_passes(self): - daybook_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise + agentfeed_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise def test_invalid_json_raises(self): with self.assertRaises(ValueError): - daybook_post.validate_payload({"kind": "json", "content": "{not json"}, None) + agentfeed_post.validate_payload({"kind": "json", "content": "{not json"}, None) def test_deck_without_section_raises(self): with self.assertRaises(ValueError): - daybook_post.validate_payload({"kind": "deck", "content": "

no slides

"}, None) + agentfeed_post.validate_payload({"kind": "deck", "content": "

no slides

"}, None) def test_html_fragment_with_nested_html_raises(self): with self.assertRaises(ValueError): - daybook_post.validate_payload( + agentfeed_post.validate_payload( {"kind": "html_fragment", "content": "x"}, None ) def test_html_full_without_html_tag_raises(self): with self.assertRaises(ValueError): - daybook_post.validate_payload({"kind": "html_full", "content": "

x

"}, None) + agentfeed_post.validate_payload({"kind": "html_full", "content": "

x

"}, None) def test_blank_markdown_raises(self): with self.assertRaises(ValueError): - daybook_post.validate_payload({"kind": "markdown", "content": " "}, None) + agentfeed_post.validate_payload({"kind": "markdown", "content": " "}, None) def test_csv_has_no_shape_check(self): - daybook_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise + agentfeed_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise def test_upload_path_skips_all_checks(self): - daybook_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise + agentfeed_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise class ResolveAutoPathTestCase(unittest.TestCase): @@ -93,7 +93,7 @@ class ResolveAutoPathTestCase(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "data.json" path.write_text('{"a": 1}', encoding="utf-8") - args = daybook_post.resolve_auto_path(self._args(path)) + args = agentfeed_post.resolve_auto_path(self._args(path)) self.assertEqual(args.kind, "json") self.assertEqual(args.content_file, str(path)) @@ -101,14 +101,14 @@ class ResolveAutoPathTestCase(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "data.csv" path.write_text("a,b\n1,2", encoding="utf-8") - args = daybook_post.resolve_auto_path(self._args(path)) + args = agentfeed_post.resolve_auto_path(self._args(path)) self.assertEqual(args.kind, "csv") def test_image_file_becomes_multipart_upload(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "chart.png" path.write_bytes(b"\x89PNG\r\n") - args = daybook_post.resolve_auto_path(self._args(path)) + args = agentfeed_post.resolve_auto_path(self._args(path)) self.assertIsNone(args.kind) self.assertEqual(args.file, str(path)) diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index ddc075d..df49e5f 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -16,7 +16,7 @@ class MakeRoomForTestCase(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-storage-")) + cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-storage-")) cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root) cls.settings_override.enable() diff --git a/tests/views/test_sharing.py b/tests/views/test_sharing.py index ab2c893..d25e0de 100644 --- a/tests/views/test_sharing.py +++ b/tests/views/test_sharing.py @@ -16,7 +16,7 @@ class SharingTestCase(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-sharing-")) + cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-sharing-")) cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root) cls.settings_override.enable() From 2b901f7844ae890cabb131c2bac3b6c2c51661e5 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 14:50:48 +0100 Subject: [PATCH 04/16] v0.3.1: bearer-token reads; public skill + capabilities Fixes an inconsistency an agent hit: it could POST/PATCH/DELETE with its token but GET (readback) required a session and 302-redirected to the browser login. - GET /api/artifacts and GET /api/artifacts/{id} now accept a bearer token OR a session, owner-scoped (a token reads only its owner's artifacts). Agents can read back what they posted with the same token. - Unauthenticated API requests return a JSON 403, never a browser-login redirect (resolve_request_user helper; dropped @login_required on the JSON read views). - GET /api/skill and GET /api/capabilities are now public (no user data), so an agent can fetch the contract + posting instructions before it has a key. - Docs (api.md, CLAUDE.md) updated; tests for bearer readback, owner-scoping, and the JSON-403-not-redirect behavior. Suite: 132 passing; verified live. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 +++++++++++- CLAUDE.md | 2 +- artifacts/views.py | 30 +++++++++++++++++-------- daybook_project/__init__.py | 2 +- docs/api.md | 30 +++++++++++++------------ tests/api/test_artifacts.py | 45 ++++++++++++++++++++++++++++++++----- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8acf954..60b5f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ former name, "Daybook".) _Nothing yet._ +## [0.3.1] — 2026-09-04 + +### Changed +- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`) + now accept a bearer token as well as a browser session, owner-scoped — an + agent can read back what it posted with the same token. Unauthenticated API + requests return a JSON `403` instead of a 302 redirect to the browser login. +- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) — + they expose no user data, so an agent can fetch the contract and posting + instructions before it has a key. + ## [0.3.0] — 2026-09-04 ### Changed @@ -117,7 +128,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.3.0...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...HEAD +[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 [0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index c0fa33e..5db5bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ The core product principle is: agents publish content, not files. The server own - Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. - Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. - `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. -- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key. - 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. diff --git a/artifacts/views.py b/artifacts/views.py index d1978ed..99f326b 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -82,12 +82,14 @@ DEFAULT_THEMES = { } -@method_decorator(login_required, name="get") class ArtifactListCreateView(APIView): parser_classes = [JSONParser, FormParser, MultiPartParser] def get(self, request): - artifacts = Artifact.objects.filter(owner=request.user) + user = resolve_request_user(request) + if user is None: + return Response({"detail": "Authentication required (session or bearer token)."}, status=403) + artifacts = Artifact.objects.filter(owner=user) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -223,10 +225,12 @@ def asset_view(request, asset_id): return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") -@method_decorator(login_required, name="get") class ArtifactDetailView(APIView): def get(self, request, artifact_id): - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + user = resolve_request_user(request) + if user is None: + return Response({"detail": "Authentication required (session or bearer token)."}, status=403) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -304,8 +308,7 @@ class CapabilitiesView(APIView): so agents can check connectivity before posting. Exposes no user data.""" def get(self, request): - if not request.user.is_authenticated and resolve_token_user(request) is None: - return HttpResponseForbidden("Login or a valid API token is required") + # Public: capability/contract info only, no user data. from daybook_project import __version__ methods = { @@ -362,9 +365,9 @@ class SkillView(APIView): """ def get(self, request): - if not request.user.is_authenticated and resolve_token_user(request) is None: - return HttpResponseForbidden("Login or a valid API token is required") - + # Public: the skill is bootstrapping instructions (how to authenticate + # and post) and contains no secrets — an agent shouldn't need a key to + # learn how to use one. origin = request.build_absolute_uri("/").rstrip("/") try: skill = render_posting_skill(origin) @@ -1073,6 +1076,15 @@ def resolve_token_user(request): return api_key.user +def resolve_request_user(request): + """The acting user from either a browser session or a bearer API key. + Used by the JSON read endpoints so agents can read back with the same token + they post with (owner-scoped). None if neither authenticates.""" + if request.user.is_authenticated: + return request.user + return resolve_token_user(request) + + def normalize_tags(tags): if isinstance(tags, str): return [tag.strip() for tag in tags.split(",") if tag.strip()] diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 665c74e..72aff5b 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.3.0" +__version__ = "0.3.1" diff --git a/docs/api.md b/docs/api.md index b3f6ee8..e804074 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,11 +16,15 @@ Authorization: Bearer Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. -Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a -logged-in browser session, not a bearer token — visit the app in a browser and log in first. +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept +**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a +token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an +agent can read back what it posted with the same token. Unauthenticated API requests get a +JSON `403`, never a browser-login redirect. -`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check -connectivity before posting. +`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose +no user data), so an agent can discover the contract and fetch posting instructions before +it has a key. ## Artifact Kinds @@ -343,7 +347,7 @@ Endpoint: GET /api/artifacts ``` -Requires a logged-in browser session. Returns only the current user's artifacts. +Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. Supported filters (combine freely; all are ANDed): @@ -541,11 +545,10 @@ This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree. Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or -simply to check the instance is reachable and their token is valid. Unlike the other read -endpoints, it accepts a bearer token as well as a browser session: +simply to check the instance is reachable. It's public — no auth required: ```bash -curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities +curl http://127.0.0.1:8001/api/capabilities ``` ## Posting Skill @@ -563,14 +566,13 @@ instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own origin**, so the text a caller receives already points at the right server; a recipient only has to fill in their `AGENTFEED_TOKEN`. -Same auth as capabilities (a browser session **or** a bearer token), so a -logged-in user can copy it to hand to their agent, or an agent can fetch it -directly. The default response is raw Markdown (`text/markdown`); add -`?format=json` for `{"version", "url", "skill"}`. +**Public** — no auth required (like capabilities), so an agent can fetch its +posting instructions before it has a key. The default response is raw Markdown +(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`. ```bash -# Fetch the ready-to-use instructions for this instance -curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/skill +# Fetch the ready-to-use instructions for this instance (no auth needed) +curl http://127.0.0.1:8001/api/skill ``` Discover it programmatically via `skill_url` in the capabilities response. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index cd7d558..34193b7 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -58,9 +58,10 @@ class ArtifactAPITestCase(APITestCase): # -- capabilities ------------------------------------------------- - def test_capabilities_requires_auth(self): + def test_capabilities_is_public(self): + # Capability/contract info only, no user data — public, no auth needed. response = self.client.get(reverse("capabilities-api")) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) def test_capabilities_with_token(self): response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) @@ -85,9 +86,10 @@ class ArtifactAPITestCase(APITestCase): # -- skill ---------------------------------------------------------- - def test_skill_requires_auth(self): + def test_skill_is_public(self): + # Bootstrapping instructions, no secrets — public, no auth needed. response = self.client.get(reverse("skill-api")) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) def test_skill_returns_markdown_with_origin_filled_in(self): response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token)) @@ -156,9 +158,10 @@ class ArtifactAPITestCase(APITestCase): # -- list (GET) ------------------------------------------------------- - def test_list_requires_session_login(self): + def test_list_requires_auth(self): + # No session and no token -> JSON 403, never a browser-login redirect. response = self.client.get(reverse("artifact-api")) - self.assertEqual(response.status_code, 302) # redirected to login + self.assertEqual(response.status_code, 403) def test_list_scoped_to_owner(self): self.create_artifact() @@ -169,6 +172,15 @@ class ArtifactAPITestCase(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual([a["title"] for a in response.data], ["Hello World"]) + def test_list_readable_with_bearer_token(self): + # Agents read back with the same token they post with; owner-scoped. + self.create_artifact() + self.create_artifact(token=self.other_token, title="Not mine") + + response = self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)) + self.assertEqual(response.status_code, 200) + self.assertEqual([a["title"] for a in response.data], ["Hello World"]) + def test_list_filter_by_label(self): self.create_artifact(tags=["urgent"]) self.create_artifact(title="Second", tags=["later"]) @@ -220,6 +232,27 @@ class ArtifactAPITestCase(APITestCase): response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) self.assertEqual(response.status_code, 200) + def test_detail_readable_with_bearer_token(self): + # The api_url returned by POST is fetchable with the posting token. + artifact_id = self.create_artifact().data["id"] + + response = self.client.get( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], artifact_id) + + # ...but only the owner's token; another user's token gets 404. + response = self.client.get( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token) + ) + self.assertEqual(response.status_code, 404) + + def test_detail_requires_auth(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 403) + def test_patch_updates_title_and_tags(self): artifact_id = self.create_artifact().data["id"] response = self.client.patch( From f6f004c6898de8bd7717636207b7411c1af52921 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 15:20:37 +0100 Subject: [PATCH 05/16] v0.3.2: add GET /api/artifacts/{id}/raw for content read-back Metadata (GET /api/artifacts/{id}) stays metadata-only; the full content now has its own endpoint so an agent can verify exactly what it posted. - New ArtifactRawView at /api/artifacts/{id}/raw: bearer-or-session auth, owner-scoped. Serves exact source text (markdown/html_full/json/csv), file bytes (image/pdf/binary), or 404 for render-only kinds (html_fragment/deck). Reuses raw_artifact_path/safe_artifact_path. - serialize_artifact exposes api_raw_url; /api/capabilities advertises the route. (The old /a/{id}?raw= viewer stays session-only for browsers.) - Docs + tests (exact content, owner-scoping, 403 unauth, 404 render-only). Suite: 136 passing; verified live. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++++++++++- artifacts/urls.py | 1 + artifacts/views.py | 33 ++++++++++++++++++++++++++++++ daybook_project/__init__.py | 2 +- docs/api.md | 28 ++++++++++++++------------ tests/api/test_artifacts.py | 40 +++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b5f60..26f8719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [0.3.2] — 2026-09-04 + +### Added +- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the + metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an + agent can read back and verify exactly what it posted: exact source text for + `markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`, + `404` for render-only `html_fragment`/`deck`. The metadata response now + includes `api_raw_url`, and `/api/capabilities` advertises the route. + ## [0.3.1] — 2026-09-04 ### Changed @@ -128,7 +138,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.3.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...HEAD +[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 diff --git a/artifacts/urls.py b/artifacts/urls.py index 1cbd657..008ceb5 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), + path("api/artifacts//raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), diff --git a/artifacts/views.py b/artifacts/views.py index 99f326b..d82eed9 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -303,6 +303,37 @@ class ArtifactDetailView(APIView): return Response(status=204) +class ArtifactRawView(APIView): + """The artifact's full content, for read-back / verification. Same auth as + the detail endpoint (bearer token or session), owner-scoped. + + GET /api/artifacts/{id}/raw + - markdown / html_full / json / csv -> exact posted source (text/plain) + - image / pdf / binary -> the stored file bytes + - html_fragment / deck -> 404 (render-only; no raw kept) + """ + + def get(self, request, artifact_id): + user = resolve_request_user(request) + if user is None: + return Response({"detail": "Authentication required (session or bearer token)."}, status=403) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + + raw_path = raw_artifact_path(artifact) + if raw_path and raw_path.exists(): + return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8") + + if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}: + path = safe_artifact_path(artifact.path) + if path.exists(): + content_type, _ = mimetypes.guess_type(path.name) + return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") + + return Response( + {"detail": "No raw content available for this kind (render-only)."}, status=404 + ) + + class CapabilitiesView(APIView): """Static capability info. Reachable by a logged-in session or a valid API token, so agents can check connectivity before posting. Exposes no user data.""" @@ -314,6 +345,7 @@ class CapabilitiesView(APIView): methods = { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/artifacts/{id}/raw": ["GET"], "/api/assets": ["POST"], "/api/capabilities": ["GET"], "/api/skill": ["GET"], @@ -1274,6 +1306,7 @@ def serialize_artifact(artifact, request): "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), + "api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])), "raw_available": raw_artifact_path(artifact) is not None, "source": artifact.source, "title": artifact.title, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 72aff5b..47770b1 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.3.1" +__version__ = "0.3.2" diff --git a/docs/api.md b/docs/api.md index e804074..187270c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -465,26 +465,28 @@ curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ ## Rendered And Raw Content -Rendered browser view: +**For agents (bearer token):** fetch the full content from the API — separate +from the metadata `GET /api/artifacts/{id}`: ```http -GET /a/{id} +GET /api/artifacts/{id}/raw ``` -Raw/source view: +Same auth as the detail endpoint (bearer token or session), owner-scoped, so an +agent can read back and verify exactly what it posted. The metadata response +includes `api_raw_url` pointing here. -```http -GET /a/{id}?raw=1 -``` +- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as + `text/plain`. +- `image` / `pdf` / `binary` → the stored file bytes (with its content type). +- `html_fragment` / `deck` → `404` (render-only; no raw source is kept). -Raw currently exists for: +Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line +endings. -| Kind | Raw content | -|---|---| -| `markdown` | Preserved `.md` source. | -| `html_full` | Stored HTML file served as text. | -| `json` | Preserved original JSON text. | -| `csv` | Preserved original CSV text. | +**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and +`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint +above for token access. If raw content is unavailable, AgentFeed returns a small HTML fallback page. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 34193b7..0c8797c 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -253,6 +253,46 @@ class ArtifactAPITestCase(APITestCase): response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) self.assertEqual(response.status_code, 403) + # -- raw content (GET {id}/raw) --------------------------------------- + + def _raw_body(self, response): + return b"".join(response.streaming_content).decode() + + def test_raw_returns_exact_content_with_bearer(self): + create = self.create_artifact(content="# Exact\n\nverify me") + artifact_id = create.data["id"] + # metadata detail advertises the raw endpoint + self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw")) + + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") + # Content round-trips faithfully; normalize newlines since the stored + # sidecar is written in the host's text mode (CRLF on Windows dev, LF on + # the Linux deploy). + self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me") + + def test_raw_requires_auth(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.get(reverse("artifact-raw-api", args=[artifact_id])) + self.assertEqual(response.status_code, 403) + + def test_raw_owner_scoped(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token) + ) + self.assertEqual(response.status_code, 404) + + def test_raw_not_available_for_render_only_kind(self): + artifact_id = self.create_artifact(kind="html_fragment", content="

hi

").data["id"] + response = self.client.get( + reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 404) + def test_patch_updates_title_and_tags(self): artifact_id = self.create_artifact().data["id"] response = self.client.patch( From cae414c307527c7221a294e51942e74d5f5d1af2 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 23:02:07 +0100 Subject: [PATCH 06/16] v0.4.0: live feed updates (auto-insert new posts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New artifacts appear at the top of the feed without a manual refresh. - GET /feed/updates (session-authed): new artifacts since a timestamp marker, owner-scoped and filter-aware (source/label/kind/q), returned as row HTML. - Feed JS polls every 5s, paused while the tab is hidden (immediate poll on refocus). Prepends new rows into today's group without touching the selected row or the preview iframe — what you're reading stays put and selected. - Fresh rows show "now", aging into their real time after ~3 min; brief highlight on arrival. - Extracted _artifact_rows.html partial, shared by the day-group render and the updates endpoint, so live rows match exactly. - Polling chosen over SSE/WebSockets: zero infra on sync-WSGI + SQLite (see planning/next_steps for the SSE upgrade path). 142 tests pass; feed page + endpoint verified. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 20 +++++- artifacts/urls.py | 1 + artifacts/views.py | 59 +++++++++++++++- daybook_project/__init__.py | 2 +- static/agentfeed.css | 10 +++ templates/artifacts/_artifact_rows.html | 34 +++++++++ templates/artifacts/_day_groups.html | 37 +--------- templates/artifacts/feed.html | 91 ++++++++++++++++++++++++- tests/views/test_feed_updates.py | 75 ++++++++++++++++++++ 9 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 templates/artifacts/_artifact_rows.html create mode 100644 tests/views/test_feed_updates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f8719..cf4ed70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ former name, "Daybook".) _Nothing yet._ +## [0.4.0] — 2026-09-04 + +### Added +- **Live feed updates.** New artifacts now appear at the top of the feed + automatically, without a manual refresh — the browser polls a lightweight + `GET /feed/updates` every 5s (paused while the tab is hidden, one immediate + poll on refocus). Owner-scoped and **respects the active source/label/kind/q + filters**, so a new post that doesn't match your current view won't show. The + currently selected/previewed artifact stays put and visibly selected. Fresh + rows display "now" and age into their real time after a few minutes. + (Chosen over SSE/WebSockets deliberately: zero new infrastructure on the + sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.) + +### Changed +- Extracted the feed row markup into a reusable `_artifact_rows.html` partial + (shared by the day-group render and the live-updates endpoint). + ## [0.3.2] — 2026-09-04 ### Added @@ -138,7 +155,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.3.2...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...HEAD +[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 [0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 diff --git a/artifacts/urls.py b/artifacts/urls.py index 008ceb5..cc1f03c 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,6 +6,7 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), path("feed/older", views.feed_older, name="feed-older"), + path("feed/updates", views.feed_updates, name="feed-updates"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), diff --git a/artifacts/views.py b/artifacts/views.py index d82eed9..1a6afc3 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -22,9 +22,10 @@ from django.db import OperationalError, transaction from django.db.models import Count, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.template.defaultfilters import slugify +from django.template.defaultfilters import date as date_filter, slugify +from django.template.loader import render_to_string from django.urls import reverse -from django.utils.dateparse import parse_date +from django.utils.dateparse import parse_date, parse_datetime from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -573,6 +574,8 @@ def feed(request): "day_groups": day_groups, "older_count": older_count, "window_start": window_start, + # Marker for live polling: newest artifact shown, else now. + "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "source_counts": source_counts, "total_count": source_scoped.filter(read_at__isnull=True).count(), "all_labels": labels_with_colors_list(all_labels, overrides), @@ -666,6 +669,58 @@ def feed_older(request): ) +@login_required +def feed_updates(request): + """New artifacts since a timestamp, as row HTML for the feed to prepend live. + Owner-scoped and filter-aware — respects the source/label/kind/q the browser + is currently viewing, so a new post that doesn't match won't appear.""" + after = parse_datetime(request.GET.get("after", "") or "") + if after is None: + return JsonResponse({"count": 0}) + + selected_source = request.GET.get("source", "") + selected_label = request.GET.get("label", "") + selected_kind = request.GET.get("kind", "") + title_query = request.GET.get("q", "") + artifacts = apply_feed_filters( + Artifact.objects.filter(owner=request.user), + selected_source, + selected_label, + selected_kind, + title_query, + ).filter(created_at__gt=after).order_by("-created_at") + + new = list(artifacts[:100]) + if not new: + return JsonResponse({"count": 0}) + + overrides = tag_color_overrides() + for artifact in new: + artifact.label_chips = tags_with_colors(artifact.tags, overrides) + + html = render_to_string( + "artifacts/_artifact_rows.html", + { + "artifacts": new, + "selected_artifact": None, + "selected_source": selected_source, + "selected_label": selected_label, + "selected_kind": selected_kind, + "title_query": title_query, + "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", + }, + request=request, + ) + return JsonResponse( + { + "count": len(new), + "newest": new[0].created_at.isoformat(), + "today_label": date_filter(timezone.localdate(), "D, M j Y"), + "html": html, + } + ) + + @login_required @xframe_options_sameorigin def artifact_view(request, artifact_id): diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 47770b1..bc935aa 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.3.2" +__version__ = "0.4.0" diff --git a/static/agentfeed.css b/static/agentfeed.css index 805b76b..865c1a0 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1045,6 +1045,16 @@ kbd { color: var(--muted); } +/* Newly live-inserted feed rows briefly highlight, then settle. */ +@keyframes agentfeed-row-arrive { + from { background: var(--surface-alt); } + to { background: transparent; } +} + +.artifact-row.just-arrived { + animation: agentfeed-row-arrive 1.5s ease-out; +} + .api-key-create { align-items: end; display: flex; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html new file mode 100644 index 0000000..1125e01 --- /dev/null +++ b/templates/artifacts/_artifact_rows.html @@ -0,0 +1,34 @@ +{% for artifact in artifacts %} + + + + {{ artifact.title }} + {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} + K + 🔗 + {% if artifact.label_chips %} + + {% for label in artifact.label_chips %} + {{ label.name }} + {% endfor %} + + {% endif %} + + {{ artifact.source }} + +{% endfor %} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 111fb86..63971f4 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,43 +1,10 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 55bfdb7..a240ebc 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -55,7 +55,7 @@ {{ artifacts|length }} artifact{{ artifacts|length|pluralize }}
-
+
{% if day_groups %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% else %} @@ -552,6 +552,95 @@ observeSentinel(); + // --- Live updates: poll for new artifacts and prepend them at the top. --- + // Cheap: a small "anything since X?" fetch every few seconds, paused while + // the tab is hidden. New rows are inserted without touching the selected + // row or the preview, so what you're reading stays put. Respects the + // active source/label/kind/q filters (passed through below). + const LIVE_POLL_MS = 5000; + const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time + let liveSince = artifactList.dataset.since || null; + let livePolling = false; + + const buildUpdatesUrl = () => { + const url = new URL("{% url 'feed-updates' %}", window.location.origin); + const p = url.searchParams; + if (liveSince) p.set("after", liveSince); + p.set("view", feed.dataset.selectedView || "rendered"); + if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource); + if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel); + if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind); + if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery); + return url; + }; + + const ensureTodayGroup = (todayLabel) => { + let group = artifactList.querySelector(".day-group[data-today]"); + if (group) return group; + const empty = artifactList.querySelector(".empty"); + if (empty) empty.remove(); + group = document.createElement("div"); + group.className = "day-group"; + group.setAttribute("data-today", "1"); + group.innerHTML = + `
${escapeHtml(todayLabel)} Today` + + `0
`; + artifactList.insertBefore(group, artifactList.firstChild); + return group; + }; + + const markArrived = (rows) => { + rows.forEach((row) => { + const timeEl = row.querySelector(".artifact-time"); + if (timeEl) { + timeEl.dataset.realtime = timeEl.textContent; + timeEl.textContent = "now"; + setTimeout(() => { + if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime; + }, NOW_TTL_MS); + } + row.classList.add("just-arrived"); + setTimeout(() => row.classList.remove("just-arrived"), 1500); + }); + }; + + const pollUpdates = async () => { + if (livePolling || document.hidden || !liveSince) return; + livePolling = true; + try { + const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}}); + if (!response.ok) return; + const data = await response.json(); + if (!data.count) return; + const group = ensureTodayGroup(data.today_label); + const separator = group.querySelector(".day-separator"); + // Newest-first batch goes immediately below the separator, above prior rows. + separator.insertAdjacentHTML("afterend", data.html); + const inserted = []; + let node = separator.nextElementSibling; + while (node && inserted.length < data.count) { + if (node.classList.contains("artifact-row")) inserted.push(node); + node = node.nextElementSibling; + } + markArrived(inserted); + liveSince = data.newest; + const dayCount = group.querySelector(".day-separator-count"); + if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count); + const header = document.querySelector(".artifact-sidebar .pane-heading span"); + if (header) { + const total = (parseInt(header.textContent, 10) || 0) + data.count; + header.textContent = `${total} artifact${total === 1 ? "" : "s"}`; + } + } catch { + // ignore; retry next tick + } finally { + livePolling = false; + } + }; + + setInterval(pollUpdates, LIVE_POLL_MS); + document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); }); + // Populate the details panel for a server-preselected artifact so the first // "Details" click shows content immediately. const preselected = selectedRow(); diff --git a/tests/views/test_feed_updates.py b/tests/views/test_feed_updates.py new file mode 100644 index 0000000..e1610af --- /dev/null +++ b/tests/views/test_feed_updates.py @@ -0,0 +1,75 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from artifacts.models import Artifact + +User = get_user_model() + + +class FeedUpdatesViewTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="alice", password="pw") + self.other = User.objects.create_user(username="bob", password="pw") + self.client.force_login(self.user) + self._n = 0 + + def make(self, owner=None, title="Note", source="agent"): + self._n += 1 + return Artifact.objects.create( + id=f"upd{self._n:05d}", + owner=owner or self.user, + source=source, + title=title, + kind=Artifact.MARKDOWN, + day=timezone.localdate(), + path=f"{source}/x.html", + original_kind=Artifact.MARKDOWN, + size_bytes=1, + ) + + def marker(self, seconds_ago=60): + return (timezone.now() - timedelta(seconds=seconds_ago)).isoformat() + + def get(self, **params): + return self.client.get(reverse("feed-updates"), params) + + def test_requires_login(self): + self.client.logout() + response = self.get(after=self.marker()) + self.assertEqual(response.status_code, 302) + + def test_returns_new_since_marker(self): + self.make(title="Fresh Post") + response = self.get(after=self.marker()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["count"], 1) + self.assertIn("Fresh Post", data["html"]) + self.assertIn("newest", data) + self.assertIn("today_label", data) + + def test_nothing_newer_than_now(self): + self.make() + response = self.get(after=self.marker(seconds_ago=-60)) # 60s in the future + self.assertEqual(response.json()["count"], 0) + + def test_missing_marker_returns_zero(self): + self.make() + self.assertEqual(self.get().json()["count"], 0) + + def test_owner_scoped(self): + self.make(owner=self.other, title="Not mine") + data = self.get(after=self.marker()).json() + self.assertEqual(data["count"], 0) + + def test_respects_source_filter(self): + self.make(title="Hermes post", source="hermes") + self.make(title="Janus post", source="janus") + data = self.get(after=self.marker(), source="hermes").json() + self.assertEqual(data["count"], 1) + self.assertIn("Hermes post", data["html"]) + self.assertNotIn("Janus post", data["html"]) From 6c1823e65eedc4fa5b4858f67e232cfbb7f87e91 Mon Sep 17 00:00:00 2001 From: peter Date: Sat, 5 Sep 2026 14:37:45 +0100 Subject: [PATCH 07/16] v0.4.1: live updates also refresh the source sidebar Extends v0.4.0 without a second request: the sidebar is updated client-side from the rows the existing /feed/updates poll already returns. - Each new row's data-source drives the sidebar: per-source + "All sources" unread counts bump; a first-time source is inserted into the list (alphabetically). Reuses the existing count-badge pattern. - sourceLinks is now live-queried (was cached at load) so sources added live stay keyboard-navigable. - No endpoint/model change. 142 tests pass; feed renders. Caveat: while filtered to a single source, other new sources appear only on reload (the poll is scoped to the selected view). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++++++- daybook_project/__init__.py | 2 +- templates/artifacts/feed.html | 73 ++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4ed70..5e1ed0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ former name, "Daybook".) _Nothing yet._ +## [0.4.1] — 2026-09-05 + +### Changed +- Live feed updates now also keep the **source sidebar** current: as new posts + arrive, per-source and total unread counts bump, and a source posting for the + first time is added to the list (alphabetically) — all derived client-side + from the polled rows, no extra request. Sources added live stay + keyboard-navigable. Caveat: while filtered to a single source, *other* new + sources still only appear on reload (the poll is scoped to the selected view). + ## [0.4.0] — 2026-09-04 ### Added @@ -155,7 +165,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.4.0...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...HEAD +[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 [0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index bc935aa..53f5709 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.4.0" +__version__ = "0.4.1" diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index a240ebc..78764fe 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -195,7 +195,8 @@ const artifactList = document.querySelector("[data-artifact-list]"); // Rows grow as older pages load, so always read them live rather than caching an array. const allRows = () => Array.from(document.querySelectorAll(".artifact-row")); - const sourceLinks = Array.from(document.querySelectorAll(".source-link")); + // Live-queried so sources added by live updates are keyboard-navigable too. + const sourceLinks = () => Array.from(document.querySelectorAll(".source-link")); const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({ @@ -383,6 +384,66 @@ }); }; + const findSourceLink = (name) => + sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name); + + const bumpSourceBadge = (link, delta) => { + let badge = link.querySelector(".source-count"); + if (!badge) { + badge = document.createElement("span"); + badge.className = "source-count"; + badge.textContent = "0"; + link.appendChild(badge); + } + badge.textContent = String((Number(badge.textContent) || 0) + delta); + }; + + const addSourceLink = (name, count) => { + const params = new URLSearchParams(); + params.set("source", name); + if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel); + if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind); + if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery); + params.set("view", feed.dataset.selectedView || "rendered"); + params.set("preview", "none"); + + const link = document.createElement("a"); + link.className = "source-link"; + link.href = `?${params.toString()}`; + link.innerHTML = + `${escapeHtml(name)}` + + `${count}`; + + // Insert alphabetically among the per-source links (after "All sources"). + const list = document.querySelector(".source-list"); + const others = sourceLinks().filter( + (l) => l.querySelector(".source-name")?.textContent.trim() !== "All sources", + ); + const after = others.find( + (l) => (l.querySelector(".source-name")?.textContent.trim() || "") > name, + ); + list.insertBefore(link, after || null); + }; + + // From the newly-arrived rows: bump each source's unread count (adding a + // sidebar entry for a source seen for the first time) plus the total. + const registerNewSources = (rows) => { + const counts = {}; + rows.forEach((row) => { + const source = row.dataset.source; + if (source) counts[source] = (counts[source] || 0) + 1; + }); + let total = 0; + Object.entries(counts).forEach(([source, n]) => { + total += n; + const existing = findSourceLink(source); + if (existing) bumpSourceBadge(existing, n); + else addSourceLink(source, n); + }); + const allLink = findSourceLink("All sources"); + if (allLink && total) bumpSourceBadge(allLink, total); + }; + const renderPreview = (row, pushState = true) => { navigationMode = "artifacts"; markRead(row); @@ -438,12 +499,13 @@ }; const moveSource = (direction) => { - if (!sourceLinks.length) return; + const links = sourceLinks(); + if (!links.length) return; navigationMode = "sources"; const current = document.querySelector(".source-link.selected"); - const currentIndex = current ? sourceLinks.indexOf(current) : 0; - const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction)); - const nextSource = sourceLinks[nextIndex]; + const currentIndex = current ? links.indexOf(current) : 0; + const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction)); + const nextSource = links[nextIndex]; if (nextSource && nextSource !== current) window.location.href = nextSource.href; }; @@ -623,6 +685,7 @@ node = node.nextElementSibling; } markArrived(inserted); + registerNewSources(inserted); // update sidebar: counts + any new source liveSince = data.newest; const dayCount = group.querySelector(".day-separator-count"); if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count); From 4254164befc2eb4279a1e676fd4c6a9ba0604d2f Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 12:48:43 +0100 Subject: [PATCH 08/16] v0.5.0: Trash (soft-delete) + Keep folder 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 --- CHANGELOG.md | 25 +++- CLAUDE.md | 1 + .../migrations/0016_artifact_deleted_at.py | 18 +++ artifacts/models.py | 7 ++ artifacts/storage.py | 21 +++- artifacts/urls.py | 3 + artifacts/views.py | 107 ++++++++++++++---- daybook_project/__init__.py | 2 +- docs/api.md | 12 +- static/agentfeed.css | 51 +++++++++ templates/artifacts/_artifact_rows.html | 5 + templates/artifacts/feed.html | 105 ++++++++++++++++- tests/api/test_artifacts.py | 17 ++- tests/views/test_trash.py | 107 ++++++++++++++++++ 14 files changed, 438 insertions(+), 43 deletions(-) create mode 100644 artifacts/migrations/0016_artifact_deleted_at.py create mode 100644 tests/views/test_trash.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1ed0f..e23c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,27 @@ former name, "Daybook".) _Nothing yet._ -## [0.4.1] — 2026-09-05 +## [0.5.0] — 2026-09-07 + +### Added +- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable + **Trash** instead of removing it. Feed rows get a hover **Delete** icon; a + **"Deleted"** folder at the bottom of the sidebar lists trashed items with a + hover **Undelete**, plus an **Empty Trash** button to purge permanently. + Trashed items are **off-quota** (deleting frees quota immediately) and + eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too + (idempotent); trashed items are hidden from all API reads. +- **Keep folder.** A sidebar folder listing kept artifacts (a view over the + existing `is_kept` flag), mirroring Trash. Both hide when empty. +- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`, + `/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views. + +### Changed +- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above. + +### Note +- Permanent purge is manual (**Empty Trash**) in this release; automatic + purge-on-delete (background, ~3-day retention) is the planned fast-follow. ### Changed - Live feed updates now also keep the **source sidebar** current: as new posts @@ -165,7 +185,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.4.1...HEAD +[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 [0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 diff --git a/CLAUDE.md b/CLAUDE.md index 5db5bbd..75af818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +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.) - 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/migrations/0016_artifact_deleted_at.py b/artifacts/migrations/0016_artifact_deleted_at.py new file mode 100644 index 0000000..0454f2f --- /dev/null +++ b/artifacts/migrations/0016_artifact_deleted_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-09-07 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0015_migrate_tokens_to_apikeys'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='deleted_at', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index bbd14a8..dd87793 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -60,6 +60,9 @@ class Artifact(models.Model): metadata = models.JSONField(default=dict, blank=True) read_at = models.DateTimeField(null=True, blank=True) is_kept = models.BooleanField(default=False) + # Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota, + # recoverable via undelete, purged by Empty Trash). NULL = live. + deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) size_bytes = models.BigIntegerField(default=0) # NULL = not shared. Many rows share NULL (SQL treats each NULL as # distinct, so `unique=True` still holds); a real value must be unique @@ -74,6 +77,10 @@ class Artifact(models.Model): def is_shared(self): return bool(self.share_token) + @property + def is_deleted(self): + return self.deleted_at is not None + class Meta: ordering = ["-created_at"] diff --git a/artifacts/storage.py b/artifacts/storage.py index 1ac53cd..2a7f7bd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -70,8 +70,13 @@ def artifact_total_bytes(artifact): def user_usage_bytes(user): + # Trashed artifacts are off-quota (deleting relieves quota immediately); + # their files linger on disk until Empty Trash / purge, but don't count. artifact_bytes = ( - Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 + Artifact.objects.filter(owner=user, deleted_at__isnull=True) + .aggregate(total=Sum("size_bytes")) + .get("total") + or 0 ) asset_bytes = ( Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 @@ -113,8 +118,10 @@ def make_room_for(user, incoming_bytes): if used + incoming_bytes <= quota: return [] + # Trashed artifacts are eviction-protected (recovery window is guaranteed) + # and already off-quota, so they're never eviction candidates. unkept = list( - Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") + Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at") ) # Check whether eviction can possibly succeed before deleting anything - # otherwise a doomed request would still destroy every unkept artifact on @@ -137,6 +144,16 @@ def make_room_for(user, incoming_bytes): return evicted +def purge_trashed(user): + """Permanently remove all of a user's trashed artifacts (files + rows). + Backs the "Empty Trash" action.""" + trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False)) + for artifact in trashed: + delete_artifact_files(artifact) + artifact.delete() + return len(trashed) + + ASSET_ORPHAN_GRACE = timedelta(hours=24) diff --git a/artifacts/urls.py b/artifacts/urls.py index cc1f03c..158b3d1 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -10,6 +10,9 @@ urlpatterns = [ path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), + path("a//delete", views.delete_artifact, name="artifact-delete"), + path("a//undelete", views.undelete_artifact, name="artifact-undelete"), + path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), path("a/", views.artifact_view, name="artifact"), path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), diff --git a/artifacts/views.py b/artifacts/views.py index 1a6afc3..84ddfa1 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -44,6 +44,7 @@ from .storage import ( delete_artifact_files, disk_free_bytes, make_room_for, + purge_trashed, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -90,7 +91,7 @@ class ArtifactListCreateView(APIView): user = resolve_request_user(request) if user is None: return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifacts = Artifact.objects.filter(owner=user) + artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -231,7 +232,7 @@ class ArtifactDetailView(APIView): user = resolve_request_user(request) if user is None: return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -239,7 +240,7 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: @@ -298,9 +299,12 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") + # Soft-delete: move to Trash (recoverable, off-quota) rather than + # permanently removing. Purged later via Empty Trash. Idempotent. artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) - delete_artifact_files(artifact) - artifact.delete() + if artifact.deleted_at is None: + artifact.deleted_at = timezone.now() + artifact.save(update_fields=["deleted_at"]) return Response(status=204) @@ -318,7 +322,7 @@ class ArtifactRawView(APIView): user = resolve_request_user(request) if user is None: return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) raw_path = raw_artifact_path(artifact) if raw_path and raw_path.exists(): @@ -516,21 +520,40 @@ def feed(request): selected_artifact_id = request.GET.get("artifact", "") preview_none = request.GET.get("preview") == "none" selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" + selected_folder = request.GET.get("folder", "") + if selected_folder not in ("deleted", "keep"): + selected_folder = "" - all_artifacts = Artifact.objects.filter(owner=request.user) + # Live (non-trashed) artifacts drive the normal feed and the source sidebar. + live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True) + if selected_folder == "deleted": + # System folder: all trashed items, not source-scoped. + selected_source = "" + base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False) + elif selected_folder == "keep": + selected_source = "" + base = live.filter(is_kept=True) + else: + base = live artifacts = apply_feed_filters( - all_artifacts, selected_source, selected_label, selected_kind, title_query + base, selected_source, selected_label, selected_kind, title_query ) - window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) - if not artifacts.filter(day__gte=window_start).exists(): - # Nothing recent for these filters: fall back to the most recent days that - # do match, so selecting a source is never met with a blank list. - recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) - if recent_days: - window_start = recent_days[-1] - window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) - older_count = artifacts.filter(day__lt=window_start).count() + if selected_folder: + # System folders show everything (no 7-day window / infinite scroll). + window_start = today + window_artifacts = list(artifacts.order_by("-created_at")) + older_count = 0 + else: + window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) + if not artifacts.filter(day__gte=window_start).exists(): + # Nothing recent for these filters: fall back to the most recent days + # that do match, so selecting a source is never met with a blank list. + recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) + if recent_days: + window_start = recent_days[-1] + window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) + older_count = artifacts.filter(day__lt=window_start).count() overrides = tag_color_overrides() day_groups = group_artifacts_by_day(window_artifacts, overrides) @@ -540,6 +563,8 @@ def feed(request): selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None) toggle_query = {"view": "rendered"} + if selected_folder: + toggle_query["folder"] = selected_folder if selected_source: toggle_query["source"] = selected_source if selected_label: @@ -554,18 +579,20 @@ def feed(request): toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - # Source counts come from the other filters only, so the sidebar still shows - # every source available under the current label/type/name filters. + # Source counts + labels always come from live artifacts, so the sidebar is + # the same whichever folder you're viewing. source_scoped = apply_feed_filters( - all_artifacts, "", selected_label, selected_kind, title_query + live, "", selected_label, selected_kind, title_query ) source_counts = ( source_scoped.values("source") .annotate(count=Count("id", filter=Q(read_at__isnull=True))) .order_by("source") ) + keep_count = live.filter(is_kept=True).count() + trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count() - all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) + all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags}) return render( request, "artifacts/feed.html", @@ -578,6 +605,10 @@ def feed(request): "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "source_counts": source_counts, "total_count": source_scoped.filter(read_at__isnull=True).count(), + "keep_count": keep_count, + "trash_count": trash_count, + "selected_folder": selected_folder, + "in_trash": selected_folder == "deleted", "all_labels": labels_with_colors_list(all_labels, overrides), "raw_url": raw_url, "rendered_url": rendered_url, @@ -630,12 +661,42 @@ def toggle_artifact_share(request, artifact_id): return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url}) +@login_required +@require_POST +def delete_artifact(request, artifact_id): + """Soft-delete: move to Trash (recoverable, off-quota). Idempotent.""" + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + if artifact.deleted_at is None: + artifact.deleted_at = timezone.now() + artifact.save(update_fields=["deleted_at"]) + return JsonResponse({"id": artifact.id, "deleted": True}) + + +@login_required +@require_POST +def undelete_artifact(request, artifact_id): + """Restore a trashed artifact back to the live feed.""" + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + if artifact.deleted_at is not None: + artifact.deleted_at = None + artifact.save(update_fields=["deleted_at"]) + return JsonResponse({"id": artifact.id, "deleted": False}) + + +@login_required +@require_POST +def empty_trash(request): + """Permanently purge all of the user's trashed artifacts.""" + count = purge_trashed(request.user) + return JsonResponse({"purged": count}) + + @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" before = parse_date(request.GET.get("before", "")) or timezone.localdate() artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user), + Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), @@ -683,7 +744,7 @@ def feed_updates(request): selected_kind = request.GET.get("kind", "") title_query = request.GET.get("q", "") artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user), + Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), selected_source, selected_label, selected_kind, diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 53f5709..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.4.1" +__version__ = "0.5.0" diff --git a/docs/api.md b/docs/api.md index 187270c..5135f76 100644 --- a/docs/api.md +++ b/docs/api.md @@ -452,11 +452,15 @@ DELETE /api/artifacts/{id} Authorization: Bearer ``` -Removes the artifact's database row and its files (primary file, markdown -sidecar, and any attached assets). Returns `204` with no body on success. +**Soft-delete:** moves the artifact to Trash rather than removing it — it's +recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden +from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it +as gone). Files stay on disk until the trash is purged (Empty Trash in the app). +Returns `204` with no body. -You can only delete artifacts your own token owns; others return `404`, as -does a second delete of the same id. +**Idempotent:** deleting an already-trashed id returns `204` again (not `404`). +You can only delete artifacts your own token owns; another user's returns `404`. +There is no API endpoint to undelete or purge yet — that's app-UI-only. ```bash curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ diff --git a/static/agentfeed.css b/static/agentfeed.css index 865c1a0..667a968 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1055,6 +1055,57 @@ kbd { animation: agentfeed-row-arrive 1.5s ease-out; } +/* Hover/focus row actions (delete in the feed, undelete in Trash) */ +.artifact-row { + position: relative; +} + +.row-action { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + opacity: 0; + cursor: pointer; + font-size: 0.95rem; + line-height: 1; + padding: 0.15rem 0.3rem; + border-radius: 4px; + background: var(--surface); + 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); +} + +.row-delete:hover { + color: var(--danger-text-plain); +} + +/* System folders (Keep / Deleted) at the bottom of the sidebar */ +.source-folders { + border-top: 1px solid var(--grid-line); + margin-top: 0.4rem; + padding-top: 0.4rem; +} + +.empty-trash-button { + background: var(--danger-bg); + border: 1px solid var(--danger-text); + border-radius: 6px; + color: var(--danger-text); + cursor: pointer; + font-size: 0.78rem; + padding: 0.2rem 0.55rem; +} + .api-key-create { align-items: end; display: flex; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html index 1125e01..45c2f2f 100644 --- a/templates/artifacts/_artifact_rows.html +++ b/templates/artifacts/_artifact_rows.html @@ -30,5 +30,10 @@ {% endif %} {{ artifact.source }} + {% if in_trash %} + ↩ + {% else %} + 🗑 + {% endif %} {% endfor %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 78764fe..90a26bb 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -27,6 +27,7 @@ data-selected-kind="{{ selected_kind }}" data-title-query="{{ title_query }}" data-selected-view="{{ selected_view }}" + data-folder="{{ selected_folder }}" data-window-start="{{ window_start|date:'Y-m-d' }}" data-older-count="{{ older_count }}">
@@ -51,8 +69,12 @@
+ +